Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b61a63686 | ||
|
|
18e45659ea | ||
|
|
426637a47c | ||
|
|
ababf7d4fa | ||
|
|
691bb5640d | ||
|
|
a80eb2ff0e | ||
|
|
fe59f9bedf | ||
|
|
0f8838623b | ||
|
|
5b5945e427 | ||
|
|
dea57bd1be | ||
|
|
3e81a38438 | ||
|
|
dfdf3d30b3 | ||
|
|
60b64d3e81 | ||
|
|
962fba4d16 | ||
|
|
de218e900b | ||
|
|
b75e7cef90 | ||
|
|
9b1224827a | ||
|
|
c7f78fa7fc | ||
|
|
7dade7f466 | ||
|
|
b20e1150a5 | ||
|
|
aa0d6a6831 | ||
|
|
663faa1f82 | ||
|
|
99b13fa25f | ||
|
|
e12c1319b6 | ||
|
|
7f365bf60b | ||
|
|
2e6c78d723 | ||
|
|
f9c64d9f65 | ||
|
|
4ef6926791 | ||
|
|
9645f1011c | ||
|
|
9847e60dca | ||
|
|
cb3d9ab625 | ||
|
|
db41fb454e | ||
|
|
3b133d5554 | ||
|
|
80f763448f | ||
|
|
a3701f5de8 | ||
|
|
ed2bfc09a6 | ||
|
|
337f821e00 | ||
|
|
f2b126f147 | ||
|
|
1173bc277b | ||
|
|
50f2cc16ab | ||
|
|
eebe319bfd | ||
|
|
75a42b77ea | ||
|
|
d71d3450af | ||
|
|
f080159268 | ||
|
|
0183d32c7f | ||
|
|
94f5894d7f | ||
|
|
81e5a62f25 | ||
|
|
186381bab2 | ||
|
|
e044488f73 | ||
|
|
b077974ebc | ||
|
|
200540dfc3 | ||
|
|
3c00f46e36 | ||
|
|
620721dea7 | ||
|
|
68cbe34c11 | ||
|
|
e21b010c6e | ||
|
|
0846d4d7b2 | ||
|
|
2183a2a1ae | ||
|
|
e8475b18d3 | ||
|
|
cdc1177762 | ||
|
|
97035e0b8b | ||
|
|
aaf62c1c96 | ||
|
|
4d0661fd9b | ||
|
|
713b214c9a | ||
|
|
681388631b | ||
|
|
ae9e27a0c7 | ||
|
|
e83144ce7f | ||
|
|
1c54c7130a | ||
|
|
1a8d5fed8a | ||
|
|
57ebf43e85 | ||
|
|
b87734343e | ||
|
|
c80c9ef726 | ||
|
|
d5758523f3 | ||
|
|
ee40e7e0d7 | ||
|
|
53b606e1c1 | ||
|
|
47c0141c49 | ||
|
|
5b8a17e366 | ||
|
|
64a86b899f | ||
|
|
312e295a47 | ||
|
|
e7544687d1 | ||
|
|
291de5bc1c | ||
|
|
c37a9f5ec3 | ||
|
|
f5cef205f1 | ||
|
|
afb7266f17 | ||
|
|
e639c77aa2 | ||
|
|
ea97055449 | ||
|
|
64dc6ab9ac | ||
|
|
25dedd1098 | ||
|
|
617bf7b6a3 | ||
|
|
e3a53de8a6 | ||
|
|
4cc91fd5b1 | ||
|
|
296a12e394 | ||
|
|
da7aa1d72f | ||
|
|
367ad8ea43 | ||
|
|
2c3bc2d75e | ||
|
|
3cce6ca02b |
+40
-43
@@ -74,8 +74,8 @@ jobs:
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y --only-upgrade binutils
|
||||
@@ -83,6 +83,20 @@ jobs:
|
||||
stack setup
|
||||
rm -rf $(stack path --dist-dir) $(stack path --local-install-root)
|
||||
stack install hlint stylish-haskell
|
||||
- run:
|
||||
name: Add stack tools to $PATH
|
||||
command: |
|
||||
echo "export PATH=/home/circleci/.local/bin:$PATH" >> $BASH_ENV
|
||||
- run:
|
||||
name: build src and tests dependencies
|
||||
command: |
|
||||
stack build --fast -j1 --only-dependencies
|
||||
stack build --fast --test --no-run-tests --only-dependencies
|
||||
- save_cache:
|
||||
paths:
|
||||
- "~/.stack"
|
||||
- ".stack-work"
|
||||
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
- run:
|
||||
name: build src and tests
|
||||
command: |
|
||||
@@ -95,15 +109,10 @@ jobs:
|
||||
test/io-tests.sh
|
||||
- run:
|
||||
name: run linter
|
||||
command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$@"
|
||||
command: make lint
|
||||
- run:
|
||||
name: run styler
|
||||
command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- stylish-haskell -i && git diff-index --exit-code HEAD --
|
||||
- save_cache:
|
||||
paths:
|
||||
- "~/.stack"
|
||||
- ".stack-work"
|
||||
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
command: make style
|
||||
|
||||
build-test-9.6:
|
||||
docker:
|
||||
@@ -122,8 +131,8 @@ jobs:
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y postgresql-client
|
||||
@@ -135,7 +144,7 @@ jobs:
|
||||
stack build --fast --test --no-run-tests
|
||||
- run:
|
||||
name: run tests
|
||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
|
||||
|
||||
build-test-10:
|
||||
docker:
|
||||
@@ -154,8 +163,8 @@ jobs:
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y postgresql-client
|
||||
@@ -167,14 +176,14 @@ jobs:
|
||||
stack build --fast --test --no-run-tests
|
||||
- run:
|
||||
name: run tests
|
||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
|
||||
|
||||
build-test-11:
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:trusty
|
||||
environment:
|
||||
- PGHOST=localhost
|
||||
- image: circleci/postgres:11
|
||||
- image: circleci/postgres:11.4
|
||||
environment:
|
||||
- POSTGRES_USER=circleci
|
||||
- POSTGRES_DB=circleci
|
||||
@@ -186,8 +195,8 @@ jobs:
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y postgresql-client
|
||||
@@ -199,7 +208,7 @@ jobs:
|
||||
stack build --fast --test --no-run-tests
|
||||
- run:
|
||||
name: run tests
|
||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
|
||||
|
||||
build-prof-test:
|
||||
docker:
|
||||
@@ -219,12 +228,21 @@ jobs:
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y postgresql-client
|
||||
stack setup
|
||||
- run:
|
||||
name: build dependencies with profiling enabled
|
||||
command: |
|
||||
stack build --profile -j1 --only-dependencies
|
||||
- save_cache:
|
||||
paths:
|
||||
- "~/.stack"
|
||||
- ".stack-work"
|
||||
key: v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
- run:
|
||||
name: build with profiling enabled
|
||||
command: |
|
||||
@@ -240,14 +258,6 @@ jobs:
|
||||
psql "postgres:///postgrest_test" -f test/fixtures/jsonschema.sql
|
||||
psql "postgres:///postgrest_test" -f test/fixtures/privileges.sql
|
||||
test/memory-tests.sh
|
||||
- save_cache:
|
||||
paths:
|
||||
- "~/.stack"
|
||||
- ".stack-work"
|
||||
key: v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
|
||||
centos6:
|
||||
<<: *build-distro-bin
|
||||
|
||||
centos7:
|
||||
<<: *build-distro-bin
|
||||
@@ -260,7 +270,7 @@ jobs:
|
||||
|
||||
release:
|
||||
docker:
|
||||
- image: circleci/golang:1.8
|
||||
- image: circleci/golang:1.9
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: /tmp/workspace
|
||||
@@ -308,18 +318,6 @@ workflows:
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
- centos6:
|
||||
requires:
|
||||
- build-test-9.4
|
||||
- build-test-9.6
|
||||
- build-test-10
|
||||
- build-test-11
|
||||
- build-prof-test
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
- centos7:
|
||||
requires:
|
||||
- build-test-9.4
|
||||
@@ -358,7 +356,6 @@ workflows:
|
||||
ignore: /.*/
|
||||
- release:
|
||||
requires:
|
||||
- centos6
|
||||
- centos7
|
||||
- ubuntu
|
||||
- ubuntui386
|
||||
|
||||
@@ -42,14 +42,13 @@ your contributions.
|
||||
* All contributions must pass the tests before being merged. When
|
||||
you create a pull request your code will automatically be tested.
|
||||
|
||||
* All code must also pass [hlint](http://community.haskell.org/~ndm/hlint/)
|
||||
with no warnings. This helps enforce a uniform style for all
|
||||
committers. Continuous integration will check this as well on every
|
||||
pull request.
|
||||
* All code must also pass [hlint](http://community.haskell.org/~ndm/hlint/) and [stylish-haskell](https://github.com/jaspervdj/stylish-haskell)
|
||||
with no warnings. This helps enforce a uniform style for all committers. Continuous integration will check this as well on every
|
||||
pull request. There's a useful Makefile that helps with checking this locally. You can run `make commit-check` to do this manually but
|
||||
we recommend adding it to `.git/hooks/pre-commit` to automatically check this before doing a commit.
|
||||
|
||||
* For help building the Haskell code on your computer check out the [building from
|
||||
source](https://postgrest.com/en/stable/install.html#build-from-source)
|
||||
wiki page.
|
||||
source](https://postgrest.com/en/stable/install.html#build-from-source) docs section.
|
||||
|
||||
### Running Tests
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: postgrest
|
||||
@@ -1,3 +1,7 @@
|
||||
<!--
|
||||
Before reporting a bug:
|
||||
If your database schema has changed while the PostgREST server is running, send the server a SIGUSR1 signal or restart it(http://postgrest.org/en/v5.2/admin.html#schema-reloading) to ensure the schema cache is not stale. This sometimes fixes apparent bugs.
|
||||
-->
|
||||
### Environment
|
||||
|
||||
* PostgreSQL version: (if using docker, specify the image)
|
||||
|
||||
+8
-2
@@ -1,3 +1,5 @@
|
||||
## Travis is only used for building an OSX binary ,
|
||||
## no tests are run here.
|
||||
language: generic
|
||||
|
||||
sudo: false
|
||||
@@ -40,11 +42,15 @@ install:
|
||||
fi
|
||||
|
||||
script:
|
||||
## Building the whole project can take longer than 50 minutes. Since Travis has a global timeout of 50 minutes
|
||||
## we compile for 30 minutes tops(`gtimeout 1800`) and quit compiling with no error.
|
||||
## Since we CACHE the compile results we can continue compiling from where we left off
|
||||
## on the next commit.
|
||||
- gtimeout 1800 stack build --no-terminal --only-snapshot --install-ghc || true
|
||||
- |
|
||||
if test ! "$TRAVIS_TAG"
|
||||
then
|
||||
echo 'No tag pushed. Skipping build.'
|
||||
echo 'No tag pushed. Skip building binary.'
|
||||
else
|
||||
stack build --no-terminal --copy-bins --local-bin-path .
|
||||
fi
|
||||
@@ -59,6 +65,6 @@ script:
|
||||
END='## \['
|
||||
BODY=$(sed -n "1,/$START/d;/$END/q;p" CHANGELOG.md)
|
||||
strip postgrest
|
||||
tar cjf postgrest-$TRAVIS_TAG-osx.tar.xz postgrest
|
||||
tar cJf postgrest-$TRAVIS_TAG-osx.tar.xz postgrest
|
||||
ghr -t $GITHUB_TOKEN -u $OWNER -r $REPO -b "$BODY"--replace $TRAVIS_TAG postgrest-$TRAVIS_TAG-osx.tar.xz
|
||||
fi
|
||||
|
||||
+5
-1
@@ -29,16 +29,20 @@ PostgREST ongoing development is only possible thanks to our Sponsors and Backer
|
||||
## Lead Backers
|
||||
|
||||
- [Daniel Babiak](https://github.com/d-babiak)
|
||||
- [Michel Pelletier](https://github.com/michelp/)
|
||||
- Evans Fernandes
|
||||
|
||||
## Backers
|
||||
|
||||
- Tsingson Qin
|
||||
- Michel Pelletier
|
||||
- Jay Hannah
|
||||
- Robert Stolarz
|
||||
- Kofi Gumbs
|
||||
- Nicholas DiBiase
|
||||
- Christopher Reid
|
||||
- Nathan Bouscal
|
||||
- Daniel Rafaj
|
||||
- David Fenko
|
||||
|
||||
## Former Backers
|
||||
|
||||
|
||||
+58
-1
@@ -9,11 +9,67 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
### Fixed
|
||||
|
||||
## [7.0.0] - 2020-04-03
|
||||
|
||||
### Added
|
||||
|
||||
- #1417, `Accept: application/vnd.pgrst.object+json` behavior is now enforced for POST/PATCH/DELETE regardless of `Prefer: return=representation/minimal` - @dwagin
|
||||
- #1415, Add support for user defined socket permission via `server-unix-socket-mode` config option - @Dansvidania
|
||||
- #1383, Add support for HEAD request - @steve-chavez
|
||||
- #1378, Add support for `Prefer: count=planned` and `Prefer: count=estimated` on GET /table - @steve-chavez, @LorenzHenk
|
||||
- #1327, Add support for optional query parameter `on_conflict` to upsert with specified keys for POST - @ykst
|
||||
- #1430, Allow specifying the foreign key constraint name(`/source?select=fk_constraint(*)`) to disambiguate an embedding - @steve-chavez
|
||||
- #1168, Allow access to the `Authorization` header through the `request.header.authorization` GUC - @steve-chavez
|
||||
- #1435, Add `request.method` and `request.path` GUCs - @steve-chavez
|
||||
- #1088, Allow adding headers to GET/POST/PATCH/PUT/DELETE responses through the `response.headers` GUC - @steve-chavez
|
||||
- #1427, Allow overriding provided headers(Location, Content-Type, etc) through the `response.headers` GUC - @steve-chavez
|
||||
- #1450, Allow multiple schemas to be exposed in one instance. The schema to use can be selected through the headers `Accept-Profile` for GET/HEAD and `Content-Profile` for POST/PATCH/PUT/DELETE - @steve-chavez, @mahmoudkassem
|
||||
|
||||
### Fixed
|
||||
|
||||
- #1301, Fix self join resource embedding on PATCH - @herulume, @steve-chavez
|
||||
- #1389, Fix many to many resource embedding on RPC/PATCH - @steve-chavez
|
||||
- #1355, Allow PATCH/DELETE without `return=minimal` on tables with no select privileges - @steve-chavez
|
||||
- #1361, Fix embedding a VIEW when its source foreign key is UNIQUE - @bwbroersma
|
||||
|
||||
### Changed
|
||||
|
||||
- #1385, bulk RPC call now should be done by specifying a `Prefer: params=multiple-objects` header - @steve-chavez
|
||||
- #1401, resource embedding now outputs an error when multiple relationships between two tables are found - @steve-chavez
|
||||
- #1423, default Unix Socket file mode from 755 to 660 - @dwagin
|
||||
- #1430, Remove embedding with duck typed column names `GET /projects?select=client(*)`- @steve-chavez
|
||||
+ You can rename the foreign key to `client` to make this request work in the new version: `alter table projects rename constraint projects_client_id_fkey to client`
|
||||
- #1413, Change `server-proxy-uri` config option to `openapi-server-proxy-uri` - @steve-chavez
|
||||
|
||||
## [6.0.2] - 2019-08-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- #1369, Change `raw-media-types` to accept a string of comma separated MIME types - @Dansvidania
|
||||
- #1368, Fix long column descriptions being truncated at 63 characters in PostgreSQL 12 - @amedeedaboville
|
||||
- #1348, Go back to converting plus "+" to space " " in querystrings by default - @steve-chavez
|
||||
|
||||
### Deprecated
|
||||
|
||||
- #1348, Deprecate `.` symbol for disambiguating resource embedding(added in #918). The url-safe '!' should be used instead. We refrained from using `+` as part of our syntax because it conflicts with some http clients and proxies.
|
||||
|
||||
## [6.0.1] - 2019-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- #1349, Add user defined raw output media types via `raw-media-types` config option - @Dansvidania
|
||||
- #1243, Add websearch_to_tsquery support - @herulume
|
||||
|
||||
### Fixed
|
||||
|
||||
- #1336, Error when testing on Chrome/Firefox: text/html requested but a single column was not selected - @Dansvidania
|
||||
- #1334, Unable to compile v6.0.0 on windows - @steve-chavez
|
||||
|
||||
## [6.0.0] - 2019-06-21
|
||||
|
||||
### Added
|
||||
|
||||
- #1186, Add support for user defined unix socket via `server-unix-socket` config option
|
||||
- #1186, Add support for user defined unix socket via `server-unix-socket` config option - @Dansvidania
|
||||
- #690, Add `?columns` query parameter for faster bulk inserts, also ignores unspecified json keys in a payload - @steve-chavez
|
||||
- #1239, Add support for resource embedding on materialized views - @vitorbaptista
|
||||
- #1264, Add support for bulk RPC call - @steve-chavez
|
||||
@@ -22,6 +78,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- #790, Allow override of OpenAPI spec through `root-spec` config option - @steve-chavez
|
||||
- #1308, Accept `text/plain` and `text/html` for raw output - @steve-chavez
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- #1223, Fix incorrect OpenAPI externalDocs url - @steve-chavez
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Copyright (c) 2014 Joe Nelson
|
||||
Copyright (c) 2019 Steve Chavez
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.PHONY: check clean lint style test test-watch coverage circleci circleci-prof-test check-dburi prompt-clean prompt-long-process
|
||||
.PHONY: commit-check check clean lint style test test-watch coverage circleci circleci-prof-test check-dburi prompt-clean prompt-long-process
|
||||
|
||||
commit-check: lint style
|
||||
|
||||
check: lint style test
|
||||
|
||||
@@ -9,8 +11,7 @@ lint:
|
||||
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$$@"
|
||||
|
||||
style:
|
||||
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- stylish-haskell -i
|
||||
|
||||
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- stylish-haskell -i && git diff-index --exit-code HEAD -- '*.hs' '*.lhs'
|
||||
|
||||
test: check-dburi
|
||||
stack test
|
||||
@@ -22,14 +23,12 @@ coverage: check-dburi clean
|
||||
stack build --coverage
|
||||
stack test --coverage
|
||||
|
||||
|
||||
circleci: prompt-long-process
|
||||
circleci local execute --job build-test-9.4
|
||||
|
||||
circleci-prof-test: prompt-long-process
|
||||
circleci local execute --job build-prof-test
|
||||
|
||||
|
||||
check-dburi:
|
||||
test -n "$(POSTGREST_TEST_CONNECTION)" # Requires POSTGREST_TEST_CONNECTION environmental variable
|
||||
|
||||
|
||||
@@ -50,13 +50,15 @@ Big thanks to our sponsors! You can join them by supporting PostgREST on [Patreo
|
||||
```bash
|
||||
postgrest --help
|
||||
```
|
||||
## [Documentation](http://postgrest.org)
|
||||
|
||||
Latest documentation is at [postgrest.org](http://postgrest.org). You can contribute to the docs in [PostgREST/postgrest-docs](https://github.com/PostgREST/postgrest-docs).
|
||||
|
||||
## Performance
|
||||
|
||||
TLDR; subsecond response times for up to 2000 requests/sec on Heroku
|
||||
free tier. If you're used to servers written in interpreted languages
|
||||
(or named after precious gems), prepare to be pleasantly surprised by
|
||||
PostgREST performance.
|
||||
free tier. If you're used to servers written in interpreted languages,
|
||||
prepare to be pleasantly surprised by PostgREST performance.
|
||||
|
||||
Three factors contribute to the speed. First the server is written
|
||||
in [Haskell](https://www.haskell.org/) using the
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"POSTGREST_VER": {
|
||||
"description": "Version of PostgREST to deploy",
|
||||
"value": "6.0.0"
|
||||
"value": "7.0.0"
|
||||
},
|
||||
"DB_URI": {
|
||||
"description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
|
||||
|
||||
+8
-11
@@ -1,24 +1,19 @@
|
||||
## AppVeyor is only used for building a Windows binary, no tests are run here.
|
||||
platform: x64
|
||||
|
||||
cache:
|
||||
- "c:\\sr"
|
||||
- .stack-work
|
||||
- "c:\\Users\\appveyor\\AppData\\Local\\Programs\\stack"
|
||||
|
||||
environment:
|
||||
global:
|
||||
STACK_ROOT: "c:\\sr"
|
||||
GOPATH: c:\gopath
|
||||
TMP: "c:\\tmp"
|
||||
|
||||
test: off
|
||||
|
||||
skip_non_tags: true
|
||||
|
||||
skip_branch_with_pr: true
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
||||
install:
|
||||
- set PATH=C:\Program Files\PostgreSQL\9.6\bin\;%PATH%
|
||||
- curl -sS -ostack.zip -L --insecure http://www.stackage.org/stack/windows-x86_64
|
||||
@@ -28,11 +23,13 @@ install:
|
||||
|
||||
build_script:
|
||||
- stack setup --no-terminal > nul
|
||||
- stack build -j1 --copy-bins --local-bin-path .
|
||||
# Appveyor has a timeout of 60 mins, building can take longer, limit the time and make sure this succeeds,
|
||||
# previous work will get cached and finish on next commit
|
||||
- bash -lc "timeout 2700 'C:\projects\postgrest\stack.exe' build -j1 --copy-bins --local-bin-path . || true"
|
||||
|
||||
artifacts:
|
||||
- path: postgrest.exe
|
||||
|
||||
deploy_script:
|
||||
- 7z a -tzip postgrest-%APPVEYOR_REPO_TAG_NAME%-windows-x64.zip postgrest.exe
|
||||
- bash -lc "exec 0</dev/null && cd $APPVEYOR_BUILD_FOLDER && ghr -t $GITHUB_TOKEN -u $APPVEYOR_ACCOUNT_NAME -r $APPVEYOR_PROJECT_NAME -b \"$(sed -n \"1,/$(echo $APPVEYOR_REPO_TAG_NAME | cut -c2-)/d;/## \[/q;p\" CHANGELOG.md)\" --replace $APPVEYOR_REPO_TAG_NAME postgrest-$APPVEYOR_REPO_TAG_NAME-windows-x64.zip"
|
||||
- IF DEFINED APPVEYOR_REPO_TAG_NAME 7z a -tzip postgrest-%APPVEYOR_REPO_TAG_NAME%-windows-x64.zip postgrest.exe
|
||||
- IF DEFINED APPVEYOR_REPO_TAG_NAME bash -lc " exec 0</dev/null && cd $APPVEYOR_BUILD_FOLDER && ghr -t $GITHUB_TOKEN -u $APPVEYOR_ACCOUNT_NAME -r $APPVEYOR_PROJECT_NAME -b \"$(sed -n \"1,/$(echo $APPVEYOR_REPO_TAG_NAME | cut -c2-)/d;/## \[/q;p\" CHANGELOG.md)\" --replace $APPVEYOR_REPO_TAG_NAME postgrest-$APPVEYOR_REPO_TAG_NAME-windows-x64.zip"
|
||||
|
||||
+8
-3
@@ -1,4 +1,7 @@
|
||||
FROM debian:stretch-slim
|
||||
# To build use:
|
||||
# docker build --build-arg POSTGREST_VERSION=<v5.2.0 or another version> -t postgrest ./docker/
|
||||
|
||||
FROM debian:buster-slim
|
||||
|
||||
ARG POSTGREST_VERSION
|
||||
|
||||
@@ -31,13 +34,15 @@ ENV PGRST_DB_URI= \
|
||||
PGRST_DB_EXTRA_SEARCH_PATH=public \
|
||||
PGRST_SERVER_HOST=*4 \
|
||||
PGRST_SERVER_PORT=3000 \
|
||||
PGRST_SERVER_PROXY_URI= \
|
||||
PGRST_OPENAPI_SERVER_PROXY_URI= \
|
||||
PGRST_JWT_SECRET= \
|
||||
PGRST_SECRET_IS_BASE64=false \
|
||||
PGRST_JWT_AUD= \
|
||||
PGRST_MAX_ROWS= \
|
||||
PGRST_PRE_REQUEST= \
|
||||
PGRST_ROLE_CLAIM_KEY=".role"
|
||||
PGRST_ROLE_CLAIM_KEY=".role" \
|
||||
PGRST_ROOT_SPEC= \
|
||||
PGRST_RAW_MEDIA_TYPES=
|
||||
|
||||
RUN groupadd -g 1000 postgrest && \
|
||||
useradd -r -u 1000 -g postgrest postgrest && \
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
db-uri = "postgres://app_user:password@postgres:5432/app_db"
|
||||
db-schema = "public"
|
||||
db-anon-role = "app_user"
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM centos:centos6
|
||||
|
||||
RUN yum -y update
|
||||
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
|
||||
RUN yum -y install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-6-x86_64/pgdg-centos93-9.3-2.noarch.rpm
|
||||
RUN yum -y install postgresql93-devel
|
||||
RUN yum clean all
|
||||
RUN curl -sSL https://get.haskellstack.org/ | sh
|
||||
|
||||
ENV PATH $PATH:/usr/pgsql-9.3/bin
|
||||
|
||||
# To disable warning when building
|
||||
ENV PATH $PATH:/root/.local/bin
|
||||
|
||||
RUN mkdir /source
|
||||
WORKDIR /source
|
||||
|
||||
ENTRYPOINT ["stack"]
|
||||
@@ -2,12 +2,12 @@ FROM centos:centos7
|
||||
|
||||
RUN yum -y update
|
||||
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
|
||||
RUN yum -y install yum install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-7-x86_64/pgdg-centos93-9.3-2.noarch.rpm
|
||||
RUN yum -y install postgresql93-devel
|
||||
RUN yum -y install yum install https://download.postgresql.org/pub/repos/yum/10/redhat/rhel-7-x86_64/pgdg-centos10-10-2.noarch.rpm
|
||||
RUN yum -y install postgresql10-devel
|
||||
RUN yum clean all
|
||||
RUN curl -sSL https://get.haskellstack.org/ | sh
|
||||
|
||||
ENV PATH $PATH:/usr/pgsql-9.3/bin
|
||||
ENV PATH $PATH:/usr/pgsql-10/bin
|
||||
|
||||
# To disable warning when building
|
||||
ENV PATH $PATH:/root/.local/bin
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
FROM ubuntu:16.04
|
||||
|
||||
## TODO pin the stack version
|
||||
#
|
||||
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
|
||||
apt-get -qq update && \
|
||||
apt-get -qqy --no-install-recommends install \
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
FROM 32bit/ubuntu:16.04
|
||||
FROM i386/ubuntu:16.04
|
||||
|
||||
## TODO pin the stack version
|
||||
|
||||
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
|
||||
apt-get -qq update && \
|
||||
|
||||
@@ -7,7 +7,7 @@ db-extra-search-path = "$(PGRST_DB_EXTRA_SEARCH_PATH)"
|
||||
server-host = "$(PGRST_SERVER_HOST)"
|
||||
server-port = "$(PGRST_SERVER_PORT)"
|
||||
|
||||
server-proxy-uri = "$(PGRST_SERVER_PROXY_URI)"
|
||||
openapi-server-proxy-uri = "$(PGRST_OPENAPI_SERVER_PROXY_URI)"
|
||||
jwt-secret = "$(PGRST_JWT_SECRET)"
|
||||
secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)"
|
||||
jwt-aud = "$(PGRST_JWT_AUD)"
|
||||
@@ -15,3 +15,5 @@ role-claim-key = "$(PGRST_ROLE_CLAIM_KEY)"
|
||||
|
||||
max-rows = "$(PGRST_MAX_ROWS)"
|
||||
pre-request = "$(PGRST_PRE_REQUEST)"
|
||||
root-spec = "$(PGRST_ROOT_SPEC)"
|
||||
raw-media-types = "$(PGRST_RAW_MEDIA_TYPES)"
|
||||
|
||||
+39
-46
@@ -12,25 +12,17 @@ import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
|
||||
import Control.Retry (RetryStatus, capDelay,
|
||||
exponentialBackoff, retrying,
|
||||
rsPreviousDelay)
|
||||
import Data.Either.Combinators (whenLeft)
|
||||
import Data.IORef (IORef, atomicWriteIORef, newIORef,
|
||||
readIORef)
|
||||
import Data.String (IsString (..))
|
||||
import Data.Text (pack, replace, strip, stripPrefix,
|
||||
unpack)
|
||||
import Data.Text (pack, replace, strip, stripPrefix)
|
||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
||||
import Data.Text.IO (hPutStrLn, readFile)
|
||||
import Data.Time.Clock (getCurrentTime)
|
||||
import Network.Socket (Family (AF_UNIX),
|
||||
SockAddr (SockAddrUnix), Socket,
|
||||
SocketType (Stream), bind, close,
|
||||
defaultProtocol, listen,
|
||||
maxListenQueue, socket)
|
||||
import Network.Wai.Handler.Warp (defaultSettings, runSettings,
|
||||
runSettingsSocket, setHost, setPort,
|
||||
setServerName)
|
||||
import System.Directory (removeFile)
|
||||
setHost, setPort, setServerName)
|
||||
import System.IO (BufferMode (..), hSetBuffering)
|
||||
import System.IO.Error (isDoesNotExistError)
|
||||
|
||||
import PostgREST.App (postgrest)
|
||||
import PostgREST.Config (AppConfig (..), configPoolTimeout',
|
||||
@@ -42,13 +34,15 @@ import PostgREST.OpenAPI (isMalformedProxyUri)
|
||||
import PostgREST.Types (ConnectionStatus (..), DbStructure,
|
||||
PgVersion (..), Schema,
|
||||
minimumPgVersion)
|
||||
import Protolude hiding (hPutStrLn, replace)
|
||||
import Protolude hiding (hPutStrLn, head, replace)
|
||||
|
||||
|
||||
#ifndef mingw32_HOST_OS
|
||||
import System.Posix.Signals
|
||||
import UnixSocket
|
||||
#endif
|
||||
|
||||
|
||||
{-|
|
||||
The purpose of this worker is to fill the refDbStructure created in 'main'
|
||||
with the 'DbStructure' returned from calling 'getDbStructure'. This method
|
||||
@@ -70,11 +64,11 @@ import System.Posix.Signals
|
||||
connectionWorker
|
||||
:: ThreadId -- ^ This thread is killed if pg version is unsupported
|
||||
-> P.Pool -- ^ The PostgreSQL connection pool
|
||||
-> Schema -- ^ Schema PostgREST is serving up
|
||||
-> [Schema] -- ^ Schemas PostgREST is serving up
|
||||
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
|
||||
-> IORef Bool -- ^ Used as a binary Semaphore
|
||||
-> IO ()
|
||||
connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
||||
connectionWorker mainTid pool schemas refDbStructure refIsWorkerOn = do
|
||||
isWorkerOn <- readIORef refIsWorkerOn
|
||||
unless isWorkerOn $ do
|
||||
atomicWriteIORef refIsWorkerOn True
|
||||
@@ -90,7 +84,7 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
||||
NotConnected -> return () -- Unreachable
|
||||
Connected actualPgVersion -> do -- Procede with initialization
|
||||
result <- P.use pool $ do
|
||||
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion
|
||||
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schemas actualPgVersion
|
||||
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
|
||||
case result of
|
||||
Left e -> do
|
||||
@@ -159,10 +153,12 @@ main = do
|
||||
-- readOptions builds the 'AppConfig' from the config file specified on the
|
||||
-- command line
|
||||
conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
|
||||
let host = configHost conf
|
||||
let schemas = toList $ configSchemas conf
|
||||
host = configHost conf
|
||||
port = configPort conf
|
||||
proxy = configProxyUri conf
|
||||
proxy = configOpenAPIProxyUri conf
|
||||
maybeSocketAddr = configSocket conf
|
||||
socketFileMode = configSocketMode conf
|
||||
pgSettings = toS (configDatabase conf) -- is the db-uri
|
||||
roleClaimKey = configRoleClaimKey conf
|
||||
appSettings =
|
||||
@@ -171,16 +167,18 @@ main = do
|
||||
. setServerName (toS $ "postgrest/" <> prettyVersion) $
|
||||
defaultSettings
|
||||
|
||||
|
||||
whenLeft socketFileMode panic
|
||||
|
||||
-- Checks that the provided proxy uri is formated correctly
|
||||
when (isMalformedProxyUri $ toS <$> proxy) $
|
||||
panic
|
||||
"Malformed proxy uri, a correct example: https://example.com:8443/basePath"
|
||||
|
||||
-- Checks that the provided jspath is valid
|
||||
when (isLeft roleClaimKey) $
|
||||
whenLeft roleClaimKey $
|
||||
panic $ show roleClaimKey
|
||||
|
||||
--
|
||||
-- create connection pool with the provided settings, returns either
|
||||
-- a 'Connection' or a 'ConnectionError'. Does not throw.
|
||||
pool <- P.acquire (configPool conf, configPoolTimeout' conf, pgSettings)
|
||||
@@ -199,7 +197,7 @@ main = do
|
||||
connectionWorker
|
||||
mainTid
|
||||
pool
|
||||
(configSchema conf)
|
||||
schemas
|
||||
refDbStructure
|
||||
refIsWorkerOn
|
||||
--
|
||||
@@ -221,7 +219,7 @@ main = do
|
||||
Catch $ connectionWorker
|
||||
mainTid
|
||||
pool
|
||||
(configSchema conf)
|
||||
schemas
|
||||
refDbStructure
|
||||
refIsWorkerOn
|
||||
) Nothing
|
||||
@@ -240,22 +238,20 @@ main = do
|
||||
(connectionWorker
|
||||
mainTid
|
||||
pool
|
||||
(configSchema conf)
|
||||
schemas
|
||||
refDbStructure
|
||||
refIsWorkerOn)
|
||||
in case maybeSocketAddr of
|
||||
Nothing -> do
|
||||
-- run the postgrest application
|
||||
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
|
||||
runSettings appSettings postgrestApplication
|
||||
Just socketAddr -> do
|
||||
-- run postgrest application with user defined socket
|
||||
sock <- createAndBindSocket (unpack socketAddr)
|
||||
listen sock maxListenQueue
|
||||
putStrLn $ ("Listening on unix socket " :: Text) <> show socketAddr
|
||||
runSettingsSocket appSettings sock postgrestApplication
|
||||
-- clean socket up when done
|
||||
close sock
|
||||
|
||||
-- run the postgrest application with user defined socket. Only for UNIX systems.
|
||||
#ifndef mingw32_HOST_OS
|
||||
whenJust maybeSocketAddr $
|
||||
runAppInSocket appSettings postgrestApplication socketFileMode
|
||||
#endif
|
||||
|
||||
-- run the postgrest application
|
||||
whenNothing maybeSocketAddr $ do
|
||||
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
|
||||
runSettings appSettings postgrestApplication
|
||||
|
||||
{-|
|
||||
The purpose of this function is to load the JWT secret from a file if
|
||||
@@ -324,14 +320,11 @@ loadDbUriFile conf = extractDbUri mDbUri
|
||||
Just filename -> strip <$> readFile (toS filename)
|
||||
setDbUri dbUri = conf {configDatabase = dbUri}
|
||||
|
||||
createAndBindSocket :: FilePath -> IO Socket
|
||||
createAndBindSocket filePath = do
|
||||
deleteSocketFileIfExist filePath
|
||||
sock <- socket AF_UNIX Stream defaultProtocol
|
||||
bind sock $ SockAddrUnix filePath
|
||||
return sock
|
||||
where
|
||||
deleteSocketFileIfExist path = removeFile path `catch` handleDoesNotExist
|
||||
handleDoesNotExist e
|
||||
| isDoesNotExistError e = return ()
|
||||
| otherwise = throwIO e
|
||||
-- Utilitarian functions.
|
||||
whenJust :: Applicative f => Maybe a -> (a -> f ()) -> f ()
|
||||
whenJust (Just x) f = f x
|
||||
whenJust Nothing _ = pass
|
||||
|
||||
whenNothing :: Applicative f => Maybe a -> f () -> f ()
|
||||
whenNothing Nothing f = f
|
||||
whenNothing _ _ = pass
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
module UnixSocket (
|
||||
runAppInSocket
|
||||
)where
|
||||
|
||||
import Network.Socket (Family (AF_UNIX),
|
||||
SockAddr (SockAddrUnix), Socket,
|
||||
SocketType (Stream), bind, close,
|
||||
defaultProtocol, listen,
|
||||
maxListenQueue, socket)
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Handler.Warp
|
||||
import System.Directory (removeFile)
|
||||
import System.IO.Error (isDoesNotExistError)
|
||||
import System.Posix.Files (setFileMode)
|
||||
import System.Posix.Types (FileMode)
|
||||
|
||||
import Protolude
|
||||
|
||||
createAndBindSocket :: FilePath -> Maybe FileMode -> IO Socket
|
||||
createAndBindSocket socketFilePath maybeSocketFileMode = do
|
||||
deleteSocketFileIfExist socketFilePath
|
||||
sock <- socket AF_UNIX Stream defaultProtocol
|
||||
bind sock $ SockAddrUnix socketFilePath
|
||||
mapM_ (setFileMode socketFilePath) maybeSocketFileMode
|
||||
return sock
|
||||
where
|
||||
deleteSocketFileIfExist path = removeFile path `catch` handleDoesNotExist
|
||||
handleDoesNotExist e
|
||||
| isDoesNotExistError e = return ()
|
||||
| otherwise = throwIO e
|
||||
|
||||
-- run the postgrest application with user defined socket.
|
||||
runAppInSocket :: Settings -> Application -> Either Text FileMode -> FilePath -> IO ()
|
||||
runAppInSocket settings app socketFileMode sockPath = do
|
||||
sock <- createAndBindSocket sockPath (rightToMaybe socketFileMode)
|
||||
putStrLn $ ("Listening on unix socket " :: Text) <> show sockPath
|
||||
listen sock maxListenQueue
|
||||
runSettingsSocket settings sock app
|
||||
-- clean socket up when done
|
||||
close sock
|
||||
+76
-27
@@ -1,13 +1,13 @@
|
||||
name: postgrest
|
||||
version: 6.0.0
|
||||
version: 7.0.0
|
||||
synopsis: REST API for any Postgres database
|
||||
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||
for the tables and views, supporting all HTTP verbs that security
|
||||
permits.
|
||||
license: MIT
|
||||
license-file: LICENSE
|
||||
author: Joe Nelson, Adam Baker
|
||||
maintainer: Steve Chávez <stevechavezast@gmail.com>
|
||||
author: Joe Nelson, Adam Baker, Steve Chavez
|
||||
maintainer: Steve Chavez <stevechavezast@gmail.com>
|
||||
category: Executable, PostgreSQL, Network APIs
|
||||
homepage: https://postgrest.org
|
||||
bug-reports: https://github.com/PostgREST/postgrest/issues
|
||||
@@ -36,11 +36,14 @@ library
|
||||
PostgREST.OpenAPI
|
||||
PostgREST.Parsers
|
||||
PostgREST.QueryBuilder
|
||||
PostgREST.Statements
|
||||
PostgREST.RangeQuery
|
||||
PostgREST.Types
|
||||
other-modules: Paths_postgrest
|
||||
PostgREST.Private.Common
|
||||
PostgREST.Private.QueryFragment
|
||||
hs-source-dirs: src
|
||||
build-depends: base >= 4.9 && < 4.13
|
||||
build-depends: base >= 4.9 && < 4.14
|
||||
, HTTP >= 4000.3.7 && < 4000.4
|
||||
, Ranged-sets >= 0.3 && < 0.5
|
||||
, aeson >= 0.11.3 && < 1.5
|
||||
@@ -49,7 +52,7 @@ library
|
||||
, bytestring >= 0.10.8 && < 0.11
|
||||
, case-insensitive >= 1.2 && < 1.3
|
||||
, cassava >= 0.4.5 && < 0.6
|
||||
, configurator-pg >= 0.1 && < 0.2
|
||||
, configurator-pg >= 0.2 && < 0.3
|
||||
, containers >= 0.5.7 && < 0.7
|
||||
, contravariant >= 1.4 && < 1.6
|
||||
, contravariant-extras >= 0.3.3 && < 0.4
|
||||
@@ -58,23 +61,23 @@ library
|
||||
, gitrev >= 1.2 && < 1.4
|
||||
, hasql >= 1.4 && < 1.5
|
||||
, hasql-pool >= 0.5 && < 0.6
|
||||
, hasql-transaction >= 0.7.2 && < 0.8
|
||||
, hasql-transaction >= 0.7.2 && < 1.1
|
||||
, heredoc >= 0.2 && < 0.3
|
||||
, http-types >= 0.12.2 && < 0.13
|
||||
, insert-ordered-containers >= 0.1 && < 0.3
|
||||
, insert-ordered-containers >= 0.2.2 && < 0.3
|
||||
, interpolatedstring-perl6 >= 1 && < 1.1
|
||||
, jose >= 0.7 && < 0.8
|
||||
, lens >= 4.14 && < 4.18
|
||||
, lens-aeson >= 1.0.1 && < 1.1
|
||||
, jose >= 0.8.1 && < 0.9
|
||||
, lens >= 4.14 && < 4.19
|
||||
, lens-aeson >= 1.0.1 && < 1.2
|
||||
, network-uri >= 2.6.1 && < 2.7
|
||||
, optparse-applicative >= 0.13 && < 0.15
|
||||
, optparse-applicative >= 0.13 && < 0.16
|
||||
, parsec >= 3.1.11 && < 3.2
|
||||
, protolude >= 0.2.2 && < 0.3
|
||||
, regex-tdfa >= 1.2.2 && < 1.3
|
||||
, regex-tdfa >= 1.2.2 && < 1.4
|
||||
, scientific >= 0.3.4 && < 0.4
|
||||
, swagger2 >= 2.1.4 && < 2.4
|
||||
, swagger2 >= 2.4 && < 2.6
|
||||
, text >= 1.2.2 && < 1.3
|
||||
, time >= 1.6 && < 1.9
|
||||
, time >= 1.6 && < 1.10
|
||||
, unordered-containers >= 0.2.8 && < 0.3
|
||||
, vector >= 0.11 && < 0.13
|
||||
, wai >= 3.2.1 && < 3.3
|
||||
@@ -89,21 +92,23 @@ library
|
||||
executable postgrest
|
||||
main-is: Main.hs
|
||||
hs-source-dirs: main
|
||||
build-depends: base >= 4.9 && < 4.13
|
||||
build-depends: base >= 4.9 && < 4.14
|
||||
, auto-update >= 0.1.4 && < 0.2
|
||||
, base64-bytestring >= 1 && < 1.1
|
||||
, bytestring >= 0.10.8 && < 0.11
|
||||
, directory >= 1.2.6 && < 1.4
|
||||
, either >= 4.4.1 && < 5.1
|
||||
, hasql >= 1.4 && < 1.5
|
||||
, hasql-pool >= 0.5 && < 0.6
|
||||
, hasql-transaction >= 0.7.2 && < 0.8
|
||||
, network < 2.9
|
||||
, hasql-transaction >= 0.7.2 && < 1.1
|
||||
, network < 3.2
|
||||
, postgrest
|
||||
, protolude >= 0.2.2 && < 0.3
|
||||
, retry >= 0.7.4 && < 0.9
|
||||
, text >= 1.2.2 && < 1.3
|
||||
, time >= 1.6 && < 1.9
|
||||
, warp >= 3.2.12 && < 3.3
|
||||
, time >= 1.6 && < 1.10
|
||||
, wai >= 3.2.1 && < 3.3
|
||||
, warp >= 3.2.12 && < 3.4
|
||||
default-language: Haskell2010
|
||||
default-extensions: OverloadedStrings
|
||||
QuasiQuotes
|
||||
@@ -112,6 +117,7 @@ executable postgrest
|
||||
|
||||
if !os(windows)
|
||||
build-depends: unix
|
||||
other-modules: UnixSocket
|
||||
|
||||
test-suite spec
|
||||
type: exitcode-stdio-1.0
|
||||
@@ -124,6 +130,7 @@ test-suite spec
|
||||
Feature.ConcurrentSpec
|
||||
Feature.CorsSpec
|
||||
Feature.DeleteSpec
|
||||
Feature.EmbedDisambiguationSpec
|
||||
Feature.ExtraSearchPathSpec
|
||||
Feature.InsertSpec
|
||||
Feature.JsonOperatorSpec
|
||||
@@ -141,10 +148,13 @@ test-suite spec
|
||||
Feature.StructureSpec
|
||||
Feature.UnicodeSpec
|
||||
Feature.UpsertSpec
|
||||
Feature.RawOutputTypesSpec
|
||||
Feature.HtmlRawOutputSpec
|
||||
Feature.MultipleSchemaSpec
|
||||
SpecHelper
|
||||
TestTypes
|
||||
hs-source-dirs: test
|
||||
build-depends: base >= 4.9 && < 4.13
|
||||
build-depends: base >= 4.9 && < 4.14
|
||||
, aeson >= 0.11.3 && < 1.5
|
||||
, aeson-qq >= 0.8.1 && < 0.9
|
||||
, async >= 2.1.1 && < 2.3
|
||||
@@ -157,21 +167,21 @@ test-suite spec
|
||||
, contravariant >= 1.4 && < 1.6
|
||||
, hasql >= 1.4 && < 1.5
|
||||
, hasql-pool >= 0.5 && < 0.6
|
||||
, hasql-transaction >= 0.7.2 && < 0.8
|
||||
, hasql-transaction >= 0.7.2 && < 1.1
|
||||
, heredoc >= 0.2 && < 0.3
|
||||
, hspec >= 2.3 && < 2.8
|
||||
, hspec-wai >= 0.7 && < 0.10
|
||||
, hspec-wai-json >= 0.7 && < 0.10
|
||||
, hspec-wai >= 0.10 && < 0.11
|
||||
, hspec-wai-json >= 0.10 && < 0.11
|
||||
, http-types >= 0.12.3 && < 0.13
|
||||
, lens >= 4.14 && < 4.18
|
||||
, lens-aeson >= 1.0.1 && < 1.1
|
||||
, lens >= 4.14 && < 4.19
|
||||
, lens-aeson >= 1.0.1 && < 1.2
|
||||
, monad-control >= 1.0.1 && < 1.1
|
||||
, postgrest
|
||||
, process >= 1.4.2 && < 1.7
|
||||
, protolude >= 0.2.2 && < 0.3
|
||||
, regex-tdfa >= 1.2.2 && < 1.3
|
||||
, regex-tdfa >= 1.2.2 && < 1.4
|
||||
, text >= 1.2.2 && < 1.3
|
||||
, time >= 1.6 && < 1.9
|
||||
, time >= 1.6 && < 1.10
|
||||
, transformers-base >= 0.4.4 && < 0.5
|
||||
, wai >= 3.2.1 && < 3.3
|
||||
, wai-extra >= 3.0.19 && < 3.1
|
||||
@@ -180,3 +190,42 @@ test-suite spec
|
||||
QuasiQuotes
|
||||
NoImplicitPrelude
|
||||
ghc-options: -threaded -rtsopts -with-rtsopts=-N
|
||||
|
||||
Test-Suite spec-querycost
|
||||
Type: exitcode-stdio-1.0
|
||||
Default-Language: Haskell2010
|
||||
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude
|
||||
Hs-Source-Dirs: test
|
||||
Main-Is: QueryCost.hs
|
||||
Other-Modules: SpecHelper
|
||||
Build-Depends: base >= 4.9 && < 4.14
|
||||
, aeson >= 0.11.3 && < 1.5
|
||||
, aeson-qq >= 0.8.1 && < 0.9
|
||||
, async >= 2.1.1 && < 2.3
|
||||
, auto-update >= 0.1.4 && < 0.2
|
||||
, base64-bytestring >= 1 && < 1.1
|
||||
, bytestring >= 0.10.8 && < 0.11
|
||||
, case-insensitive >= 1.2 && < 1.3
|
||||
, cassava >= 0.4.5 && < 0.6
|
||||
, containers >= 0.5.7 && < 0.7
|
||||
, contravariant >= 1.4 && < 1.6
|
||||
, hasql >= 1.4 && < 1.5
|
||||
, hasql-pool >= 0.5 && < 0.6
|
||||
, hasql-transaction >= 0.7.2 && < 1.1
|
||||
, heredoc >= 0.2 && < 0.3
|
||||
, hspec >= 2.3 && < 2.8
|
||||
, hspec-wai >= 0.10 && < 0.11
|
||||
, hspec-wai-json >= 0.10 && < 0.11
|
||||
, http-types >= 0.12.3 && < 0.13
|
||||
, lens >= 4.14 && < 4.19
|
||||
, lens-aeson >= 1.0.1 && < 1.2
|
||||
, monad-control >= 1.0.1 && < 1.1
|
||||
, postgrest
|
||||
, process >= 1.4.2 && < 1.7
|
||||
, protolude >= 0.2.2 && < 0.3
|
||||
, regex-tdfa >= 1.2.2 && < 1.4
|
||||
, text >= 1.2.2 && < 1.3
|
||||
, time >= 1.6 && < 1.10
|
||||
, transformers-base >= 0.4.4 && < 0.5
|
||||
, wai >= 3.2.1 && < 3.3
|
||||
, wai-extra >= 3.0.19 && < 3.1
|
||||
|
||||
+113
-80
@@ -3,13 +3,14 @@ Module : PostgREST.ApiRequest
|
||||
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
|
||||
-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE MultiWayIf #-}
|
||||
|
||||
module PostgREST.ApiRequest (
|
||||
ApiRequest(..)
|
||||
, InvokeMethod(..)
|
||||
, ContentType(..)
|
||||
, Action(..)
|
||||
, Target(..)
|
||||
, PreferRepresentation (..)
|
||||
, mutuallyAgreeable
|
||||
, userApiRequest
|
||||
) where
|
||||
@@ -27,7 +28,8 @@ import qualified Data.Vector as V
|
||||
|
||||
import Control.Arrow ((***))
|
||||
import Data.Aeson.Types (emptyArray, emptyObject)
|
||||
import Data.List (last, lookup, partition)
|
||||
import Data.List (elem, last, lookup, partition)
|
||||
import Data.List.NonEmpty (NonEmpty, head)
|
||||
import Data.Maybe (fromJust)
|
||||
import Data.Ranged.Ranges (Range (..), emptyRange,
|
||||
rangeIntersection)
|
||||
@@ -47,24 +49,23 @@ import PostgREST.RangeQuery (NonnegRange, allRange, rangeGeq,
|
||||
rangeLimit, rangeOffset, rangeRequested,
|
||||
restrictRange)
|
||||
import PostgREST.Types
|
||||
import Protolude
|
||||
import Protolude hiding (head)
|
||||
|
||||
type RequestBody = BL.ByteString
|
||||
|
||||
data InvokeMethod = InvHead | InvGet | InvPost deriving Eq
|
||||
-- | Types of things a user wants to do to tables/views/procs
|
||||
data Action = ActionCreate | ActionRead
|
||||
| ActionUpdate | ActionDelete
|
||||
| ActionInfo | ActionInvoke{isReadOnly :: Bool}
|
||||
| ActionInspect | ActionSingleUpsert
|
||||
data Action = ActionCreate | ActionRead{isHead :: Bool}
|
||||
| ActionUpdate | ActionDelete
|
||||
| ActionSingleUpsert | ActionInvoke InvokeMethod
|
||||
| ActionInfo | ActionInspect{isHead :: Bool}
|
||||
deriving Eq
|
||||
-- | The target db object of a user action
|
||||
data Target = TargetIdent QualifiedIdentifier
|
||||
| TargetProc{tpQi :: QualifiedIdentifier, tpIsRootSpec :: Bool}
|
||||
| TargetDefaultSpec -- The default spec offered at root "/"
|
||||
| TargetDefaultSpec{tdsSchema :: Schema} -- The default spec offered at root "/"
|
||||
| TargetUnknown [Text]
|
||||
deriving Eq
|
||||
-- | How to return the inserted data
|
||||
data PreferRepresentation = Full | HeadersOnly | None deriving Eq
|
||||
|
||||
{-|
|
||||
Describes what the user wants to do. This data type is a
|
||||
@@ -74,83 +75,85 @@ data PreferRepresentation = Full | HeadersOnly | None deriving Eq
|
||||
if it is an action we are able to perform.
|
||||
-}
|
||||
data ApiRequest = ApiRequest {
|
||||
-- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
|
||||
iAction :: Action
|
||||
-- | Requested range of rows within response
|
||||
, iRange :: M.HashMap ByteString NonnegRange
|
||||
-- | The target, be it calling a proc or accessing a table
|
||||
, iTarget :: Target
|
||||
-- | Content types the client will accept, [CTAny] if no Accept header
|
||||
, iAccepts :: [ContentType]
|
||||
-- | Data sent by client and used for mutation actions
|
||||
, iPayload :: Maybe PayloadJSON
|
||||
-- | If client wants created items echoed back
|
||||
, iPreferRepresentation :: PreferRepresentation
|
||||
-- | Pass all parameters as a single json object to a stored procedure
|
||||
, iPreferSingleObjectParameter :: Bool
|
||||
-- | Whether the client wants a result count (slower)
|
||||
, iPreferCount :: Bool
|
||||
-- | Whether the client wants to UPSERT or ignore records on PK conflict
|
||||
, iPreferResolution :: Maybe PreferResolution
|
||||
-- | Filters on the result ("id", "eq.10")
|
||||
, iFilters :: [(Text, Text)]
|
||||
-- | &and and &or parameters used for complex boolean logic
|
||||
, iLogic :: [(Text, Text)]
|
||||
-- | &select parameter used to shape the response
|
||||
, iSelect :: Text
|
||||
-- | &columns parameter used to shape the payload
|
||||
, iColumns :: Maybe Text
|
||||
-- | &order parameters for each level
|
||||
, iOrder :: [(Text, Text)]
|
||||
-- | Alphabetized (canonical) request query string for response URLs
|
||||
, iCanonicalQS :: ByteString
|
||||
-- | JSON Web Token
|
||||
, iJWT :: Text
|
||||
-- | HTTP request headers
|
||||
, iHeaders :: [(Text, Text)]
|
||||
-- | Request Cookies
|
||||
, iCookies :: [(Text, Text)]
|
||||
iAction :: Action -- ^ Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
|
||||
, iRange :: M.HashMap ByteString NonnegRange -- ^ Requested range of rows within response
|
||||
, iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level
|
||||
, iTarget :: Target -- ^ The target, be it calling a proc or accessing a table
|
||||
, iAccepts :: [ContentType] -- ^ Content types the client will accept, [CTAny] if no Accept header
|
||||
, iPayload :: Maybe PayloadJSON -- ^ Data sent by client and used for mutation actions
|
||||
, iPreferRepresentation :: PreferRepresentation -- ^ If client wants created items echoed back
|
||||
, iPreferParameters :: Maybe PreferParameters -- ^ How to pass parameters to a stored procedure
|
||||
, iPreferCount :: Maybe PreferCount -- ^ Whether the client wants a result count
|
||||
, iPreferResolution :: Maybe PreferResolution -- ^ Whether the client wants to UPSERT or ignore records on PK conflict
|
||||
, iFilters :: [(Text, Text)] -- ^ Filters on the result ("id", "eq.10")
|
||||
, iLogic :: [(Text, Text)] -- ^ &and and &or parameters used for complex boolean logic
|
||||
, iSelect :: Maybe Text -- ^ &select parameter used to shape the response
|
||||
, iOnConflict :: Maybe Text -- ^ &on_conflict parameter used to upsert on specific unique keys
|
||||
, iColumns :: Maybe Text -- ^ &columns parameter used to shape the payload
|
||||
, iOrder :: [(Text, Text)] -- ^ &order parameters for each level
|
||||
, iCanonicalQS :: ByteString -- ^ Alphabetized (canonical) request query string for response URLs
|
||||
, iJWT :: Text -- ^ JSON Web Token
|
||||
, iHeaders :: [(Text, Text)] -- ^ HTTP request headers
|
||||
, iCookies :: [(Text, Text)] -- ^ Request Cookies
|
||||
, iPath :: ByteString -- ^ Raw request path
|
||||
, iMethod :: ByteString -- ^ Raw request method
|
||||
, iProfile :: Maybe Schema -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/.
|
||||
, iSchema :: Schema -- ^ The request schema. Can vary depending on iProfile.
|
||||
}
|
||||
|
||||
-- | Examines HTTP request and translates it into user intent.
|
||||
userApiRequest :: Schema -> Maybe QualifiedIdentifier -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||
userApiRequest schema rootSpec req reqBody
|
||||
| isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate
|
||||
userApiRequest :: NonEmpty Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||
userApiRequest confSchemas rootSpec req reqBody
|
||||
| isJust profile && fromJust profile `notElem` confSchemas = Left $ UnacceptableSchema $ toList confSchemas
|
||||
| isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate
|
||||
| topLevelRange == emptyRange = Left InvalidRange
|
||||
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
|
||||
| otherwise = Right ApiRequest {
|
||||
iAction = action
|
||||
, iTarget = target
|
||||
, iRange = ranges
|
||||
, iTopLevelRange = topLevelRange
|
||||
, iAccepts = maybe [CTAny] (map decodeContentType . parseHttpAccept) $ lookupHeader "accept"
|
||||
, iPayload = relevantPayload
|
||||
, iPreferRepresentation = representation
|
||||
, iPreferSingleObjectParameter = singleObject
|
||||
, iPreferCount = hasPrefer "count=exact"
|
||||
, iPreferResolution = if hasPrefer (show MergeDuplicates) then Just MergeDuplicates
|
||||
else if hasPrefer (show IgnoreDuplicates) then Just IgnoreDuplicates
|
||||
else Nothing
|
||||
, iPreferParameters = if | hasPrefer (show SingleObject) -> Just SingleObject
|
||||
| hasPrefer (show MultipleObjects) -> Just MultipleObjects
|
||||
| otherwise -> Nothing
|
||||
, iPreferCount = if | hasPrefer (show ExactCount) -> Just ExactCount
|
||||
| hasPrefer (show PlannedCount) -> Just PlannedCount
|
||||
| hasPrefer (show EstimatedCount) -> Just EstimatedCount
|
||||
| otherwise -> Nothing
|
||||
, iPreferResolution = if | hasPrefer (show MergeDuplicates) -> Just MergeDuplicates
|
||||
| hasPrefer (show IgnoreDuplicates) -> Just IgnoreDuplicates
|
||||
| otherwise -> Nothing
|
||||
, iFilters = filters
|
||||
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
|
||||
, iSelect = toS $ fromMaybe "*" $ join $ lookup "select" qParams
|
||||
, iSelect = toS <$> join (lookup "select" qParams)
|
||||
, iOnConflict = toS <$> join (lookup "on_conflict" qParams)
|
||||
, iColumns = columns
|
||||
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
|
||||
, iCanonicalQS = toS $ urlEncodeVars
|
||||
. L.sortOn fst
|
||||
. map (join (***) toS . second (fromMaybe BS.empty))
|
||||
$ queryStringWPlus
|
||||
$ qString
|
||||
, iJWT = tokenStr
|
||||
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie]
|
||||
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hCookie]
|
||||
, iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
|
||||
, iPath = rawPathInfo req
|
||||
, iMethod = method
|
||||
, iProfile = profile
|
||||
, iSchema = schema
|
||||
}
|
||||
where
|
||||
-- queryString with '+' not converted to ' '
|
||||
queryStringWPlus = parseQueryReplacePlus False $ rawQueryString req
|
||||
-- queryString with '+' converted to ' '(space)
|
||||
qString = parseQueryReplacePlus True $ rawQueryString req
|
||||
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
|
||||
(filters, rpcQParams) =
|
||||
case action of
|
||||
ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
|
||||
_ -> (flts, [])
|
||||
ActionInvoke InvGet -> partitionFlts
|
||||
ActionInvoke InvHead -> partitionFlts
|
||||
_ -> (flts, [])
|
||||
partitionFlts = partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
|
||||
flts =
|
||||
[ (toS k, toS $ fromJust v) |
|
||||
(k,v) <- qParams, isJust v,
|
||||
@@ -163,13 +166,17 @@ userApiRequest schema rootSpec req reqBody
|
||||
isTargetingProc = case target of
|
||||
TargetProc _ _ -> True
|
||||
_ -> False
|
||||
isTargetingDefaultSpec = case target of
|
||||
TargetDefaultSpec _ -> True
|
||||
_ -> False
|
||||
contentType = decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type"
|
||||
columns | action `elem` [ActionCreate, ActionUpdate, ActionInvoke{isReadOnly=False}] = toS <$> join (lookup "columns" qParams)
|
||||
| otherwise = Nothing
|
||||
columns
|
||||
| action `elem` [ActionCreate, ActionUpdate, ActionInvoke InvPost] = toS <$> join (lookup "columns" qParams)
|
||||
| otherwise = Nothing
|
||||
payload =
|
||||
case (contentType, action) of
|
||||
(_, ActionInvoke{isReadOnly=True}) ->
|
||||
Right $ ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) PJObject (S.fromList $ fst <$> rpcQParams)
|
||||
(_, ActionInvoke InvGet) -> Right rpcPrmsToJson
|
||||
(_, ActionInvoke InvHead) -> Right rpcPrmsToJson
|
||||
(CTApplicationJSON, _) ->
|
||||
if isJust columns
|
||||
then Right $ RawJSON reqBody
|
||||
@@ -186,47 +193,73 @@ userApiRequest schema rootSpec req reqBody
|
||||
Right $ ProcessedJSON (JSON.encode json) PJObject keys
|
||||
(ct, _) ->
|
||||
Left $ toS $ "Content-Type not acceptable: " <> toMime ct
|
||||
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges
|
||||
rpcPrmsToJson = ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams)
|
||||
PJObject (S.fromList $ fst <$> rpcQParams)
|
||||
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges -- if no limit is specified, get all the request rows
|
||||
action =
|
||||
case method of
|
||||
"GET" | target == TargetDefaultSpec -> ActionInspect
|
||||
| isTargetingProc -> ActionInvoke{isReadOnly=True}
|
||||
| otherwise -> ActionRead
|
||||
|
||||
-- The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response
|
||||
-- From https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
|
||||
"HEAD" | isTargetingDefaultSpec -> ActionInspect{isHead=True}
|
||||
| isTargetingProc -> ActionInvoke InvHead
|
||||
| otherwise -> ActionRead{isHead=True}
|
||||
"GET" | isTargetingDefaultSpec -> ActionInspect{isHead=False}
|
||||
| isTargetingProc -> ActionInvoke InvGet
|
||||
| otherwise -> ActionRead{isHead=False}
|
||||
"POST" -> if isTargetingProc
|
||||
then ActionInvoke{isReadOnly=False}
|
||||
then ActionInvoke InvPost
|
||||
else ActionCreate
|
||||
"PATCH" -> ActionUpdate
|
||||
"PUT" -> ActionSingleUpsert
|
||||
"DELETE" -> ActionDelete
|
||||
"OPTIONS" -> ActionInfo
|
||||
_ -> ActionInspect
|
||||
_ -> ActionInspect{isHead=False}
|
||||
|
||||
defaultSchema = head confSchemas
|
||||
profile
|
||||
| length confSchemas <= 1 -- only enable content negotiation by profile when there are multiple schemas specified in the config
|
||||
= Nothing
|
||||
| action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionDelete] -- POST/PATCH/PUT/DELETE don't use the same header as per the spec
|
||||
= Just $ maybe defaultSchema toS $ lookupHeader "Content-Profile"
|
||||
| action `elem` [ActionRead True, ActionRead False, ActionInvoke InvGet, ActionInvoke InvHead, ActionInvoke InvPost,
|
||||
ActionInspect False, ActionInspect True, ActionInfo]
|
||||
= Just $ maybe defaultSchema toS $ lookupHeader "Accept-Profile"
|
||||
| otherwise = Nothing
|
||||
schema = fromMaybe defaultSchema profile
|
||||
target = case path of
|
||||
[] -> case rootSpec of
|
||||
Just rsQi -> TargetProc rsQi True
|
||||
Nothing -> TargetDefaultSpec
|
||||
Just pName -> TargetProc (QualifiedIdentifier schema pName) True
|
||||
Nothing -> TargetDefaultSpec schema
|
||||
[table] -> TargetIdent $ QualifiedIdentifier schema table
|
||||
["rpc", proc] -> TargetProc (QualifiedIdentifier schema proc) False
|
||||
other -> TargetUnknown other
|
||||
|
||||
shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke{isReadOnly=False}, ActionInvoke{isReadOnly=True}]
|
||||
shouldParsePayload =
|
||||
action `elem`
|
||||
[ActionCreate, ActionUpdate, ActionSingleUpsert,
|
||||
ActionInvoke InvPost,
|
||||
-- Though ActionInvoke{isGet=True}(a GET /rpc/..) doesn't really have a payload, we use the payload variable as a way
|
||||
-- to store the query string arguments to the function.
|
||||
ActionInvoke InvGet,
|
||||
ActionInvoke InvHead]
|
||||
relevantPayload | shouldParsePayload = rightToMaybe payload
|
||||
| otherwise = Nothing
|
||||
path = pathInfo req
|
||||
method = requestMethod req
|
||||
hdrs = requestHeaders req
|
||||
qParams = [(toS k, v)|(k,v) <- queryStringWPlus]
|
||||
qParams = [(toS k, v)|(k,v) <- qString]
|
||||
lookupHeader = flip lookup hdrs
|
||||
hasPrefer :: Text -> Bool
|
||||
hasPrefer val = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs
|
||||
where
|
||||
split :: BS.ByteString -> [Text]
|
||||
split = map T.strip . T.split (==',') . toS
|
||||
singleObject = hasPrefer "params=single-object"
|
||||
representation
|
||||
| hasPrefer "return=representation" = Full
|
||||
| hasPrefer "return=minimal" = None
|
||||
| otherwise = HeadersOnly
|
||||
| hasPrefer (show Full) = Full
|
||||
| hasPrefer (show None) = None
|
||||
| otherwise = if action == ActionCreate
|
||||
then HeadersOnly -- Assume the user wants the Location header(for POST) by default
|
||||
else None
|
||||
auth = fromMaybe "" $ lookupHeader hAuthorization
|
||||
tokenStr = case T.split (== ' ') (toS auth) of
|
||||
("Bearer" : t : _) -> t
|
||||
|
||||
+236
-205
@@ -1,4 +1,16 @@
|
||||
{-|
|
||||
Module : PostgREST.App
|
||||
Description : PostgREST main application
|
||||
|
||||
This module is in charge of mapping HTTP requests to PostgreSQL queries.
|
||||
Some of its functionality includes:
|
||||
|
||||
- Mapping HTTP request methods to proper SQL statements. For example, a GET request is translated to executing a SELECT query in a read-only TRANSACTION.
|
||||
- Producing HTTP Headers according to RFCs.
|
||||
- Content Negotiation
|
||||
-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE MultiWayIf #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
@@ -8,13 +20,13 @@ module PostgREST.App (
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.List as L (union)
|
||||
import qualified Data.Set as S
|
||||
import qualified Hasql.Pool as P
|
||||
import qualified Hasql.Transaction as H
|
||||
import qualified Hasql.Transaction as HT
|
||||
import qualified Hasql.Transaction.Sessions as HT
|
||||
|
||||
import Data.Aeson as JSON
|
||||
import Data.Function (id)
|
||||
import Data.IORef (IORef, readIORef)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
@@ -29,26 +41,28 @@ import Network.Wai
|
||||
|
||||
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
|
||||
ContentType (..),
|
||||
PreferRepresentation (..),
|
||||
Target (..), mutuallyAgreeable,
|
||||
userApiRequest)
|
||||
InvokeMethod (..), Target (..),
|
||||
mutuallyAgreeable, userApiRequest)
|
||||
import PostgREST.Auth (containsRole, jwtClaims,
|
||||
parseSecret)
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.DbRequestBuilder (fieldNames, mutateRequest,
|
||||
readRequest)
|
||||
import PostgREST.DbRequestBuilder (mutateRequest, readRequest)
|
||||
import PostgREST.DbStructure
|
||||
import PostgREST.Error (PgError (..), SimpleError (..),
|
||||
errorResponseFor, singularityError)
|
||||
import PostgREST.Middleware
|
||||
import PostgREST.OpenAPI
|
||||
import PostgREST.Parsers (pRequestColumns)
|
||||
import PostgREST.QueryBuilder (ResultsWithCount, callProc,
|
||||
import PostgREST.QueryBuilder (limitedQuery, mutateRequestToQuery,
|
||||
readRequestToCountQuery,
|
||||
readRequestToQuery,
|
||||
requestToCallProcQuery)
|
||||
import PostgREST.RangeQuery (allRange, contentRangeH,
|
||||
rangeStatusHeader)
|
||||
import PostgREST.Statements (callProcStatement,
|
||||
createExplainStatement,
|
||||
createReadStatement,
|
||||
createWriteStatement,
|
||||
requestToCountQuery,
|
||||
requestToQuery)
|
||||
import PostgREST.RangeQuery (allRange, rangeOffset)
|
||||
createWriteStatement)
|
||||
import PostgREST.Types
|
||||
import Protolude hiding (Proxy, intercalate)
|
||||
|
||||
@@ -56,7 +70,6 @@ postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> I
|
||||
postgrest conf refDbStructure pool getTime worker =
|
||||
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle
|
||||
jwtSecret = parseSecret <$> configJwtSecret conf in
|
||||
|
||||
middle $ \ req respond -> do
|
||||
time <- getTime
|
||||
body <- strictRequestBody req
|
||||
@@ -65,8 +78,9 @@ postgrest conf refDbStructure pool getTime worker =
|
||||
Nothing -> respond . errorResponseFor $ ConnectionLostError
|
||||
Just dbStructure -> do
|
||||
response <- do
|
||||
-- Need to parse ?columns early because findProc needs it to solve overloaded functions
|
||||
let apiReq = userApiRequest (configSchema conf) (configRootSpec conf) req body
|
||||
-- Need to parse ?columns early because findProc needs it to solve overloaded functions.
|
||||
-- TODO: move this logic to the app function
|
||||
let apiReq = userApiRequest (configSchemas conf) (configRootSpec conf) req body
|
||||
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
|
||||
case apiReqCols of
|
||||
Left err -> return . errorResponseFor $ err
|
||||
@@ -78,7 +92,7 @@ postgrest conf refDbStructure pool getTime worker =
|
||||
(Just RawJSON{}, Just cls) -> cls
|
||||
_ -> S.empty
|
||||
proc = case iTarget apiRequest of
|
||||
TargetProc qi _ -> findProc qi cols (iPreferSingleObjectParameter apiRequest) $ dbProcs dbStructure
|
||||
TargetProc qi _ -> findProc qi cols (iPreferParameters apiRequest == Just SingleObject) $ dbProcs dbStructure
|
||||
_ -> Nothing
|
||||
handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest
|
||||
txMode = transactionMode proc (iAction apiRequest)
|
||||
@@ -90,46 +104,57 @@ postgrest conf refDbStructure pool getTime worker =
|
||||
transactionMode :: Maybe ProcDescription -> Action -> HT.Mode
|
||||
transactionMode proc action =
|
||||
case action of
|
||||
ActionRead -> HT.Read
|
||||
ActionInfo -> HT.Read
|
||||
ActionInspect -> HT.Read
|
||||
ActionInvoke{isReadOnly=False} ->
|
||||
ActionRead _ -> HT.Read
|
||||
ActionInfo -> HT.Read
|
||||
ActionInspect _ -> HT.Read
|
||||
ActionInvoke InvGet -> HT.Read
|
||||
ActionInvoke InvHead -> HT.Read
|
||||
ActionInvoke InvPost ->
|
||||
let v = maybe Volatile pdVolatility proc in
|
||||
if v == Stable || v == Immutable
|
||||
then HT.Read
|
||||
else HT.Write
|
||||
ActionInvoke{isReadOnly=True} -> HT.Read
|
||||
_ -> HT.Write
|
||||
|
||||
app :: DbStructure -> Maybe ProcDescription -> S.Set FieldName -> AppConfig -> ApiRequest -> H.Transaction Response
|
||||
app dbStructure proc cols conf apiRequest =
|
||||
case responseContentTypeOrError (iAccepts apiRequest) (iAction apiRequest) (iTarget apiRequest) of
|
||||
let rawContentTypes = (decodeContentType <$> configRawMediaTypes conf) `L.union` [ CTOctetStream, CTTextPlain ] in
|
||||
case responseContentTypeOrError (iAccepts apiRequest) rawContentTypes (iAction apiRequest) (iTarget apiRequest) of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right contentType ->
|
||||
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
||||
|
||||
(ActionRead, TargetIdent qi, Nothing) ->
|
||||
let partsField = (,) <$> readSqlParts
|
||||
<*> (binaryField contentType =<< fldNames) in
|
||||
case partsField of
|
||||
(ActionRead headersOnly, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
|
||||
case readSqlParts tSchema tName of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right ((q, cq), bField) -> do
|
||||
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount
|
||||
(contentType == CTTextCSV) bField
|
||||
Right (q, cq, bField) -> do
|
||||
let cQuery = if estimatedCount
|
||||
then limitedQuery cq ((+ 1) <$> maxRows) -- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
|
||||
else cq
|
||||
stm = createReadStatement q cQuery (contentType == CTSingularJSON) shouldCount
|
||||
(contentType == CTTextCSV) bField pgVer
|
||||
explStm = createExplainStatement cq
|
||||
row <- H.statement () stm
|
||||
let (tableTotal, queryTotal, _ , body) = row
|
||||
(status, contentRange) = rangeHeader queryTotal tableTotal
|
||||
canonical = iCanonicalQS apiRequest
|
||||
return $
|
||||
if contentType == CTSingularJSON && queryTotal /= 1
|
||||
then errorResponseFor . singularityError $ queryTotal
|
||||
else responseLBS status
|
||||
[toHeader contentType, contentRange,
|
||||
("Content-Location",
|
||||
"/" <> toS (qiName qi) <>
|
||||
if BS.null canonical then "" else "?" <> toS canonical
|
||||
)
|
||||
] (toS body)
|
||||
let (tableTotal, queryTotal, _ , body, gucHeaders) = row
|
||||
case gucHeaders of
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
Right ghdrs -> do
|
||||
total <- if | plannedCount -> H.statement () explStm
|
||||
| estimatedCount -> if tableTotal > (fromIntegral <$> maxRows)
|
||||
then do estTotal <- H.statement () explStm
|
||||
pure $ if estTotal > tableTotal then estTotal else tableTotal
|
||||
else pure tableTotal
|
||||
| otherwise -> pure tableTotal
|
||||
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal total
|
||||
headers = addHeadersIfNotIncluded (catMaybes [
|
||||
Just $ toHeader contentType, Just contentRange,
|
||||
Just $ contentLocationH tName (iCanonicalQS apiRequest), profileH])
|
||||
(unwrapGucHeader <$> ghdrs)
|
||||
rBody = if headersOnly then mempty else toS body
|
||||
return $
|
||||
if contentType == CTSingularJSON && queryTotal /= 1
|
||||
then errorResponseFor . singularityError $ queryTotal
|
||||
else responseLBS status headers rBody
|
||||
|
||||
(ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
|
||||
case mutateSqlParts tSchema tName of
|
||||
@@ -138,32 +163,31 @@ app dbStructure proc cols conf apiRequest =
|
||||
let pkCols = tablePKCols dbStructure tSchema tName
|
||||
stm = createWriteStatement sq mq
|
||||
(contentType == CTSingularJSON) True
|
||||
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols
|
||||
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols pgVer
|
||||
row <- H.statement (toS $ pjRaw pJson) stm
|
||||
let (_, queryTotal, fs, body) = extractQueryResult row
|
||||
headers = catMaybes [
|
||||
if null fs
|
||||
then Nothing
|
||||
else Just (hLocation, "/" <> toS tName <> renderLocationFields fs)
|
||||
, if iPreferRepresentation apiRequest == Full
|
||||
then Just $ toHeader contentType
|
||||
else Nothing
|
||||
, Just $ contentRangeH 1 0 $
|
||||
if shouldCount then Just queryTotal else Nothing
|
||||
, if null pkCols
|
||||
then Nothing
|
||||
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
|
||||
]
|
||||
if contentType == CTSingularJSON
|
||||
&& queryTotal /= 1
|
||||
&& iPreferRepresentation apiRequest == Full
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor . singularityError $ queryTotal
|
||||
else
|
||||
return . responseLBS status201 headers $
|
||||
if iPreferRepresentation apiRequest == Full
|
||||
then toS body else ""
|
||||
let (_, queryTotal, fields, body, gucHeaders) = row
|
||||
case gucHeaders of
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
Right ghdrs -> do
|
||||
let
|
||||
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||
then ([Just $ toHeader contentType, profileH], toS body)
|
||||
else ([], mempty)
|
||||
headers = addHeadersIfNotIncluded (catMaybes ([
|
||||
if null fields
|
||||
then Nothing
|
||||
else Just $ locationH tName fields
|
||||
, Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
||||
, if null pkCols && isNothing (iOnConflict apiRequest)
|
||||
then Nothing
|
||||
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
|
||||
] ++ ctHeaders)) (unwrapGucHeader <$> ghdrs)
|
||||
if contentType == CTSingularJSON && queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor . singularityError $ queryTotal
|
||||
else
|
||||
return $ responseLBS status201 headers rBody
|
||||
|
||||
(ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
|
||||
case mutateSqlParts tSchema tName of
|
||||
@@ -171,31 +195,28 @@ app dbStructure proc cols conf apiRequest =
|
||||
Right (sq, mq) -> do
|
||||
let stm = createWriteStatement sq mq
|
||||
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
|
||||
(iPreferRepresentation apiRequest) []
|
||||
(iPreferRepresentation apiRequest) [] pgVer
|
||||
row <- H.statement (toS $ pjRaw pJson) stm
|
||||
let (_, queryTotal, _, body) = extractQueryResult row
|
||||
|
||||
updateIsNoOp = S.null cols
|
||||
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $
|
||||
if shouldCount then Just queryTotal else Nothing
|
||||
minimalHeaders = [contentRangeHeader]
|
||||
fullHeaders = toHeader contentType : minimalHeaders
|
||||
|
||||
status | queryTotal == 0 && not updateIsNoOp = status404
|
||||
| iPreferRepresentation apiRequest == Full = status200
|
||||
| otherwise = status204
|
||||
|
||||
case (contentType, iPreferRepresentation apiRequest) of
|
||||
(CTSingularJSON, Full)
|
||||
| queryTotal == 1 -> return $ responseLBS status fullHeaders (toS body)
|
||||
| otherwise -> HT.condemn >> (return . errorResponseFor . singularityError) queryTotal
|
||||
|
||||
(_, Full) ->
|
||||
return $ responseLBS status fullHeaders (toS body)
|
||||
|
||||
(_, _) ->
|
||||
return $ responseLBS status minimalHeaders mempty
|
||||
|
||||
let (_, queryTotal, _, body, gucHeaders) = row
|
||||
case gucHeaders of
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
Right ghdrs -> do
|
||||
let
|
||||
updateIsNoOp = S.null cols
|
||||
status | queryTotal == 0 && not updateIsNoOp = status404
|
||||
| iPreferRepresentation apiRequest == Full = status200
|
||||
| otherwise = status204
|
||||
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing
|
||||
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||
then ([Just $ toHeader contentType, profileH], toS body)
|
||||
else ([], mempty)
|
||||
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
|
||||
if contentType == CTSingularJSON && queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor . singularityError $ queryTotal
|
||||
else
|
||||
return $ responseLBS status headers rBody
|
||||
|
||||
(ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just ProcessedJSON{pjRaw, pjType, pjKeys}) ->
|
||||
case mutateSqlParts tSchema tName of
|
||||
@@ -214,19 +235,22 @@ app dbStructure proc cols conf apiRequest =
|
||||
else do
|
||||
row <- H.statement (toS pjRaw) $
|
||||
createWriteStatement sq mq (contentType == CTSingularJSON) False
|
||||
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) []
|
||||
let (_, queryTotal, _, body) = extractQueryResult row
|
||||
-- Makes sure the querystring pk matches the payload pk
|
||||
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
|
||||
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
|
||||
if queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor $ PutMatchingPkError
|
||||
else
|
||||
return $ if iPreferRepresentation apiRequest == Full
|
||||
then responseLBS status200 [toHeader contentType] (toS body)
|
||||
else responseLBS status204 [] ""
|
||||
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) [] pgVer
|
||||
let (_, queryTotal, _, body, gucHeaders) = row
|
||||
case gucHeaders of
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
Right ghdrs -> do
|
||||
let headers = addHeadersIfNotIncluded (catMaybes [Just $ toHeader contentType, profileH]) (unwrapGucHeader <$> ghdrs)
|
||||
(status, rBody) = if iPreferRepresentation apiRequest == Full then (status200, toS body) else (status204, mempty)
|
||||
-- Makes sure the querystring pk matches the payload pk
|
||||
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
|
||||
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
|
||||
if queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor $ PutMatchingPkError
|
||||
else
|
||||
return $ responseLBS status headers rBody
|
||||
|
||||
(ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
|
||||
case mutateSqlParts tSchema tName of
|
||||
@@ -235,65 +259,68 @@ app dbStructure proc cols conf apiRequest =
|
||||
let stm = createWriteStatement sq mq
|
||||
(contentType == CTSingularJSON) False
|
||||
(contentType == CTTextCSV)
|
||||
(iPreferRepresentation apiRequest) []
|
||||
(iPreferRepresentation apiRequest) [] pgVer
|
||||
row <- H.statement mempty stm
|
||||
let (_, queryTotal, _, body) = extractQueryResult row
|
||||
r = contentRangeH 1 0 $
|
||||
if shouldCount then Just queryTotal else Nothing
|
||||
if contentType == CTSingularJSON
|
||||
&& queryTotal /= 1
|
||||
&& iPreferRepresentation apiRequest == Full
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor . singularityError $ queryTotal
|
||||
else
|
||||
return $ if iPreferRepresentation apiRequest == Full
|
||||
then responseLBS status200 [toHeader contentType, r] (toS body)
|
||||
else responseLBS status204 [r] ""
|
||||
let (_, queryTotal, _, body, gucHeaders) = row
|
||||
case gucHeaders of
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
Right ghdrs -> do
|
||||
let
|
||||
status = if iPreferRepresentation apiRequest == Full then status200 else status204
|
||||
contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
||||
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||
then ([Just $ toHeader contentType, profileH], toS body)
|
||||
else ([], mempty)
|
||||
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
|
||||
if contentType == CTSingularJSON
|
||||
&& queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor . singularityError $ queryTotal
|
||||
else
|
||||
return $ responseLBS status headers rBody
|
||||
|
||||
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->
|
||||
let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in
|
||||
case mTable of
|
||||
Nothing -> return notFound
|
||||
Just table ->
|
||||
let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
|
||||
return $ responseLBS status200 [allOrigins, acceptH] ""
|
||||
let allowH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET")
|
||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header in
|
||||
return $ responseLBS status200 [allOrigins, allowH] mempty
|
||||
|
||||
(ActionInvoke _, TargetProc qi _, Just pJson) ->
|
||||
let returnsScalar = case proc of
|
||||
Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True
|
||||
_ -> False
|
||||
rpcBinaryField = if returnsScalar
|
||||
then Right Nothing
|
||||
else binaryField contentType =<< fldNames
|
||||
parts = (,) <$> readSqlParts <*> rpcBinaryField in
|
||||
case parts of
|
||||
(ActionInvoke invMethod, TargetProc qi@(QualifiedIdentifier tSchema pName) _, Just pJson) ->
|
||||
let tName = fromMaybe pName $ procTableName =<< proc in
|
||||
case readSqlParts tSchema tName of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right ((q, cq), bField) -> do
|
||||
let singular = contentType == CTSingularJSON
|
||||
row <- H.statement (toS $ pjRaw pJson) $
|
||||
callProc qi (specifiedProcArgs cols proc) returnsScalar q cq shouldCount
|
||||
singular (iPreferSingleObjectParameter apiRequest)
|
||||
(contentType == CTTextCSV)
|
||||
(contentType `elem` rawContentTypes) bField
|
||||
(pgVersion dbStructure)
|
||||
let (tableTotal, queryTotal, body, jsonHeaders) =
|
||||
fromMaybe (Just 0, 0, "[]", "[]") row
|
||||
(status, contentRange) = rangeHeader queryTotal tableTotal
|
||||
decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]
|
||||
case decodedHeaders of
|
||||
Right (q, cq, bField) -> do
|
||||
let
|
||||
preferParams = iPreferParameters apiRequest
|
||||
pq = requestToCallProcQuery qi (specifiedProcArgs cols proc) returnsScalar preferParams
|
||||
stm = callProcStatement returnsScalar pq q cq shouldCount (contentType == CTSingularJSON)
|
||||
(contentType == CTTextCSV) (contentType `elem` rawContentTypes) (preferParams == Just MultipleObjects)
|
||||
bField pgVer
|
||||
row <- H.statement (toS $ pjRaw pJson) stm
|
||||
let (tableTotal, queryTotal, body, gucHeaders) = row
|
||||
case gucHeaders of
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
Right hs ->
|
||||
if singular && queryTotal /= 1
|
||||
Right ghdrs -> do
|
||||
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal tableTotal
|
||||
headers = addHeadersIfNotIncluded
|
||||
(catMaybes [Just $ toHeader contentType, Just contentRange, profileH])
|
||||
(unwrapGucHeader <$> ghdrs)
|
||||
rBody = if invMethod == InvHead then mempty else toS body
|
||||
if contentType == CTSingularJSON && queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor . singularityError $ queryTotal
|
||||
else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body)
|
||||
else
|
||||
return $ responseLBS status headers rBody
|
||||
|
||||
(ActionInspect, TargetDefaultSpec, Nothing) -> do
|
||||
(ActionInspect headersOnly, TargetDefaultSpec tSchema, Nothing) -> do
|
||||
let host = configHost conf
|
||||
port = toInteger $ configPort conf
|
||||
proxy = pickProxy $ toS <$> configProxyUri conf
|
||||
proxy = pickProxy $ toS <$> configOpenAPIProxyUri conf
|
||||
uri Nothing = ("http", host, port, "/")
|
||||
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
|
||||
uri' = uri proxy
|
||||
@@ -301,46 +328,57 @@ app dbStructure proc cols conf apiRequest =
|
||||
toTableInfo = map (\t -> let (s, tn) = (tableSchema t, tableName t) in (t, tableCols dbStructure s tn, tablePKCols dbStructure s tn))
|
||||
encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd $ dbPrimaryKeys dbStructure
|
||||
|
||||
body <- encodeApi <$> H.statement schema accessibleTables <*> H.statement schema schemaDescription <*> H.statement schema accessibleProcs
|
||||
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
|
||||
body <- encodeApi <$>
|
||||
H.statement tSchema accessibleTables <*>
|
||||
H.statement tSchema schemaDescription <*>
|
||||
H.statement tSchema accessibleProcs
|
||||
return $ responseLBS status200 (catMaybes [Just $ toHeader CTOpenAPI, profileH]) (if headersOnly then mempty else toS body)
|
||||
|
||||
_ -> return notFound
|
||||
|
||||
where
|
||||
notFound = responseLBS status404 [] ""
|
||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
||||
shouldCount = iPreferCount apiRequest
|
||||
schema = toS $ configSchema conf
|
||||
topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest
|
||||
rangeHeader queryTotal tableTotal =
|
||||
let lower = rangeOffset topLevelRange
|
||||
upper = lower + toInteger queryTotal - 1
|
||||
contentRange = contentRangeH lower upper (toInteger <$> tableTotal)
|
||||
status = rangeStatus lower upper (toInteger <$> tableTotal)
|
||||
in (status, contentRange)
|
||||
where
|
||||
notFound = responseLBS status404 [] ""
|
||||
maxRows = configMaxRows conf
|
||||
exactCount = iPreferCount apiRequest == Just ExactCount
|
||||
estimatedCount = iPreferCount apiRequest == Just EstimatedCount
|
||||
plannedCount = iPreferCount apiRequest == Just PlannedCount
|
||||
shouldCount = exactCount || estimatedCount
|
||||
topLevelRange = iTopLevelRange apiRequest
|
||||
returnsScalar = maybe False procReturnsScalar proc
|
||||
pgVer = pgVersion dbStructure
|
||||
profileH = contentProfileH <$> iProfile apiRequest
|
||||
|
||||
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) proc apiRequest
|
||||
fldNames = fieldNames <$> readReq
|
||||
readDbRequest = DbRead <$> readReq
|
||||
selectQuery = requestToQuery schema False <$> readDbRequest
|
||||
countQuery = requestToCountQuery schema <$> readDbRequest
|
||||
readSqlParts = (,) <$> selectQuery <*> countQuery
|
||||
mutationDbRequest s t = mutateRequest apiRequest t cols (tablePKCols dbStructure s t) =<< fldNames
|
||||
mutateSqlParts s t =
|
||||
(,) <$> selectQuery
|
||||
<*> (requestToQuery schema False . DbMutate <$> mutationDbRequest s t)
|
||||
readSqlParts s t =
|
||||
let
|
||||
readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
|
||||
in
|
||||
(,,) <$>
|
||||
(readRequestToQuery <$> readReq) <*>
|
||||
(readRequestToCountQuery <$> readReq) <*>
|
||||
(binaryField contentType rawContentTypes returnsScalar =<< readReq)
|
||||
|
||||
responseContentTypeOrError :: [ContentType] -> Action -> Target -> Either Response ContentType
|
||||
responseContentTypeOrError accepts action target = serves contentTypesForRequest accepts
|
||||
mutateSqlParts s t =
|
||||
let
|
||||
readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
|
||||
mutReq = mutateRequest s t apiRequest cols (tablePKCols dbStructure s t) =<< readReq
|
||||
in
|
||||
(,) <$>
|
||||
(readRequestToQuery <$> readReq) <*>
|
||||
(mutateRequestToQuery <$> mutReq)
|
||||
|
||||
responseContentTypeOrError :: [ContentType] -> [ContentType] -> Action -> Target -> Either Response ContentType
|
||||
responseContentTypeOrError accepts rawContentTypes action target = serves contentTypesForRequest accepts
|
||||
where
|
||||
contentTypesForRequest = case action of
|
||||
ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ++ rawContentTypes
|
||||
ActionRead _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
++ rawContentTypes
|
||||
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ++ rawContentTypes ++
|
||||
[CTOpenAPI | tpIsRootSpec target]
|
||||
ActionInspect -> [CTOpenAPI, CTApplicationJSON]
|
||||
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
++ rawContentTypes
|
||||
++ [CTOpenAPI | tpIsRootSpec target]
|
||||
ActionInspect _ -> [CTOpenAPI, CTApplicationJSON]
|
||||
ActionInfo -> [CTTextCSV]
|
||||
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
serves sProduces cAccepts =
|
||||
@@ -352,41 +390,34 @@ responseContentTypeOrError accepts action target = serves contentTypesForRequest
|
||||
| If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that
|
||||
| `?select=...` contains only one field other than `*`
|
||||
-}
|
||||
binaryField :: ContentType -> [FieldName] -> Either Response (Maybe FieldName)
|
||||
binaryField ct fldNames
|
||||
binaryField :: ContentType -> [ContentType] -> Bool -> ReadRequest -> Either Response (Maybe FieldName)
|
||||
binaryField ct rawContentTypes isScalarProc readReq
|
||||
| isScalarProc = Right Nothing
|
||||
| ct `elem` rawContentTypes =
|
||||
let fieldName = headMay fldNames in
|
||||
if length fldNames == 1 && fieldName /= Just "*"
|
||||
then Right fieldName
|
||||
else Left . errorResponseFor $ BinaryFieldError ct
|
||||
| otherwise = Right Nothing
|
||||
where
|
||||
fldNames = fstFieldNames readReq
|
||||
|
||||
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
|
||||
splitKeyValue kv = (k, BS.tail v)
|
||||
where (k, v) = BS.break (== '=') kv
|
||||
locationH :: TableName -> [BS.ByteString] -> Header
|
||||
locationH tName fields =
|
||||
let
|
||||
locationFields = renderSimpleQuery True $ splitKeyValue <$> fields
|
||||
in
|
||||
(hLocation, "/" <> toS tName <> locationFields)
|
||||
where
|
||||
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
|
||||
splitKeyValue kv =
|
||||
let (k, v) = BS.break (== '=') kv
|
||||
in (k, BS.tail v)
|
||||
|
||||
renderLocationFields :: [BS.ByteString] -> BS.ByteString
|
||||
renderLocationFields fields =
|
||||
renderSimpleQuery True $ map splitKeyValue fields
|
||||
contentLocationH :: TableName -> ByteString -> Header
|
||||
contentLocationH tName qString =
|
||||
("Content-Location", "/" <> toS tName <> if BS.null qString then mempty else "?" <> toS qString)
|
||||
|
||||
rangeStatus :: Integer -> Integer -> Maybe Integer -> Status
|
||||
rangeStatus _ _ Nothing = status200
|
||||
rangeStatus lower upper (Just total)
|
||||
| lower > total = status416
|
||||
| (1 + upper - lower) < total = status206
|
||||
| otherwise = status200
|
||||
|
||||
contentRangeH :: (Integral a, Show a) => a -> a -> Maybe a -> Header
|
||||
contentRangeH lower upper total =
|
||||
("Content-Range", headerValue)
|
||||
where
|
||||
headerValue = rangeString <> "/" <> totalString
|
||||
rangeString
|
||||
| totalNotZero && fromInRange = show lower <> "-" <> show upper
|
||||
| otherwise = "*"
|
||||
totalString = maybe "*" show total
|
||||
totalNotZero = maybe True (0 /=) total
|
||||
fromInRange = lower <= upper
|
||||
|
||||
extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
|
||||
extractQueryResult = fromMaybe (Nothing, 0, [], "")
|
||||
contentProfileH :: Schema -> Header
|
||||
contentProfileH schema =
|
||||
("Content-Profile", toS schema)
|
||||
|
||||
+45
-17
@@ -37,6 +37,7 @@ import Control.Lens (preview)
|
||||
import Control.Monad (fail)
|
||||
import Crypto.JWT (StringOrURI, stringOrUri)
|
||||
import Data.List (lookup)
|
||||
import Data.List.NonEmpty (NonEmpty, fromList)
|
||||
import Data.Scientific (floatingOrInteger)
|
||||
import Data.Text (dropEnd, dropWhileEnd,
|
||||
intercalate, lines, splitOn,
|
||||
@@ -46,8 +47,10 @@ import Data.Text.IO (hPutStrLn)
|
||||
import Data.Version (versionBranch)
|
||||
import Development.GitRev (gitHash)
|
||||
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
||||
import Numeric (readOct)
|
||||
import Paths_postgrest (version)
|
||||
import System.IO.Error (IOError)
|
||||
import System.Posix.Types (FileMode)
|
||||
|
||||
import Control.Applicative
|
||||
import Data.Monoid
|
||||
@@ -58,21 +61,22 @@ import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))
|
||||
|
||||
import PostgREST.Error (ApiRequestError (..))
|
||||
import PostgREST.Parsers (pRoleClaimKey)
|
||||
import PostgREST.Types (JSPath, JSPathExp (..),
|
||||
QualifiedIdentifier (..))
|
||||
import PostgREST.Types (JSPath, JSPathExp (..))
|
||||
import Protolude hiding (concat, hPutStrLn, intercalate, null,
|
||||
take, (<>))
|
||||
|
||||
|
||||
|
||||
-- | Config file settings for the server
|
||||
data AppConfig = AppConfig {
|
||||
configDatabase :: Text
|
||||
, configAnonRole :: Text
|
||||
, configProxyUri :: Maybe Text
|
||||
, configSchema :: Text
|
||||
, configOpenAPIProxyUri :: Maybe Text
|
||||
, configSchemas :: NonEmpty Text
|
||||
, configHost :: Text
|
||||
, configPort :: Int
|
||||
, configSocket :: Maybe Text
|
||||
, configSocket :: Maybe FilePath
|
||||
, configSocketMode :: Either Text FileMode
|
||||
|
||||
, configJwtSecret :: Maybe B.ByteString
|
||||
, configJwtSecretIsBase64 :: Bool
|
||||
@@ -87,7 +91,8 @@ data AppConfig = AppConfig {
|
||||
, configRoleClaimKey :: Either ApiRequestError JSPath
|
||||
, configExtraSearchPath :: [Text]
|
||||
|
||||
, configRootSpec :: Maybe QualifiedIdentifier
|
||||
, configRootSpec :: Maybe Text
|
||||
, configRawMediaTypes :: [B.ByteString]
|
||||
}
|
||||
|
||||
configPoolTimeout' :: (Fractional a) => AppConfig -> a
|
||||
@@ -136,7 +141,7 @@ readOptions = do
|
||||
-- Now read the actual config file
|
||||
conf <- catches (C.load cfgPath)
|
||||
[ Handler (\(ex :: IOError) -> exitErr $ "Cannot open config file:\n\t" <> show ex)
|
||||
, Handler (\(C.ParseError err) -> exitErr $ "Error parsing config file:\n\t" <> err)
|
||||
, Handler (\(C.ParseError err) -> exitErr $ "Error parsing config file:\n" <> err)
|
||||
]
|
||||
|
||||
case C.runParser parseConfig conf of
|
||||
@@ -146,16 +151,16 @@ readOptions = do
|
||||
return appConf
|
||||
|
||||
where
|
||||
dbSchema = reqString "db-schema"
|
||||
parseConfig =
|
||||
AppConfig
|
||||
<$> reqString "db-uri"
|
||||
<*> reqString "db-anon-role"
|
||||
<*> optString "server-proxy-uri"
|
||||
<*> dbSchema
|
||||
<*> (fromList . splitOnCommas <$> reqValue "db-schema")
|
||||
<*> (fromMaybe "!4" <$> optString "server-host")
|
||||
<*> (fromMaybe 3000 <$> optInt "server-port")
|
||||
<*> optString "server-unix-socket"
|
||||
<*> (fmap unpack <$> optString "server-unix-socket")
|
||||
<*> parseSocketFileMode "server-unix-socket-mode"
|
||||
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
|
||||
<*> (fromMaybe False <$> optBool "secret-is-base64")
|
||||
<*> parseJwtAudience "jwt-aud"
|
||||
@@ -166,8 +171,22 @@ readOptions = do
|
||||
<*> pure False
|
||||
<*> (fmap (fmap coerceText) <$> C.subassocs "app.settings" C.value)
|
||||
<*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> optValue "role-claim-key")
|
||||
<*> (maybe ["public"] splitExtraSearchPath <$> optValue "db-extra-search-path")
|
||||
<*> ((\x y -> QualifiedIdentifier x <$> y) <$> dbSchema <*> optString "root-spec")
|
||||
<*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path")
|
||||
<*> optString "root-spec"
|
||||
<*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types")
|
||||
|
||||
parseSocketFileMode :: C.Key -> C.Parser C.Config (Either Text FileMode)
|
||||
parseSocketFileMode k =
|
||||
C.optional k C.string >>= \case
|
||||
Nothing -> pure $ Right 432 -- return default 660 mode if no value was provided
|
||||
Just fileModeText ->
|
||||
case (readOct . unpack) fileModeText of
|
||||
[] ->
|
||||
pure $ Left "Invalid server-unix-socket-mode: not an octal"
|
||||
(fileMode, _):_ ->
|
||||
if fileMode < 384 || fileMode > 511
|
||||
then pure $ Left "Invalid server-unix-socket-mode: needs to be between 600 and 777"
|
||||
else pure $ Right fileMode
|
||||
|
||||
parseJwtAudience :: C.Key -> C.Parser C.Config (Maybe StringOrURI)
|
||||
parseJwtAudience k =
|
||||
@@ -181,6 +200,9 @@ readOptions = do
|
||||
reqString :: C.Key -> C.Parser C.Config Text
|
||||
reqString k = C.required k C.string
|
||||
|
||||
reqValue :: C.Key -> C.Parser C.Config C.Value
|
||||
reqValue k = C.required k C.value
|
||||
|
||||
optString :: C.Key -> C.Parser C.Config (Maybe Text)
|
||||
optString k = mfilter (/= "") <$> C.optional k C.string
|
||||
|
||||
@@ -211,9 +233,9 @@ readOptions = do
|
||||
parseRoleClaimKey (C.String s) = pRoleClaimKey s
|
||||
parseRoleClaimKey v = pRoleClaimKey $ show v
|
||||
|
||||
splitExtraSearchPath :: C.Value -> [Text]
|
||||
splitExtraSearchPath (C.String s) = strip <$> splitOn "," s
|
||||
splitExtraSearchPath _ = []
|
||||
splitOnCommas :: C.Value -> [Text]
|
||||
splitOnCommas (C.String s) = strip <$> splitOn "," s
|
||||
splitOnCommas _ = []
|
||||
|
||||
opts = info (helper <*> pathParser) $
|
||||
fullDesc
|
||||
@@ -248,13 +270,16 @@ readOptions = do
|
||||
|## unix socket location
|
||||
|## if specified it takes precedence over server-port
|
||||
|# server-unix-socket = "/tmp/pgrst.sock"
|
||||
|## unix socket file mode
|
||||
|## when none is provided, 660 is applied by default
|
||||
|# server-unix-socket-mode = "660"
|
||||
|
|
||||
|## base url for swagger output
|
||||
|# server-proxy-uri = ""
|
||||
|# openapi-server-proxy-uri = ""
|
||||
|
|
||||
|## choose a secret, JSON Web Key (or set) to enable JWT auth
|
||||
|## (use "@filename" to load from separate file)
|
||||
|# jwt-secret = "foo"
|
||||
|# jwt-secret = "secret_with_at_least_32_characters"
|
||||
|# secret-is-base64 = false
|
||||
|# jwt-aud = "your_audience_claim"
|
||||
|
|
||||
@@ -273,6 +298,9 @@ readOptions = do
|
||||
|## stored proc that overrides the root "/" spec
|
||||
|## it must be inside the db-schema
|
||||
|# root-spec = "stored_proc_name"
|
||||
|
|
||||
|## content types to produce raw output
|
||||
|# raw-media-types="image/png, image/jpg"
|
||||
|]
|
||||
|
||||
pathParser :: Parser FilePath
|
||||
|
||||
+185
-195
@@ -14,248 +14,217 @@ A query tree is built in case of resource embedding. By inferring the relationsh
|
||||
module PostgREST.DbRequestBuilder (
|
||||
readRequest
|
||||
, mutateRequest
|
||||
, fieldNames
|
||||
) where
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.Set as S
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.Set as S
|
||||
|
||||
import Control.Arrow ((***))
|
||||
import Control.Lens.Getter (view)
|
||||
import Control.Lens.Tuple (_1)
|
||||
import Data.Either.Combinators (mapLeft)
|
||||
import Data.Foldable (foldr1)
|
||||
import Data.List (delete)
|
||||
import Data.Maybe (fromJust)
|
||||
import Data.Text (isInfixOf)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
import Unsafe (unsafeHead)
|
||||
|
||||
import Control.Applicative
|
||||
import Data.Tree
|
||||
import Network.Wai
|
||||
|
||||
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
|
||||
PreferRepresentation (..),
|
||||
PreferRepresentation (..), Target (..))
|
||||
import PostgREST.ApiRequest (Action (..), ApiRequest (..))
|
||||
import PostgREST.Error (ApiRequestError (..), errorResponseFor)
|
||||
import PostgREST.Parsers
|
||||
import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange)
|
||||
import PostgREST.Types
|
||||
import Protolude hiding (from)
|
||||
|
||||
readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest
|
||||
readRequest maxRows allRels proc apiRequest =
|
||||
readRequest :: Schema -> TableName -> Maybe Integer -> [Relation] -> ApiRequest -> Either Response ReadRequest
|
||||
readRequest schema rootTableName maxRows allRels apiRequest =
|
||||
mapLeft errorResponseFor $
|
||||
treeRestrictRange maxRows =<<
|
||||
augumentRequestWithJoin schema relations =<<
|
||||
augumentRequestWithJoin schema rootRels =<<
|
||||
addFiltersOrdersRanges apiRequest <*>
|
||||
(buildReadRequest <$> pRequestSelect (iSelect apiRequest))
|
||||
(initReadRequest rootName <$> pRequestSelect sel)
|
||||
where
|
||||
action = iAction apiRequest
|
||||
(schema, rootTableName) = fromJust $ -- Make it safe
|
||||
let target = iTarget apiRequest in
|
||||
case target of
|
||||
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
|
||||
(TargetProc (QualifiedIdentifier s pName) _ ) -> Just (s, tName)
|
||||
where
|
||||
tName = case pdReturnType <$> proc of
|
||||
Just (SetOf (Composite qi)) -> qiName qi
|
||||
Just (Single (Composite qi)) -> qiName qi
|
||||
_ -> pName
|
||||
sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param
|
||||
(rootName, rootRels) = rootWithRels schema rootTableName allRels (iAction apiRequest)
|
||||
|
||||
_ -> Nothing
|
||||
-- Get the root table name with its relationships according to the Action type.
|
||||
-- This is done because of the shape of the final SQL Query. The mutation cases are wrapped in a WITH {sourceCTEName}(see Statements.hs).
|
||||
-- So we need a FROM {sourceCTEName} instead of FROM {tableName}.
|
||||
rootWithRels :: Schema -> TableName -> [Relation] -> Action -> (QualifiedIdentifier, [Relation])
|
||||
rootWithRels schema rootTableName allRels action = case action of
|
||||
ActionRead _ -> (QualifiedIdentifier schema rootTableName, allRels) -- normal read case
|
||||
_ -> (QualifiedIdentifier mempty sourceCTEName, mapMaybe toSourceRel allRels ++ allRels) -- mutation cases and calling proc
|
||||
where
|
||||
-- To enable embedding in the sourceCTEName cases we need to replace the foreign key tableName in the Relation
|
||||
-- with {sourceCTEName}. This way findRel can find relationships with sourceCTEName.
|
||||
toSourceRel :: Relation -> Maybe Relation
|
||||
toSourceRel r@Relation{relTable=t}
|
||||
| rootTableName == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
|
||||
| otherwise = Nothing
|
||||
|
||||
-- Build tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having
|
||||
-- an alias like "table_depth", this is related to issue #987.
|
||||
buildReadRequest :: [Tree SelectItem] -> ReadRequest
|
||||
buildReadRequest fieldTree =
|
||||
let rootDepth = 0
|
||||
rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in
|
||||
foldr (treeEntry rootDepth) (Node (Select [] rootNodeName Nothing [] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree
|
||||
where
|
||||
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
||||
treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
|
||||
let nxtDepth = succ depth in
|
||||
case fldForest of
|
||||
[] -> Node (q {select=fld:select q}, i) rForest
|
||||
_ -> Node (q, i) $
|
||||
foldr (treeEntry nxtDepth) (Node (Select [] fn Nothing [] [] [] [] allRange, (fn, Nothing, alias, relationDetail, nxtDepth)) []) fldForest:rForest
|
||||
|
||||
relations :: [Relation]
|
||||
relations = case action of
|
||||
ActionCreate -> fakeSourceRelations ++ allRels
|
||||
ActionUpdate -> fakeSourceRelations ++ allRels
|
||||
ActionDelete -> fakeSourceRelations ++ allRels
|
||||
ActionInvoke _ -> fakeSourceRelations ++ allRels
|
||||
_ -> allRels
|
||||
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels
|
||||
|
||||
-- in a relation where one of the tables matches "TableName"
|
||||
-- replace the name to that table with pg_source
|
||||
-- this "fake" relations is needed so that in a mutate query
|
||||
-- we can look at the "returning *" part which is wrapped with a "with"
|
||||
-- as just another table that has relations with other tables
|
||||
toSourceRelation :: TableName -> Relation -> Maybe Relation
|
||||
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
|
||||
| mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
|
||||
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
|
||||
| Just mt == (tableName <$> rt) = Just $ r {relLinkTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
|
||||
| otherwise = Nothing
|
||||
-- Build the initial tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having
|
||||
-- an alias like "table_depth", this is related to http://github.com/PostgREST/postgrest/issues/987.
|
||||
initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadRequest
|
||||
initReadRequest rootQi =
|
||||
foldr (treeEntry rootDepth) initial
|
||||
where
|
||||
rootDepth = 0
|
||||
rootSchema = qiSchema rootQi
|
||||
rootName = qiName rootQi
|
||||
initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, rootDepth)) []
|
||||
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
||||
treeEntry depth (Node fld@((fn, _),_,alias, embedHint) fldForest) (Node (q, i) rForest) =
|
||||
let nxtDepth = succ depth in
|
||||
case fldForest of
|
||||
[] -> Node (q {select=fld:select q}, i) rForest
|
||||
_ -> Node (q, i) $
|
||||
foldr (treeEntry nxtDepth)
|
||||
(Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,
|
||||
(fn, Nothing, alias, embedHint, nxtDepth)) [])
|
||||
fldForest:rForest
|
||||
|
||||
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
|
||||
treeRestrictRange maxRows request = pure $ nodeRestrictRange maxRows <$> request
|
||||
where
|
||||
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
||||
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
||||
|
||||
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
augumentRequestWithJoin schema allRels request =
|
||||
addRelations schema allRels Nothing request
|
||||
>>= addJoinConditions schema Nothing
|
||||
addRels schema allRels Nothing request
|
||||
>>= addJoinConditions Nothing
|
||||
|
||||
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
addRelations schema allRelations parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, relationDetail, depth)) forest) =
|
||||
addRels :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, depth)) forest) =
|
||||
case parentNode of
|
||||
Just (Node (Select{from=parentNodeTable}, _) _) ->
|
||||
let newFrom r = if tbl == nodeName then tableName (relTable r) else tbl
|
||||
Just (Node (Select{from=parentNodeQi}, _) _) ->
|
||||
let newFrom r = if qiName tbl == nodeName then tableQi (relFTable r) else tbl
|
||||
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
|
||||
rel :: Either ApiRequestError Relation
|
||||
rel = note (NoRelationBetween parentNodeTable nodeName) $
|
||||
findRelation schema allRelations nodeName parentNodeTable relationDetail in
|
||||
rel = findRel schema allRels (qiName parentNodeQi) nodeName hint
|
||||
in
|
||||
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
|
||||
_ ->
|
||||
let rn = (query, (nodeName, Just r, alias, Nothing, depth))
|
||||
r = Relation t [] t [] Root Nothing Nothing Nothing
|
||||
t = Table schema nodeName Nothing True in -- !!! TODO find another way to get the table from the query
|
||||
let rn = (query, (nodeName, Nothing, alias, Nothing, depth)) in
|
||||
Node rn <$> updateForest (Just $ Node rn forest)
|
||||
where
|
||||
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
||||
updateForest rq = mapM (addRelations schema allRelations rq) forest
|
||||
updateForest rq = mapM (addRels schema allRels rq) forest
|
||||
|
||||
findRelation :: Schema -> [Relation] -> NodeName -> TableName -> Maybe RelationDetail -> Maybe Relation
|
||||
findRelation schema allRelations nodeTableName parentNodeTableName relationDetail =
|
||||
find (\Relation{relTable, relColumns, relFTable, relFColumns, relType, relLinkTable} ->
|
||||
-- Both relation ends need to be on the exposed schema
|
||||
schema == tableSchema relTable && schema == tableSchema relFTable &&
|
||||
case relationDetail of
|
||||
Nothing ->
|
||||
|
||||
-- (request) => projects { ..., clients{...} }
|
||||
-- will match
|
||||
-- (relation type) => parent
|
||||
-- (entity) => clients {id}
|
||||
-- (foriegn entity) => projects {client_id}
|
||||
-- Finds a relationship between an origin and a target in the request: /origin?select=target(*)
|
||||
-- If more than one relationship is found then the request is ambiguous and we return an error.
|
||||
-- In that case the request can be disambiguated by adding precision to the target or by using a hint: /origin?select=target!hint(*)
|
||||
-- The elements will be matched according to these rules:
|
||||
-- origin = table / view
|
||||
-- target = table / view / constraint / column-from-origin
|
||||
-- hint = table / view / constraint / column-from-origin / column-from-target
|
||||
-- (hint can take table / view values to aid in finding the junction in an m2m relationship)
|
||||
findRel :: Schema -> [Relation] -> NodeName -> NodeName -> Maybe EmbedHint -> Either ApiRequestError Relation
|
||||
findRel schema allRels origin target hint =
|
||||
case rel of
|
||||
[] -> Left $ NoRelBetween origin target
|
||||
[r] -> Right r
|
||||
rs ->
|
||||
-- Return error if more than one relationship is found, unless we're in a self reference case.
|
||||
--
|
||||
-- Here we handle a self reference relationship to not cause a breaking change:
|
||||
-- In a self reference we get two relationships with the same foreign key and relTable/relFtable but with different cardinalities(m2o/o2m)
|
||||
-- We output the O2M rel, the M2O rel can be obtained by using the origin column as an embed hint.
|
||||
let [rel0, rel1] = take 2 rs in
|
||||
if length rs == 2 && relConstraint rel0 == relConstraint rel1 && relTable rel0 == relTable rel1 && relFTable rel0 == relFTable rel1
|
||||
then note (NoRelBetween origin target) (find (\r -> relType r == O2M) rs)
|
||||
else Left $ AmbiguousRelBetween origin target rs
|
||||
where
|
||||
matchFKSingleCol hint_ cols = length cols == 1 && hint_ == (colName <$> head cols)
|
||||
rel = filter (
|
||||
\Relation{relTable, relColumns, relConstraint, relFTable, relFColumns, relType, relJunction} ->
|
||||
-- Both relationship ends need to be on the exposed schema
|
||||
schema == tableSchema relTable && schema == tableSchema relFTable &&
|
||||
(
|
||||
nodeTableName == tableName relTable && -- match relation table name
|
||||
parentNodeTableName == tableName relFTable -- match relation foreign table name
|
||||
) ||
|
||||
-- /projects?select=clients(*)
|
||||
origin == tableName relTable && -- projects
|
||||
target == tableName relFTable || -- clients
|
||||
|
||||
-- (request) => projects { ..., client_id{...} }
|
||||
-- will match
|
||||
-- (relation type) => parent
|
||||
-- (entity) => clients {id}
|
||||
-- (foriegn entity) => projects {client_id}
|
||||
(
|
||||
parentNodeTableName == tableName relFTable &&
|
||||
length relFColumns == 1 &&
|
||||
-- match common foreign key names(table_name_id, table_name_fk) to table_name
|
||||
(toS ("^" <> colName (unsafeHead relFColumns) <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS nodeTableName :: BS.ByteString)
|
||||
-- /projects?select=projects_client_id_fkey(*)
|
||||
(
|
||||
origin == tableName relTable && -- projects
|
||||
Just target == relConstraint -- projects_client_id_fkey
|
||||
) ||
|
||||
-- /projects?select=client_id(*)
|
||||
(
|
||||
origin == tableName relTable && -- projects
|
||||
matchFKSingleCol (Just target) relColumns -- client_id
|
||||
)
|
||||
) && (
|
||||
isNothing hint || -- hint is optional
|
||||
|
||||
-- /projects?select=clients!projects_client_id_fkey(*)
|
||||
hint == relConstraint || -- projects_client_id_fkey
|
||||
|
||||
-- /projects?select=clients!client_id(*) or /projects?select=clients!id(*)
|
||||
matchFKSingleCol hint relColumns || -- client_id
|
||||
matchFKSingleCol hint relFColumns || -- id
|
||||
|
||||
-- /users?select=tasks!users_tasks(*)
|
||||
(
|
||||
relType == M2M && -- many-to-many between users and tasks
|
||||
hint == (tableName . junTable <$> relJunction) -- users_tasks
|
||||
)
|
||||
)
|
||||
|
||||
-- (request) => project_id { ..., client_id{...} }
|
||||
-- will match
|
||||
-- (relation type) => parent
|
||||
-- (entity) => clients {id}
|
||||
-- (foriegn entity) => projects {client_id}
|
||||
-- this case works becasue before reaching this place
|
||||
-- addRelation will turn project_id to project so the above condition will match
|
||||
|
||||
Just rd ->
|
||||
|
||||
-- (request) => clients { ..., projects.client_id{...} }
|
||||
-- will match
|
||||
-- (relation type) => child
|
||||
-- (entity) => clients {id}
|
||||
-- (foriegn entity) => projects {client_id}
|
||||
(
|
||||
relType == Child &&
|
||||
nodeTableName == tableName relTable && -- match relation table name
|
||||
parentNodeTableName == tableName relFTable && -- match relation foreign table name
|
||||
length relColumns == 1 &&
|
||||
rd == colName (unsafeHead relColumns)
|
||||
) ||
|
||||
|
||||
-- (request) => message { ..., person_detail.sender{...} }
|
||||
-- will match
|
||||
-- (relation type) => parent
|
||||
-- (entity) => message {sender}
|
||||
-- (foriegn entity) => person_detail {id}
|
||||
(
|
||||
relType == Parent &&
|
||||
nodeTableName == tableName relTable && -- match relation table name
|
||||
parentNodeTableName == tableName relFTable && -- match relation foreign table name
|
||||
length relFColumns == 1 &&
|
||||
rd == colName (unsafeHead relFColumns)
|
||||
) ||
|
||||
|
||||
-- (request) => tasks { ..., users.tasks_users{...} }
|
||||
-- will match
|
||||
-- (relation type) => many
|
||||
-- (entity) => users
|
||||
-- (foriegn entity) => tasks
|
||||
(
|
||||
relType == Many &&
|
||||
nodeTableName == tableName relTable && -- match relation table name
|
||||
parentNodeTableName == tableName relFTable && -- match relation foreign table name
|
||||
rd == tableName (fromJust relLinkTable)
|
||||
)
|
||||
) allRelations
|
||||
) allRels
|
||||
|
||||
-- previousAlias is only used for the case of self joins
|
||||
addJoinConditions :: Schema -> Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
addJoinConditions schema previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, relation, _, _, depth)) forest) =
|
||||
case relation of
|
||||
Just Relation{relType=Root} -> Node node <$> updatedForest -- this is the root node
|
||||
Just rel@Relation{relType=Parent} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
|
||||
Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
|
||||
Just rel@Relation{relType=Many, relLinkTable=(Just linkTable)} ->
|
||||
let rq = augmentQuery rel in
|
||||
Node (rq{implicitJoins=tableName linkTable:implicitJoins rq}, nodeProps) <$> updatedForest
|
||||
_ -> Left UnknownRelation
|
||||
addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, depth)) forest) =
|
||||
case rel of
|
||||
Just r@Relation{relType=O2M} -> Node (augmentQuery r, nodeProps) <$> updatedForest
|
||||
Just r@Relation{relType=M2O} -> Node (augmentQuery r, nodeProps) <$> updatedForest
|
||||
Just r@Relation{relType=M2M, relJunction=junction} ->
|
||||
case junction of
|
||||
Just Junction{junTable} ->
|
||||
let rq = augmentQuery r in
|
||||
Node (rq{implicitJoins=tableQi junTable:implicitJoins rq}, nodeProps) <$> updatedForest
|
||||
Nothing ->
|
||||
Left UnknownRelation
|
||||
Nothing -> Node node <$> updatedForest
|
||||
where
|
||||
newAlias = case isSelfJoin <$> relation of
|
||||
newAlias = case isSelfReference <$> rel of
|
||||
Just True
|
||||
| depth /= 0 -> Just (tbl <> "_" <> show depth) -- root node doesn't get aliased
|
||||
| depth /= 0 -> Just (qiName tbl <> "_" <> show depth) -- root node doesn't get aliased
|
||||
| otherwise -> Nothing
|
||||
_ -> Nothing
|
||||
augmentQuery rel =
|
||||
augmentQuery r =
|
||||
foldr
|
||||
(\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})
|
||||
query{fromAlias=newAlias}
|
||||
(getJoinConditions previousAlias newAlias rel)
|
||||
updatedForest = mapM (addJoinConditions schema newAlias) forest
|
||||
(getJoinConditions previousAlias newAlias r)
|
||||
updatedForest = mapM (addJoinConditions newAlias) forest
|
||||
|
||||
-- previousAlias and newAlias are used in the case of self joins
|
||||
getJoinConditions :: Maybe Alias -> Maybe Alias -> Relation -> [JoinCondition]
|
||||
getJoinConditions previousAlias newAlias (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols typ lt lc1 lc2) =
|
||||
getJoinConditions previousAlias newAlias (Relation Table{tableSchema=tSchema, tableName=tN} cols _ Table{tableName=ftN} fCols typ jun) =
|
||||
case typ of
|
||||
Child ->
|
||||
O2M ->
|
||||
zipWith (toJoinCondition tN ftN) cols fCols
|
||||
Parent ->
|
||||
M2O ->
|
||||
zipWith (toJoinCondition tN ftN) cols fCols
|
||||
Many ->
|
||||
let ltN = maybe "" tableName lt in
|
||||
zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2)
|
||||
Root -> witness
|
||||
M2M -> case jun of
|
||||
Just (Junction jt _ jc1 _ jc2) ->
|
||||
let jtn = tableName jt in
|
||||
zipWith (toJoinCondition tN jtn) cols jc1 ++ zipWith (toJoinCondition ftN jtn) fCols jc2
|
||||
Nothing -> []
|
||||
where
|
||||
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
|
||||
toJoinCondition tb ftb c fc =
|
||||
let qi1 = QualifiedIdentifier tSchema tb
|
||||
qi2 = QualifiedIdentifier tSchema ftb in
|
||||
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) newAlias, colName c)
|
||||
(maybe qi2 (QualifiedIdentifier mempty) previousAlias, colName fc)
|
||||
let qi1 = removeSourceCTESchema tSchema tb
|
||||
qi2 = removeSourceCTESchema tSchema ftb in
|
||||
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, colName c)
|
||||
(maybe qi2 (QualifiedIdentifier mempty) newAlias, colName fc)
|
||||
|
||||
-- On mutation and calling proc cases we wrap the target table in a WITH {sourceCTEName}
|
||||
-- if this happens remove the schema `FROM "schema"."{sourceCTEName}"` and use only the
|
||||
-- `FROM "{sourceCTEName}"`. If the schema remains the FROM would be invalid.
|
||||
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
|
||||
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then mempty else schema) tbl
|
||||
|
||||
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
|
||||
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
||||
@@ -266,7 +235,7 @@ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
||||
]
|
||||
{-
|
||||
The esence of what is going on above is that we are composing tree functions
|
||||
of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context
|
||||
of type (ReadRequest->ReadRequest) that are in (Either ApiRequestError a) context
|
||||
-}
|
||||
where
|
||||
filters :: Either ApiRequestError [(EmbedPath, Filter)]
|
||||
@@ -278,7 +247,7 @@ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
||||
(flts, logFrst) =
|
||||
case action of
|
||||
ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)
|
||||
ActionRead -> (iFilters apiRequest, iLogic apiRequest)
|
||||
ActionRead _ -> (iFilters apiRequest, iLogic apiRequest)
|
||||
_ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
|
||||
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
|
||||
orders = mapM pRequestOrder $ iOrder apiRequest
|
||||
@@ -318,11 +287,15 @@ addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
|
||||
where
|
||||
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
|
||||
|
||||
mutateRequest :: ApiRequest -> TableName -> S.Set FieldName -> [FieldName] -> [FieldName] -> Either Response MutateRequest
|
||||
mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
|
||||
mutateRequest :: Schema -> TableName -> ApiRequest -> S.Set FieldName -> [FieldName] -> ReadRequest -> Either Response MutateRequest
|
||||
mutateRequest schema tName apiRequest cols pkCols readReq = mapLeft errorResponseFor $
|
||||
case action of
|
||||
ActionCreate -> Right $ Insert tName cols ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings
|
||||
ActionUpdate -> Update tName cols <$> combinedLogic <*> pure returnings
|
||||
ActionCreate -> do
|
||||
confCols <- case iOnConflict apiRequest of
|
||||
Nothing -> pure pkCols
|
||||
Just param -> pRequestOnConflict param
|
||||
pure $ Insert qi cols ((,) <$> iPreferResolution apiRequest <*> Just confCols) [] returnings
|
||||
ActionUpdate -> Update qi cols <$> combinedLogic <*> pure returnings
|
||||
ActionSingleUpsert ->
|
||||
(\flts ->
|
||||
if null (iLogic apiRequest) &&
|
||||
@@ -331,14 +304,18 @@ mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
|
||||
all (\case
|
||||
Filter _ (OpExpr False (Op "eq" _)) -> True
|
||||
_ -> False) flts
|
||||
then Insert tName cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
|
||||
then Insert qi cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
|
||||
else
|
||||
Left InvalidFilters) =<< filters
|
||||
ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings
|
||||
ActionDelete -> Delete qi <$> combinedLogic <*> pure returnings
|
||||
_ -> Left UnsupportedVerb
|
||||
where
|
||||
qi = QualifiedIdentifier schema tName
|
||||
action = iAction apiRequest
|
||||
returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
|
||||
returnings =
|
||||
if iPreferRepresentation apiRequest == None
|
||||
then []
|
||||
else returningCols readReq
|
||||
filters = map snd <$> mapM pRequestFilter mutateFilters
|
||||
logic = map snd <$> mapM pRequestLogicTree logicFilters
|
||||
combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters
|
||||
@@ -346,13 +323,26 @@ mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
|
||||
(mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
|
||||
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
|
||||
|
||||
fieldNames :: ReadRequest -> [FieldName]
|
||||
fieldNames (Node (sel, _) forest) =
|
||||
map (fst . view _1) (select sel) ++ map colName fks
|
||||
returningCols :: ReadRequest -> [FieldName]
|
||||
returningCols rr@(Node _ forest) = returnings
|
||||
where
|
||||
fks = concatMap (fromMaybe [] . f) forest
|
||||
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _, _)) _) = Just cols
|
||||
f _ = Nothing
|
||||
fldNames = fstFieldNames rr
|
||||
-- Without fkCols, when a mutateRequest to /projects?select=name,clients(name) occurs, the RETURNING SQL part would be
|
||||
-- `RETURNING name`(see QueryBuilder).
|
||||
-- This would make the embedding fail because the following JOIN would need the "client_id" column from projects.
|
||||
-- So this adds the foreign key columns to ensure the embedding succeeds, result would be `RETURNING name, client_id`.
|
||||
-- This also works for the other relType's.
|
||||
fkCols = concat $ mapMaybe (\case
|
||||
Node (_, (_, Just Relation{relColumns=cols, relType=relTyp}, _, _, _)) _ -> case relTyp of
|
||||
O2M -> Just cols
|
||||
M2O -> Just cols
|
||||
M2M -> Just cols
|
||||
_ -> Nothing
|
||||
) forest
|
||||
-- However if the "client_id" is present, e.g. mutateRequest to /projects?select=client_id,name,clients(name)
|
||||
-- we would get `RETURNING client_id, name, client_id` and then we would produce the "column reference \"client_id\" is ambiguous"
|
||||
-- error from PostgreSQL. So we deduplicate with Set:
|
||||
returnings = S.toList . S.fromList $ fldNames ++ (colName <$> fkCols)
|
||||
|
||||
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
|
||||
-- they are later concatenated with AND in the QueryBuilder
|
||||
|
||||
+132
-138
@@ -40,34 +40,23 @@ import Unsafe (unsafeHead)
|
||||
|
||||
import Control.Applicative
|
||||
|
||||
import PostgREST.Private.Common
|
||||
import PostgREST.Types
|
||||
import Protolude
|
||||
|
||||
column :: HD.Value a -> HD.Row a
|
||||
column = HD.column . HD.nonNullable
|
||||
getDbStructure :: [Schema] -> PgVersion -> HT.Transaction DbStructure
|
||||
getDbStructure schemas pgVer = do
|
||||
HT.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
|
||||
tabs <- HT.statement () allTables
|
||||
cols <- HT.statement schemas $ allColumns tabs
|
||||
srcCols <- HT.statement schemas $ allSourceColumns cols pgVer
|
||||
m2oRels <- HT.statement () $ allM2ORels tabs cols
|
||||
keys <- HT.statement () $ allPrimaryKeys tabs
|
||||
procs <- HT.statement schemas allProcs
|
||||
|
||||
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
|
||||
nullableColumn = HD.column . HD.nullable
|
||||
|
||||
element :: HD.Value a -> HD.Array a
|
||||
element = HD.element . HD.nonNullable
|
||||
|
||||
param :: HE.Value a -> HE.Params a
|
||||
param = HE.param . HE.nonNullable
|
||||
|
||||
getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure
|
||||
getDbStructure schema pgVer = do
|
||||
HT.sql "set local schema ''" -- for getting the fully qualified name(schema.name) of every db object
|
||||
tabs <- HT.statement () allTables
|
||||
cols <- HT.statement schema $ allColumns tabs
|
||||
syns <- HT.statement schema $ allSynonyms cols pgVer
|
||||
childRels <- HT.statement () $ allChildRelations tabs cols
|
||||
keys <- HT.statement () $ allPrimaryKeys tabs
|
||||
procs <- HT.statement schema allProcs
|
||||
|
||||
let rels = addManyToManyRelations . addParentRelations $ addViewChildRelations syns childRels
|
||||
let rels = addM2MRels . addO2MRels $ addViewM2ORels srcCols m2oRels
|
||||
cols' = addForeignKeys rels cols
|
||||
keys' = addViewPrimaryKeys syns keys
|
||||
keys' = addViewPrimaryKeys srcCols keys
|
||||
|
||||
return DbStructure {
|
||||
dbTables = tabs
|
||||
@@ -102,13 +91,14 @@ decodeColumns tables =
|
||||
<*> nullableColumn HD.text
|
||||
<*> nullableColumn HD.text
|
||||
|
||||
decodeRelations :: [Table] -> [Column] -> HD.Result [Relation]
|
||||
decodeRelations tables cols =
|
||||
mapMaybe (relationFromRow tables cols) <$> HD.rowList relRow
|
||||
decodeRels :: [Table] -> [Column] -> HD.Result [Relation]
|
||||
decodeRels tables cols =
|
||||
mapMaybe (relFromRow tables cols) <$> HD.rowList relRow
|
||||
where
|
||||
relRow = (,,,,,)
|
||||
relRow = (,,,,,,)
|
||||
<$> column HD.text
|
||||
<*> column HD.text
|
||||
<*> column HD.text
|
||||
<*> column (HD.array (HD.dimension replicateM (element HD.text)))
|
||||
<*> column HD.text
|
||||
<*> column HD.text
|
||||
@@ -120,22 +110,30 @@ decodePks tables =
|
||||
where
|
||||
pkRow = (,,) <$> column HD.text <*> column HD.text <*> column HD.text
|
||||
|
||||
decodeSynonyms :: [Column] -> HD.Result [Synonym]
|
||||
decodeSynonyms cols =
|
||||
mapMaybe (synonymFromRow cols) <$> HD.rowList synRow
|
||||
decodeSourceColumns :: [Column] -> HD.Result [SourceColumn]
|
||||
decodeSourceColumns cols =
|
||||
mapMaybe (sourceColumnFromRow cols) <$> HD.rowList srcColRow
|
||||
where
|
||||
synRow = (,,,,,)
|
||||
srcColRow = (,,,,,)
|
||||
<$> column HD.text <*> column HD.text
|
||||
<*> column HD.text <*> column HD.text
|
||||
<*> column HD.text <*> column HD.text
|
||||
|
||||
decodeProcs :: HD.Result (M.HashMap Text [ProcDescription])
|
||||
sourceColumnFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe SourceColumn
|
||||
sourceColumnFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
|
||||
where
|
||||
col1 = findCol s1 t1 c1
|
||||
col2 = findCol s2 t2 c2
|
||||
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
|
||||
|
||||
decodeProcs :: HD.Result ProcsMap
|
||||
decodeProcs =
|
||||
-- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance
|
||||
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addName) <$> HD.rowList tblRow
|
||||
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addKey) <$> HD.rowList procRow
|
||||
where
|
||||
tblRow = ProcDescription
|
||||
procRow = ProcDescription
|
||||
<$> column HD.text
|
||||
<*> column HD.text
|
||||
<*> nullableColumn HD.text
|
||||
<*> (parseArgs <$> column HD.text)
|
||||
<*> (parseRetType
|
||||
@@ -145,8 +143,8 @@ decodeProcs =
|
||||
<*> column HD.char)
|
||||
<*> (parseVolatility <$> column HD.char)
|
||||
|
||||
addName :: ProcDescription -> (Text, ProcDescription)
|
||||
addName pd = (pdName pd, pd)
|
||||
addKey :: ProcDescription -> (QualifiedIdentifier, ProcDescription)
|
||||
addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd)
|
||||
|
||||
parseArgs :: Text -> [PgArg]
|
||||
parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')
|
||||
@@ -179,31 +177,34 @@ decodeProcs =
|
||||
| v == 's' = Stable
|
||||
| otherwise = Volatile -- only 'v' can happen here
|
||||
|
||||
allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
|
||||
allProcs = H.Statement (toS procsSqlQuery) (param HE.text) decodeProcs True
|
||||
allProcs :: H.Statement [Schema] ProcsMap
|
||||
allProcs = H.Statement (toS sql) (arrayParam HE.text) decodeProcs True
|
||||
where
|
||||
sql = procsSqlQuery <> " WHERE pn.nspname = ANY($1)"
|
||||
|
||||
accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
|
||||
accessibleProcs :: H.Statement Schema ProcsMap
|
||||
accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs True
|
||||
where
|
||||
sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"
|
||||
sql = procsSqlQuery <> " WHERE pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')"
|
||||
|
||||
procsSqlQuery :: SqlQuery
|
||||
procsSqlQuery = [q|
|
||||
SELECT p.proname as "proc_name",
|
||||
d.description as "proc_description",
|
||||
pg_get_function_arguments(p.oid) as "args",
|
||||
tn.nspname as "rettype_schema",
|
||||
coalesce(comp.relname, t.typname) as "rettype_name",
|
||||
p.proretset as "rettype_is_setof",
|
||||
t.typtype as "rettype_typ",
|
||||
p.provolatile
|
||||
SELECT
|
||||
pn.nspname as "proc_schema",
|
||||
p.proname as "proc_name",
|
||||
d.description as "proc_description",
|
||||
pg_get_function_arguments(p.oid) as "args",
|
||||
tn.nspname as "rettype_schema",
|
||||
coalesce(comp.relname, t.typname) as "rettype_name",
|
||||
p.proretset as "rettype_is_setof",
|
||||
t.typtype as "rettype_typ",
|
||||
p.provolatile
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace pn ON pn.oid = p.pronamespace
|
||||
JOIN pg_type t ON t.oid = p.prorettype
|
||||
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
||||
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
|
||||
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
|
||||
WHERE pn.nspname = $1
|
||||
|]
|
||||
|
||||
schemaDescription :: H.Statement Schema (Maybe Text)
|
||||
@@ -254,20 +255,20 @@ addForeignKeys rels = map addFk
|
||||
addFk col = col { colFK = fk col }
|
||||
fk col = find (lookupFn col) rels >>= relToFk col
|
||||
lookupFn :: Column -> Relation -> Bool
|
||||
lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==Child
|
||||
lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==M2O
|
||||
relToFk col Relation{relColumns=cols, relFColumns=colsF} = do
|
||||
pos <- L.elemIndex col cols
|
||||
colF <- atMay colsF pos
|
||||
return $ ForeignKey colF
|
||||
|
||||
{-
|
||||
Adds Views Child Relations based on Synonyms found, the logic is as follows:
|
||||
Adds Views M2O Relations based on SourceColumns found, the logic is as follows:
|
||||
|
||||
Having a Relation{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relType=Child} represented by:
|
||||
Having a Relation{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relType=M2O} represented by:
|
||||
|
||||
t1.c1------t2.c2
|
||||
|
||||
When only having a t1_view.c1 synonym, we need to add a View to Table Child Relation
|
||||
When only having a t1_view.c1 source column, we need to add a View-Table M2O Relation
|
||||
|
||||
t1.c1----t2.c2 t1.c1----------t2.c2
|
||||
-> ________/
|
||||
@@ -275,70 +276,72 @@ When only having a t1_view.c1 synonym, we need to add a View to Table Child Rela
|
||||
t1_view.c1 t1_view.c1
|
||||
|
||||
|
||||
When only having a t2_view.c2 synonym, we need to add a Table to View Child Relation
|
||||
When only having a t2_view.c2 source column, we need to add a Table-View M2O Relation
|
||||
|
||||
t1.c1----t2.c2 t1.c1----------t2.c2
|
||||
-> \________
|
||||
\
|
||||
t2_view.c2 t2_view.c1
|
||||
|
||||
When having t1_view.c1 and a t2_view.c2 synonyms, we need to add a View to View Child Relation in addition to the prior
|
||||
When having t1_view.c1 and a t2_view.c2 source columns, we need to add a View-View M2O Relation in addition to the prior
|
||||
|
||||
t1.c1----t2.c2 t1.c1----------t2.c2
|
||||
-> \________/
|
||||
/ \
|
||||
t1_view.c1 t2_view.c2 t1_view.c1-------t2_view.c1
|
||||
|
||||
The logic for composite pks is similar just need to make sure all the Relation columns have synonyms.
|
||||
The logic for composite pks is similar just need to make sure all the Relation columns have source columns.
|
||||
-}
|
||||
addViewChildRelations :: [Synonym] -> [Relation] -> [Relation]
|
||||
addViewChildRelations allSyns = concatMap (\rel ->
|
||||
addViewM2ORels :: [SourceColumn] -> [Relation] -> [Relation]
|
||||
addViewM2ORels allSrcCols = concatMap (\rel ->
|
||||
rel : case rel of
|
||||
Relation{relType=Child, relTable, relColumns, relFTable, relFColumns} ->
|
||||
Relation{relType=M2O, relTable, relColumns, relConstraint, relFTable, relFColumns} ->
|
||||
|
||||
let colSynsGroupedByView :: [Column] -> [[Synonym]]
|
||||
colSynsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $
|
||||
filter (\(c, _) -> c `elem` relCols) allSyns
|
||||
colsSyns = colSynsGroupedByView relColumns
|
||||
fColsSyns = colSynsGroupedByView relFColumns
|
||||
getView :: [Synonym] -> Table
|
||||
let srcColsGroupedByView :: [Column] -> [[SourceColumn]]
|
||||
srcColsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $
|
||||
filter (\(c, _) -> c `elem` relCols) allSrcCols
|
||||
relSrcCols = srcColsGroupedByView relColumns
|
||||
relFSrcCols = srcColsGroupedByView relFColumns
|
||||
getView :: [SourceColumn] -> Table
|
||||
getView = colTable . snd . unsafeHead
|
||||
syns `allSynsOf` cols = S.fromList (fst <$> syns) == S.fromList cols
|
||||
srcCols `allSrcColsOf` cols = S.fromList (fst <$> srcCols) == S.fromList cols
|
||||
-- Relation is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query.
|
||||
-- So we need to change the order of the synonyms to match the relColumns
|
||||
-- This could be avoided if the Relation type is improved with a structure that maintains the association of relColumns and relFColumns
|
||||
syns `sortAccordingTo` columns = sortOn (\(k, _) -> L.lookup k $ zip columns [0::Int ..]) syns
|
||||
-- So we need to change the order of the SourceColumns to match the relColumns
|
||||
-- TODO: This could be avoided if the Relation type is improved with a structure that maintains the association of relColumns and relFColumns
|
||||
srcCols `sortAccordingTo` cols = sortOn (\(k, _) -> L.lookup k $ zip cols [0::Int ..]) srcCols
|
||||
|
||||
viewTableChild =
|
||||
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
|
||||
relFTable relFColumns
|
||||
Child Nothing Nothing Nothing
|
||||
| syns <- colsSyns, syns `allSynsOf` relColumns ]
|
||||
viewTableM2O =
|
||||
[ Relation (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)
|
||||
relConstraint relFTable relFColumns
|
||||
M2O Nothing
|
||||
| srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns ]
|
||||
|
||||
tableViewChild =
|
||||
tableViewM2O =
|
||||
[ Relation relTable relColumns
|
||||
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
|
||||
Child Nothing Nothing Nothing
|
||||
| fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
|
||||
relConstraint
|
||||
(getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relFColumns)
|
||||
M2O Nothing
|
||||
| fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relFColumns ]
|
||||
|
||||
viewViewChild =
|
||||
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
|
||||
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
|
||||
Child Nothing Nothing Nothing
|
||||
| syns <- colsSyns, syns `allSynsOf` relColumns
|
||||
, fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
|
||||
viewViewM2O =
|
||||
[ Relation (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)
|
||||
relConstraint
|
||||
(getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relFColumns)
|
||||
M2O Nothing
|
||||
| srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns
|
||||
, fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relFColumns ]
|
||||
|
||||
in viewTableChild ++ tableViewChild ++ viewViewChild
|
||||
in viewTableM2O ++ tableViewM2O ++ viewViewM2O
|
||||
|
||||
_ -> [])
|
||||
|
||||
addParentRelations :: [Relation] -> [Relation]
|
||||
addParentRelations = concatMap (\rel@(Relation t c ft fc _ _ _ _) -> [rel, Relation ft fc t c Parent Nothing Nothing Nothing])
|
||||
addO2MRels :: [Relation] -> [Relation]
|
||||
addO2MRels = concatMap (\rel@(Relation t c cn ft fc _ _) -> [rel, Relation ft fc cn t c O2M Nothing])
|
||||
|
||||
addManyToManyRelations :: [Relation] -> [Relation]
|
||||
addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation links)
|
||||
addM2MRels :: [Relation] -> [Relation]
|
||||
addM2MRels rels = rels ++ addMirrorRel (mapMaybe junction2Rel junctions)
|
||||
where
|
||||
links = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==Child). relType) rels
|
||||
junctions = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==M2O). relType) rels
|
||||
groupFn :: Relation -> Text
|
||||
groupFn Relation{relTable=Table{tableSchema=s, tableName=t}} = s <> "_" <> t
|
||||
-- Reference : https://wiki.haskell.org/99_questions/Solutions/26
|
||||
@@ -346,19 +349,20 @@ addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation
|
||||
combinations 0 _ = [ [] ]
|
||||
combinations n xs = [ y:ys | y:xs' <- tails xs
|
||||
, ys <- combinations (n-1) xs']
|
||||
addMirrorRelation = concatMap (\rel@(Relation t c ft fc _ lt lc1 lc2) -> [rel, Relation ft fc t c Many lt lc2 lc1])
|
||||
link2Relation [
|
||||
Relation{relTable=lt, relColumns=lc1, relFTable=t, relFColumns=c},
|
||||
Relation{ relColumns=lc2, relFTable=ft, relFColumns=fc}
|
||||
junction2Rel [
|
||||
Relation{relTable=jt, relColumns=jc1, relConstraint=const1, relFTable=t, relFColumns=c},
|
||||
Relation{ relColumns=jc2, relConstraint=const2, relFTable=ft, relFColumns=fc}
|
||||
]
|
||||
| lc1 /= lc2 && length lc1 == 1 && length lc2 == 1 = Just $ Relation t c ft fc Many (Just lt) (Just lc1) (Just lc2)
|
||||
| jc1 /= jc2 && length jc1 == 1 && length jc2 == 1 = Just $ Relation t c Nothing ft fc M2M (Just $ Junction jt const1 jc1 const2 jc2)
|
||||
| otherwise = Nothing
|
||||
link2Relation _ = Nothing
|
||||
junction2Rel _ = Nothing
|
||||
addMirrorRel = concatMap (\rel@(Relation t c _ ft fc _ (Just (Junction jt const1 jc1 const2 jc2))) ->
|
||||
[rel, Relation ft fc Nothing t c M2M (Just (Junction jt const2 jc2 const1 jc1))])
|
||||
|
||||
addViewPrimaryKeys :: [Synonym] -> [PrimaryKey] -> [PrimaryKey]
|
||||
addViewPrimaryKeys syns = concatMap (\pk ->
|
||||
addViewPrimaryKeys :: [SourceColumn] -> [PrimaryKey] -> [PrimaryKey]
|
||||
addViewPrimaryKeys srcCols = concatMap (\pk ->
|
||||
let viewPks = (\(_, viewCol) -> PrimaryKey{pkTable=colTable viewCol, pkName=colName viewCol}) <$>
|
||||
filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) syns in
|
||||
filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) srcCols in
|
||||
pk : viewPks)
|
||||
|
||||
allTables :: H.Statement () [Table]
|
||||
@@ -384,9 +388,9 @@ allTables =
|
||||
GROUP BY table_schema, table_name, insertable
|
||||
ORDER BY table_schema, table_name |]
|
||||
|
||||
allColumns :: [Table] -> H.Statement Schema [Column]
|
||||
allColumns :: [Table] -> H.Statement [Schema] [Column]
|
||||
allColumns tabs =
|
||||
H.Statement sql (param HE.text) (decodeColumns tabs) True
|
||||
H.Statement sql (arrayParam HE.text) (decodeColumns tabs) True
|
||||
where
|
||||
sql = [q|
|
||||
SELECT DISTINCT
|
||||
@@ -404,7 +408,7 @@ allColumns tabs =
|
||||
array_to_string(enum_info.vals, ',') AS enum
|
||||
FROM (
|
||||
/*
|
||||
-- CTE based on pg_catalog to get only Primary and Foreign key columns outside api schema
|
||||
-- CTE based on pg_catalog to get PRIMARY/FOREIGN key and UNIQUE columns outside api schema
|
||||
*/
|
||||
WITH key_columns AS (
|
||||
SELECT
|
||||
@@ -420,11 +424,11 @@ allColumns tabs =
|
||||
pg_catalog.pg_class c,
|
||||
pg_catalog.pg_namespace n
|
||||
WHERE
|
||||
r.contype IN ('f', 'p')
|
||||
r.contype IN ('f', 'p', 'u')
|
||||
AND c.relkind IN ('r', 'v', 'f', 'm')
|
||||
AND r.conrelid = c.oid
|
||||
AND c.relnamespace = n.oid
|
||||
AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1)
|
||||
AND n.nspname <> ANY (ARRAY['pg_catalog', 'information_schema'] || $1)
|
||||
),
|
||||
/*
|
||||
-- CTE based on information_schema.columns
|
||||
@@ -437,7 +441,7 @@ allColumns tabs =
|
||||
nc.nspname::information_schema.sql_identifier AS table_schema,
|
||||
c.relname::information_schema.sql_identifier AS table_name,
|
||||
a.attname::information_schema.sql_identifier AS column_name,
|
||||
d.description::information_schema.sql_identifier AS description,
|
||||
d.description AS description,
|
||||
a.attnum::information_schema.cardinal_number AS ordinal_position,
|
||||
pg_get_expr(ad.adbin, ad.adrelid)::information_schema.character_data AS column_default,
|
||||
CASE
|
||||
@@ -526,7 +530,7 @@ allColumns tabs =
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char", 'm'::"char"]))
|
||||
AND (nc.nspname = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
|
||||
AND (nc.nspname = ANY ($1) OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
|
||||
/*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
|
||||
)
|
||||
SELECT
|
||||
@@ -571,39 +575,36 @@ columnFromRow tabs (s, t, n, desc, pos, nul, typ, u, l, p, d, e) = buildColumn <
|
||||
parseEnum :: Maybe Text -> [Text]
|
||||
parseEnum = maybe [] (split (==','))
|
||||
|
||||
allChildRelations :: [Table] -> [Column] -> H.Statement () [Relation]
|
||||
allChildRelations tabs cols =
|
||||
H.Statement sql HE.noParams (decodeRelations tabs cols) True
|
||||
allM2ORels :: [Table] -> [Column] -> H.Statement () [Relation]
|
||||
allM2ORels tabs cols =
|
||||
H.Statement sql HE.noParams (decodeRels tabs cols) True
|
||||
where
|
||||
sql = [q|
|
||||
SELECT ns1.nspname AS table_schema,
|
||||
tab.relname AS table_name,
|
||||
conname AS constraint_name,
|
||||
column_info.cols AS columns,
|
||||
ns2.nspname AS foreign_table_schema,
|
||||
other.relname AS foreign_table_name,
|
||||
column_info.refs AS foreign_columns
|
||||
FROM pg_constraint,
|
||||
LATERAL (SELECT array_agg(cols.attname) AS cols,
|
||||
array_agg(cols.attnum) AS nums,
|
||||
array_agg(refs.attname) AS refs
|
||||
FROM ( SELECT unnest(conkey) AS col, unnest(confkey) AS ref) k,
|
||||
LATERAL (SELECT * FROM pg_attribute
|
||||
WHERE attrelid = conrelid AND attnum = col)
|
||||
AS cols,
|
||||
LATERAL (SELECT * FROM pg_attribute
|
||||
WHERE attrelid = confrelid AND attnum = ref)
|
||||
AS refs)
|
||||
AS column_info,
|
||||
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = connamespace) AS ns1,
|
||||
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = conrelid) AS tab,
|
||||
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = confrelid) AS other,
|
||||
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = other.relnamespace) AS ns2
|
||||
LATERAL (
|
||||
SELECT array_agg(cols.attname) AS cols,
|
||||
array_agg(cols.attnum) AS nums,
|
||||
array_agg(refs.attname) AS refs
|
||||
FROM ( SELECT unnest(conkey) AS col, unnest(confkey) AS ref) k,
|
||||
LATERAL (SELECT * FROM pg_attribute WHERE attrelid = conrelid AND attnum = col) AS cols,
|
||||
LATERAL (SELECT * FROM pg_attribute WHERE attrelid = confrelid AND attnum = ref) AS refs) AS column_info,
|
||||
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = connamespace) AS ns1,
|
||||
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = conrelid) AS tab,
|
||||
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = confrelid) AS other,
|
||||
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = other.relnamespace) AS ns2
|
||||
WHERE confrelid != 0
|
||||
ORDER BY (conrelid, column_info.nums) |]
|
||||
|
||||
relationFromRow :: [Table] -> [Column] -> (Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation
|
||||
relationFromRow allTabs allCols (rs, rt, rcs, frs, frt, frcs) =
|
||||
Relation <$> table <*> cols <*> tableF <*> colsF <*> pure Child <*> pure Nothing <*> pure Nothing <*> pure Nothing
|
||||
relFromRow :: [Table] -> [Column] -> (Text, Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation
|
||||
relFromRow allTabs allCols (rs, rt, cn, rcs, frs, frt, frcs) =
|
||||
Relation <$> table <*> cols <*> pure (Just cn) <*> tableF <*> colsF <*> pure M2O <*> pure Nothing
|
||||
where
|
||||
findTable s t = find (\tbl -> tableSchema tbl == s && tableName tbl == t) allTabs
|
||||
findCol s t c = find (\col -> tableSchema (colTable col) == s && tableName (colTable col) == t && colName col == c) allCols
|
||||
@@ -722,9 +723,9 @@ pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey
|
||||
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
|
||||
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
|
||||
|
||||
allSynonyms :: [Column] -> PgVersion -> H.Statement Schema [Synonym]
|
||||
allSynonyms cols pgVer =
|
||||
H.Statement sql (param HE.text) (decodeSynonyms cols) True
|
||||
allSourceColumns :: [Column] -> PgVersion -> H.Statement [Schema] [SourceColumn]
|
||||
allSourceColumns cols pgVer =
|
||||
H.Statement sql (arrayParam HE.text) (decodeSourceColumns cols) True
|
||||
-- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
|
||||
where
|
||||
subselectRegex :: Text
|
||||
@@ -743,7 +744,7 @@ allSynonyms cols pgVer =
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
join pg_rewrite r on r.ev_class = c.oid
|
||||
where (c.relkind in ('v', 'm')) and n.nspname = $1
|
||||
where (c.relkind in ('v', 'm')) and n.nspname = ANY ($1)
|
||||
),
|
||||
removed_subselects as(
|
||||
select
|
||||
@@ -791,13 +792,6 @@ allSynonyms cols pgVer =
|
||||
where resorigtbl <> '0'
|
||||
order by view_schema, view_name, view_colum_name; |]
|
||||
|
||||
synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe Synonym
|
||||
synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
|
||||
where
|
||||
col1 = findCol s1 t1 c1
|
||||
col2 = findCol s2 t2 c2
|
||||
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
|
||||
|
||||
getPgVersion :: H.Session PgVersion
|
||||
getPgVersion = H.statement () $ H.Statement sql HE.noParams versionRow False
|
||||
where
|
||||
|
||||
+37
-7
@@ -16,12 +16,12 @@ module PostgREST.Error (
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.Text as T
|
||||
import qualified Hasql.Pool as P
|
||||
import qualified Hasql.Session as H
|
||||
import qualified Network.HTTP.Types.Status as HT
|
||||
|
||||
import Data.Aeson ((.=))
|
||||
import Data.Text (unwords)
|
||||
import Network.Wai (Response, responseLBS)
|
||||
import Text.Read (readMaybe)
|
||||
|
||||
@@ -48,8 +48,10 @@ data ApiRequestError
|
||||
| InvalidRange
|
||||
| InvalidBody ByteString
|
||||
| ParseRequestError Text Text
|
||||
| NoRelationBetween Text Text
|
||||
| NoRelBetween Text Text
|
||||
| AmbiguousRelBetween Text Text [Relation]
|
||||
| InvalidFilters
|
||||
| UnacceptableSchema [Text]
|
||||
| UnknownRelation -- Unreachable?
|
||||
| UnsupportedVerb -- Unreachable?
|
||||
deriving (Show, Eq)
|
||||
@@ -62,7 +64,9 @@ instance PgrstError ApiRequestError where
|
||||
status UnknownRelation = HT.status404
|
||||
status ActionInappropriate = HT.status405
|
||||
status (ParseRequestError _ _) = HT.status400
|
||||
status (NoRelationBetween _ _) = HT.status400
|
||||
status (NoRelBetween _ _) = HT.status400
|
||||
status AmbiguousRelBetween{} = HT.status300
|
||||
status (UnacceptableSchema _) = HT.status406
|
||||
|
||||
headers _ = [toHeader CTApplicationJSON]
|
||||
|
||||
@@ -77,13 +81,39 @@ instance JSON.ToJSON ApiRequestError where
|
||||
"message" .= ("HTTP Range error" :: Text)]
|
||||
toJSON UnknownRelation = JSON.object [
|
||||
"message" .= ("Unknown relation" :: Text)]
|
||||
toJSON (NoRelationBetween parent child) = JSON.object [
|
||||
"message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)]
|
||||
toJSON (NoRelBetween parent child) = JSON.object [
|
||||
"message" .= ("Could not find foreign keys between these entities. No relationship found between " <> parent <> " and " <> child :: Text)]
|
||||
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [
|
||||
"hint" .= ("By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)" :: Text),
|
||||
"message" .= ("More than one relationship was found for " <> parent <> " and " <> child :: Text),
|
||||
"details" .= (compressedRel <$> rels) ]
|
||||
toJSON UnsupportedVerb = JSON.object [
|
||||
"message" .= ("Unsupported HTTP verb" :: Text)]
|
||||
toJSON InvalidFilters = JSON.object [
|
||||
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
|
||||
toJSON (UnacceptableSchema schemas) = JSON.object [
|
||||
"message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas)]
|
||||
|
||||
compressedRel :: Relation -> JSON.Value
|
||||
compressedRel rel =
|
||||
let
|
||||
fmtTbl tbl = tableSchema tbl <> "." <> tableName tbl
|
||||
fmtEls els = "[" <> T.intercalate ", " els <> "]"
|
||||
in
|
||||
JSON.object $ [
|
||||
"origin" .= fmtTbl (relTable rel)
|
||||
, "target" .= fmtTbl (relFTable rel)
|
||||
, "cardinality" .= (show $ relType rel :: Text)
|
||||
] ++
|
||||
case (relType rel, relJunction rel, relConstraint rel) of
|
||||
(M2M, Just (Junction jt (Just const1) _ (Just const2) _), _) -> [
|
||||
"relationship" .= (fmtTbl jt <> fmtEls [const1] <> fmtEls [const2])
|
||||
]
|
||||
(_, _, Just relCon) -> [
|
||||
"relationship" .= (relCon <> fmtEls (colName <$> relColumns rel) <> fmtEls (colName <$> relFColumns rel))
|
||||
]
|
||||
(_, _, _) ->
|
||||
mempty
|
||||
|
||||
data PgError = PgError Authenticated P.UsageError
|
||||
type Authenticated = Bool
|
||||
@@ -224,7 +254,7 @@ instance JSON.ToJSON SimpleError where
|
||||
toJSON GucHeadersError = JSON.object [
|
||||
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text)]
|
||||
toJSON (BinaryFieldError ct) = JSON.object [
|
||||
"message" .= ((toS (toMime ct) <> " requested but a single column was not selected") :: Text)]
|
||||
"message" .= ((toS (toMime ct) <> " requested but more than one column was selected") :: Text)]
|
||||
toJSON ConnectionLostError = JSON.object [
|
||||
"message" .= ("Database connection lost, retrying the connection." :: Text)]
|
||||
|
||||
@@ -241,7 +271,7 @@ instance JSON.ToJSON SimpleError where
|
||||
"message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)]
|
||||
toJSON (SingularityError n) = JSON.object [
|
||||
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),
|
||||
"details" .= unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]]
|
||||
"details" .= T.unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]]
|
||||
|
||||
toJSON JwtTokenMissing = JSON.object [
|
||||
"message" .= ("Server lacks JWT secret" :: Text)]
|
||||
|
||||
+20
-10
@@ -10,6 +10,8 @@ module PostgREST.Middleware where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import Data.Scientific (FPFormat (..), formatScientific,
|
||||
isInteger)
|
||||
import qualified Hasql.Transaction as H
|
||||
|
||||
import Network.Wai (Application, Response)
|
||||
@@ -24,9 +26,8 @@ import PostgREST.Auth (JWTAttempt (..))
|
||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
|
||||
errorResponseFor)
|
||||
import PostgREST.QueryBuilder (pgFmtSetLocal, pgFmtSetLocalSearchPath,
|
||||
unquoted)
|
||||
import Protolude
|
||||
import PostgREST.QueryBuilder (setLocalQuery, setLocalSearchPathQuery)
|
||||
import Protolude hiding (head)
|
||||
|
||||
runWithClaims :: AppConfig -> JWTAttempt ->
|
||||
(ApiRequest -> H.Transaction Response) ->
|
||||
@@ -37,17 +38,19 @@ runWithClaims conf eClaims app req =
|
||||
JWTInvalid JWTExpired -> return . errorResponseFor . JwtTokenInvalid $ "JWT expired"
|
||||
JWTInvalid e -> return . errorResponseFor . JwtTokenInvalid . show $ e
|
||||
JWTClaims claims -> do
|
||||
H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql
|
||||
H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql
|
||||
mapM_ H.sql customReqCheck
|
||||
app req
|
||||
where
|
||||
headersSql = pgFmtSetLocal "request.header." <$> iHeaders req
|
||||
cookiesSql = pgFmtSetLocal "request.cookie." <$> iCookies req
|
||||
claimsSql = pgFmtSetLocal "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
|
||||
appSettingsSql = pgFmtSetLocal mempty <$> configSettings conf
|
||||
methodSql = setLocalQuery mempty ("request.method", toS $ iMethod req)
|
||||
pathSql = setLocalQuery mempty ("request.path", toS $ iPath req)
|
||||
headersSql = setLocalQuery "request.header." <$> iHeaders req
|
||||
cookiesSql = setLocalQuery "request.cookie." <$> iCookies req
|
||||
claimsSql = setLocalQuery "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
|
||||
appSettingsSql = setLocalQuery mempty <$> configSettings conf
|
||||
setRoleSql = maybeToList $ (\x ->
|
||||
pgFmtSetLocal mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
|
||||
setSearchPathSql = pgFmtSetLocalSearchPath $ configSchema conf : configExtraSearchPath conf
|
||||
setLocalQuery mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
|
||||
setSearchPathSql = setLocalSearchPathQuery (iSchema req : configExtraSearchPath conf)
|
||||
-- role claim defaults to anon if not specified in jwt
|
||||
claimsWithRole = M.union claims (M.singleton "role" anon)
|
||||
anon = JSON.String . toS $ configAnonRole conf
|
||||
@@ -58,3 +61,10 @@ defaultMiddle =
|
||||
gzip def
|
||||
. cors corsPolicy
|
||||
. staticPolicy (only [("favicon.ico", "static/favicon.ico")])
|
||||
|
||||
unquoted :: JSON.Value -> Text
|
||||
unquoted (JSON.String t) = t
|
||||
unquoted (JSON.Number n) =
|
||||
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
|
||||
unquoted (JSON.Bool b) = show b
|
||||
unquoted v = toS $ JSON.encode v
|
||||
|
||||
+22
-15
@@ -10,7 +10,7 @@ module PostgREST.OpenAPI (
|
||||
, pickProxy
|
||||
) where
|
||||
|
||||
import qualified Data.Set as Set
|
||||
import qualified Data.HashSet.InsOrd as Set
|
||||
|
||||
import Control.Arrow ((&&&))
|
||||
import Data.Aeson (decode, encode)
|
||||
@@ -55,7 +55,7 @@ makeTableDef pks (t, cs, _) =
|
||||
let tn = tableName t in
|
||||
(tn, (mempty :: Schema)
|
||||
& description .~ tableDescription t
|
||||
& type_ .~ SwaggerObject
|
||||
& type_ ?~ SwaggerObject
|
||||
& properties .~ fromList (map (makeProperty pks) cs)
|
||||
& required .~ map colName (filter (not . colNullable) cs))
|
||||
|
||||
@@ -84,13 +84,13 @@ makeProperty pks c = (colName c, Inline s)
|
||||
& enum_ .~ e
|
||||
& format ?~ colType c
|
||||
& maxLength .~ (fromIntegral <$> colMaxLen c)
|
||||
& type_ .~ toSwaggerType (colType c)
|
||||
& type_ ?~ toSwaggerType (colType c)
|
||||
|
||||
makeProcSchema :: ProcDescription -> Schema
|
||||
makeProcSchema pd =
|
||||
(mempty :: Schema)
|
||||
& description .~ pdDescription pd
|
||||
& type_ .~ SwaggerObject
|
||||
& type_ ?~ SwaggerObject
|
||||
& properties .~ fromList (map makeProcProperty (pdArgs pd))
|
||||
& required .~ map pgaName (filter pgaReq (pdArgs pd))
|
||||
|
||||
@@ -98,7 +98,7 @@ makeProcProperty :: PgArg -> (Text, Referenced Schema)
|
||||
makeProcProperty (PgArg n t _) = (n, Inline s)
|
||||
where
|
||||
s = (mempty :: Schema)
|
||||
& type_ .~ toSwaggerType t
|
||||
& type_ ?~ toSwaggerType t
|
||||
& format ?~ t
|
||||
|
||||
makePreferParam :: [Text] -> Param
|
||||
@@ -109,7 +109,7 @@ makePreferParam ts =
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamHeader
|
||||
& type_ .~ SwaggerString
|
||||
& type_ ?~ SwaggerString
|
||||
& enum_ .~ decode (encode ts))
|
||||
|
||||
makeProcParam :: ProcDescription -> [Referenced Param]
|
||||
@@ -132,28 +132,35 @@ makeParamDefs ti =
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString))
|
||||
& type_ ?~ SwaggerString))
|
||||
, ("on_conflict", (mempty :: Param)
|
||||
& name .~ "on_conflict"
|
||||
& description ?~ "On Conflict"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ ?~ SwaggerString))
|
||||
, ("order", (mempty :: Param)
|
||||
& name .~ "order"
|
||||
& description ?~ "Ordering"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString))
|
||||
& type_ ?~ SwaggerString))
|
||||
, ("range", (mempty :: Param)
|
||||
& name .~ "Range"
|
||||
& description ?~ "Limiting and Pagination"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamHeader
|
||||
& type_ .~ SwaggerString))
|
||||
& type_ ?~ SwaggerString))
|
||||
, ("rangeUnit", (mempty :: Param)
|
||||
& name .~ "Range-Unit"
|
||||
& description ?~ "Limiting and Pagination"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamHeader
|
||||
& type_ .~ SwaggerString
|
||||
& type_ ?~ SwaggerString
|
||||
& default_ .~ decode "\"items\""))
|
||||
, ("offset", (mempty :: Param)
|
||||
& name .~ "offset"
|
||||
@@ -161,14 +168,14 @@ makeParamDefs ti =
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString))
|
||||
& type_ ?~ SwaggerString))
|
||||
, ("limit", (mempty :: Param)
|
||||
& name .~ "limit"
|
||||
& description ?~ "Limiting and Pagination"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString))
|
||||
& type_ ?~ SwaggerString))
|
||||
]
|
||||
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) cs
|
||||
| (t, cs, _) <- ti
|
||||
@@ -190,7 +197,7 @@ makeRowFilter tn c =
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString
|
||||
& type_ ?~ SwaggerString
|
||||
& format ?~ colType c))
|
||||
|
||||
makeRowFilters :: Text -> [Column] -> [(Text, Param)]
|
||||
@@ -213,12 +220,12 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
|
||||
& at 200 ?~ Inline ((mempty :: Response)
|
||||
& description .~ "OK"
|
||||
& schema ?~ Inline (mempty
|
||||
& type_ .~ SwaggerArray
|
||||
& type_ ?~ SwaggerArray
|
||||
& items ?~ (SwaggerItemsObject $ Ref $ Reference $ tableName t)
|
||||
)
|
||||
)
|
||||
postOp = tOp
|
||||
& parameters .~ map ref ["body." <> tn, "preferReturn"]
|
||||
& parameters .~ map ref ["body." <> tn, "select", "preferReturn"]
|
||||
& at 201 ?~ "Created"
|
||||
patchOp = tOp
|
||||
& parameters .~ map ref (rs <> ["body." <> tn, "preferReturn"])
|
||||
|
||||
@@ -30,6 +30,10 @@ pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
|
||||
pRequestSelect selStr =
|
||||
mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
|
||||
|
||||
pRequestOnConflict :: Text -> Either ApiRequestError [FieldName]
|
||||
pRequestOnConflict oncStr =
|
||||
mapError $ parse pColumns ("failed to parse on_conflict parameter (" <> toS oncStr <> ")") (toS oncStr)
|
||||
|
||||
pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
|
||||
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
|
||||
where
|
||||
@@ -130,12 +134,12 @@ pRelationSelect :: Parser SelectItem
|
||||
pRelationSelect = lexeme $ try ( do
|
||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||
fld <- pField
|
||||
relationDetail <- optionMaybe (
|
||||
try ( char '+' *> pFieldName ) <|>
|
||||
try ( char '.' *> pFieldName ) -- TODO deprecated, remove in next major version
|
||||
hint <- optionMaybe (
|
||||
try ( char '!' *> pFieldName) <|>
|
||||
-- deprecated, remove in next major version
|
||||
try ( char '.' *> pFieldName)
|
||||
)
|
||||
|
||||
return (fld, Nothing, alias, relationDetail)
|
||||
return (fld, Nothing, alias, hint)
|
||||
)
|
||||
|
||||
pFieldSelect :: Parser SelectItem
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{-|
|
||||
Module : PostgREST.Common
|
||||
Description : Common helper functions.
|
||||
-}
|
||||
module PostgREST.Private.Common where
|
||||
|
||||
import Data.Maybe
|
||||
import qualified Hasql.Decoders as HD
|
||||
import qualified Hasql.Encoders as HE
|
||||
import Protolude
|
||||
|
||||
column :: HD.Value a -> HD.Row a
|
||||
column = HD.column . HD.nonNullable
|
||||
|
||||
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
|
||||
nullableColumn = HD.column . HD.nullable
|
||||
|
||||
element :: HD.Value a -> HD.Array a
|
||||
element = HD.element . HD.nonNullable
|
||||
|
||||
param :: HE.Value a -> HE.Params a
|
||||
param = HE.param . HE.nonNullable
|
||||
|
||||
arrayParam :: HE.Value a -> HE.Params [a]
|
||||
arrayParam = param . HE.array . HE.dimension foldl' . HE.element . HE.nonNullable
|
||||
@@ -0,0 +1,205 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-|
|
||||
Module : PostgREST.Private.QueryFragment
|
||||
Description : Helper functions for PostgREST.QueryBuilder.
|
||||
|
||||
Any function that outputs a SqlFragment should be in this module.
|
||||
-}
|
||||
module PostgREST.Private.QueryFragment where
|
||||
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import Data.Maybe
|
||||
import Data.Text (intercalate,
|
||||
isInfixOf, replace,
|
||||
toLower, unwords)
|
||||
import qualified Data.Text as T (map, null,
|
||||
takeWhile)
|
||||
import PostgREST.Types
|
||||
import Protolude hiding (cast,
|
||||
intercalate, replace)
|
||||
import Text.InterpolatedString.Perl6 (qc)
|
||||
|
||||
noLocationF :: SqlFragment
|
||||
noLocationF = "array[]::text[]"
|
||||
|
||||
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
|
||||
-- otherwise the query will err with a `could not determine data type of parameter $1`.
|
||||
-- This happens because `unknown` relies on the context to determine the value type.
|
||||
-- The error also happens on raw libpq used with C.
|
||||
ignoredBody :: SqlFragment
|
||||
ignoredBody = "pgrst_ignored_body AS (SELECT $1::text) "
|
||||
|
||||
-- |
|
||||
-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads
|
||||
-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays
|
||||
-- We do this in SQL to avoid processing the JSON in application code
|
||||
normalizedBody :: SqlFragment
|
||||
normalizedBody =
|
||||
unwords [
|
||||
"pgrst_payload AS (SELECT $1::json AS json_data),",
|
||||
"pgrst_body AS (",
|
||||
"SELECT",
|
||||
"CASE WHEN json_typeof(json_data) = 'array'",
|
||||
"THEN json_data",
|
||||
"ELSE json_build_array(json_data)",
|
||||
"END AS val",
|
||||
"FROM pgrst_payload)"]
|
||||
|
||||
selectBody :: SqlFragment
|
||||
selectBody = "(SELECT val FROM pgrst_body)"
|
||||
|
||||
pgFmtLit :: SqlFragment -> SqlFragment
|
||||
pgFmtLit x =
|
||||
let trimmed = trimNullChars x
|
||||
escaped = "'" <> replace "'" "''" trimmed <> "'"
|
||||
slashed = replace "\\" "\\\\" escaped in
|
||||
if "\\" `isInfixOf` escaped
|
||||
then "E" <> slashed
|
||||
else slashed
|
||||
|
||||
pgFmtIdent :: SqlFragment -> SqlFragment
|
||||
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
|
||||
|
||||
asCsvF :: SqlFragment
|
||||
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
|
||||
where
|
||||
asCsvHeaderF =
|
||||
"(SELECT coalesce(string_agg(a.k, ','), '')" <>
|
||||
" FROM (" <>
|
||||
" SELECT json_object_keys(r)::TEXT as k" <>
|
||||
" FROM ( " <>
|
||||
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
|
||||
" ) s" <>
|
||||
" ) a" <>
|
||||
")"
|
||||
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
|
||||
|
||||
asJsonF :: SqlFragment
|
||||
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
|
||||
|
||||
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
||||
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
|
||||
|
||||
asBinaryF :: FieldName -> SqlFragment
|
||||
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
|
||||
|
||||
locationF :: [Text] -> SqlFragment
|
||||
locationF pKeys = [qc|(
|
||||
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
|
||||
SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
|
||||
FROM data CROSS JOIN json_each_text(data.row) AS json_data
|
||||
{("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}
|
||||
)|]
|
||||
|
||||
fromQi :: QualifiedIdentifier -> SqlFragment
|
||||
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
||||
where
|
||||
n = qiName t
|
||||
s = qiSchema t
|
||||
|
||||
emptyOnFalse :: Text -> Bool -> Text
|
||||
emptyOnFalse val cond = if cond then "" else val
|
||||
|
||||
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
||||
pgFmtColumn table "*" = fromQi table <> ".*"
|
||||
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
|
||||
|
||||
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
|
||||
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
|
||||
|
||||
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
|
||||
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
|
||||
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias
|
||||
|
||||
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment
|
||||
pgFmtOrderTerm qi ot = unwords [
|
||||
toS . pgFmtField qi $ otTerm ot,
|
||||
maybe "" show $ otDirection ot,
|
||||
maybe "" show $ otNullOrder ot]
|
||||
|
||||
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
|
||||
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
|
||||
Op op val -> pgFmtFieldOp op <> " " <> case op of
|
||||
"like" -> unknownLiteral (T.map star val)
|
||||
"ilike" -> unknownLiteral (T.map star val)
|
||||
"is" -> whiteList val
|
||||
_ -> unknownLiteral val
|
||||
|
||||
In vals -> pgFmtField table fld <> " " <>
|
||||
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
|
||||
case (&&) (length vals == 1) . T.null <$> headMay vals of
|
||||
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
|
||||
Just True -> emptyValForIn
|
||||
Nothing -> emptyValForIn
|
||||
|
||||
Fts op lang val ->
|
||||
pgFmtFieldOp op
|
||||
<> "("
|
||||
<> maybe "" ((<> ", ") . pgFmtLit) lang
|
||||
<> unknownLiteral val
|
||||
<> ") "
|
||||
where
|
||||
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
|
||||
sqlOperator o = HM.lookupDefault "=" o operators
|
||||
notOp = if hasNot then "NOT" else ""
|
||||
star c = if c == '*' then '%' else c
|
||||
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
||||
whiteList :: Text -> SqlFragment
|
||||
whiteList v = fromMaybe
|
||||
(toS (pgFmtLit v) <> "::unknown ")
|
||||
(find ((==) . toLower $ v) ["null","true","false"])
|
||||
|
||||
pgFmtJoinCondition :: JoinCondition -> SqlFragment
|
||||
pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) =
|
||||
pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 col2
|
||||
|
||||
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
|
||||
pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"
|
||||
where notOp = if hasNot then "NOT" else ""
|
||||
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt
|
||||
|
||||
pgFmtJsonPath :: JsonPath -> SqlFragment
|
||||
pgFmtJsonPath = \case
|
||||
[] -> ""
|
||||
(JArrow x:xs) -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
|
||||
(J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
|
||||
where
|
||||
pgFmtJsonOperand (JKey k) = pgFmtLit k
|
||||
pgFmtJsonOperand (JIdx i) = pgFmtLit i <> "::int"
|
||||
|
||||
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment
|
||||
pgFmtAs _ [] Nothing = ""
|
||||
pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
|
||||
Just (JKey key) -> " AS " <> pgFmtIdent key
|
||||
Just (JIdx _) -> " AS " <> pgFmtIdent (fromMaybe fName lastKey)
|
||||
-- We get the lastKey because on:
|
||||
-- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ]
|
||||
-- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ]
|
||||
where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp)
|
||||
Nothing -> ""
|
||||
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
|
||||
|
||||
trimNullChars :: Text -> Text
|
||||
trimNullChars = T.takeWhile (/= '\x0')
|
||||
|
||||
countF :: SqlQuery -> Bool -> (SqlFragment, SqlFragment)
|
||||
countF countQuery shouldCount =
|
||||
if shouldCount
|
||||
then (
|
||||
", pg_source_count AS (" <> countQuery <> ")"
|
||||
, "(SELECT pg_catalog.count(*) FROM pg_source_count)" )
|
||||
else (
|
||||
mempty
|
||||
, "null::bigint")
|
||||
|
||||
returningF :: QualifiedIdentifier -> [FieldName] -> SqlFragment
|
||||
returningF qi returnings =
|
||||
if null returnings
|
||||
then "RETURNING 1" -- For mutation cases where there's no ?select, we return 1 to know how many rows were modified
|
||||
else "RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)
|
||||
|
||||
responseHeadersF :: PgVersion -> SqlFragment
|
||||
responseHeadersF pgVer =
|
||||
if pgVer >= pgVersion96
|
||||
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
|
||||
else "'[]'" :: Text
|
||||
+110
-440
@@ -1,255 +1,41 @@
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
{-|
|
||||
Module : PostgREST.QueryBuilder
|
||||
Description : PostgREST SQL generating functions.
|
||||
Description : PostgREST SQL queries generating functions.
|
||||
|
||||
This module provides functions to consume data types that
|
||||
represent database objects (e.g. Relation, Schema, SqlQuery)
|
||||
and produces SQL Statements.
|
||||
|
||||
Any function that outputs a SQL fragment should be in this module.
|
||||
represent database queries (e.g. ReadRequest, MutateRequest) and SqlFragment
|
||||
to produce SqlQuery type outputs.
|
||||
-}
|
||||
module PostgREST.QueryBuilder (
|
||||
callProc
|
||||
, createReadStatement
|
||||
, createWriteStatement
|
||||
, pgFmtIdent
|
||||
, pgFmtLit
|
||||
, requestToQuery
|
||||
, requestToCountQuery
|
||||
, unquoted
|
||||
, ResultsWithCount
|
||||
, pgFmtSetLocal
|
||||
, pgFmtSetLocalSearchPath
|
||||
readRequestToQuery
|
||||
, mutateRequestToQuery
|
||||
, readRequestToCountQuery
|
||||
, requestToCallProcQuery
|
||||
, limitedQuery
|
||||
, setLocalQuery
|
||||
, setLocalSearchPathQuery
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Data.Set as S
|
||||
import qualified Data.Text as T (map, null, takeWhile)
|
||||
import qualified Data.Text.Encoding as T
|
||||
import qualified Hasql.Decoders as HD
|
||||
import qualified Hasql.Encoders as HE
|
||||
import qualified Hasql.Statement as H
|
||||
import qualified Data.Set as S
|
||||
|
||||
import Data.Scientific (FPFormat (..), formatScientific,
|
||||
isInteger)
|
||||
import Data.Text (intercalate, isInfixOf, replace,
|
||||
toLower, unwords)
|
||||
import Data.Tree (Tree (..))
|
||||
import Text.InterpolatedString.Perl6 (qc)
|
||||
import Data.Text (intercalate, unwords)
|
||||
import Data.Tree (Tree (..))
|
||||
|
||||
import Data.Maybe
|
||||
|
||||
import PostgREST.ApiRequest (PreferRepresentation (..))
|
||||
import PostgREST.RangeQuery (allRange, rangeLimit, rangeOffset)
|
||||
import PostgREST.Private.QueryFragment
|
||||
import PostgREST.RangeQuery (allRange, rangeLimit,
|
||||
rangeOffset)
|
||||
import PostgREST.Types
|
||||
import Protolude hiding (cast, intercalate, replace)
|
||||
import Protolude hiding (cast, intercalate,
|
||||
replace)
|
||||
|
||||
column :: HD.Value a -> HD.Row a
|
||||
column = HD.column . HD.nonNullable
|
||||
|
||||
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
|
||||
nullableColumn = HD.column . HD.nullable
|
||||
|
||||
element :: HD.Value a -> HD.Array a
|
||||
element = HD.element . HD.nonNullable
|
||||
|
||||
param :: HE.Value a -> HE.Params a
|
||||
param = HE.param . HE.nonNullable
|
||||
|
||||
{-| The generic query result format used by API responses. The location header
|
||||
is represented as a list of strings containing variable bindings like
|
||||
@"k1=eq.42"@, or the empty list if there is no location header.
|
||||
-}
|
||||
type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString)
|
||||
|
||||
standardRow :: HD.Row ResultsWithCount
|
||||
standardRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
|
||||
<*> column header <*> column HD.bytea
|
||||
where
|
||||
header = HD.array $ HD.dimension replicateM $ element HD.bytea
|
||||
|
||||
noLocationF :: Text
|
||||
noLocationF = "array[]::text[]"
|
||||
|
||||
{-| Read and Write api requests use a similar response format which includes
|
||||
various record counts and possible location header. This is the decoder
|
||||
for that common type of query.
|
||||
-}
|
||||
decodeStandard :: HD.Result ResultsWithCount
|
||||
decodeStandard =
|
||||
HD.singleRow standardRow
|
||||
|
||||
decodeStandardMay :: HD.Result (Maybe ResultsWithCount)
|
||||
decodeStandardMay =
|
||||
HD.rowMaybe standardRow
|
||||
|
||||
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName ->
|
||||
H.Statement () ResultsWithCount
|
||||
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField =
|
||||
unicodeStatement sql HE.noParams decodeStandard False
|
||||
where
|
||||
sql = [qc|
|
||||
WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols}
|
||||
FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]
|
||||
countResultF = if countTotal then "("<>countQuery<>")" else "null"
|
||||
cols = intercalate ", " [
|
||||
countResultF <> " AS total_result_set",
|
||||
"pg_catalog.count(_postgrest_t) AS page_total",
|
||||
noLocationF <> " AS header",
|
||||
bodyF <> " AS body"
|
||||
]
|
||||
bodyF
|
||||
| asCsv = asCsvF
|
||||
| isSingle = asJsonSingleF
|
||||
| isJust binaryField = asBinaryF $ fromJust binaryField
|
||||
| otherwise = asJsonF
|
||||
|
||||
|
||||
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
||||
PreferRepresentation -> [Text] ->
|
||||
H.Statement ByteString (Maybe ResultsWithCount)
|
||||
createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys =
|
||||
unicodeStatement sql (param HE.unknown) decodeStandardMay True
|
||||
|
||||
where
|
||||
sql = case rep of
|
||||
None -> [qc|
|
||||
WITH {sourceCTEName} AS ({mutateQuery})
|
||||
SELECT '', 0, {noLocationF}, '' |]
|
||||
HeadersOnly -> [qc|
|
||||
WITH {sourceCTEName} AS ({mutateQuery})
|
||||
SELECT {cols}
|
||||
FROM (SELECT 1 FROM {sourceCTEName}) _postgrest_t |]
|
||||
Full -> [qc|
|
||||
WITH {sourceCTEName} AS ({mutateQuery})
|
||||
SELECT {cols}
|
||||
FROM ({selectQuery}) _postgrest_t |]
|
||||
|
||||
cols = intercalate ", " [
|
||||
"'' AS total_result_set", -- when updateing it does not make sense
|
||||
"pg_catalog.count(_postgrest_t) AS page_total",
|
||||
if isInsert
|
||||
then unwords [
|
||||
"CASE",
|
||||
"WHEN pg_catalog.count(_postgrest_t) = 1 THEN",
|
||||
"coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")",
|
||||
"ELSE " <> noLocationF,
|
||||
"END AS header"]
|
||||
else noLocationF <> "AS header",
|
||||
if rep == Full
|
||||
then bodyF <> " AS body"
|
||||
else "''"
|
||||
]
|
||||
|
||||
bodyF
|
||||
| asCsv = asCsvF
|
||||
| wantSingle = asJsonSingleF
|
||||
| otherwise = asJsonF
|
||||
|
||||
type ProcResults = (Maybe Int64, Int64, ByteString, ByteString)
|
||||
callProc :: QualifiedIdentifier -> [PgArg] -> Bool -> SqlQuery -> SqlQuery -> Bool ->
|
||||
Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->
|
||||
H.Statement ByteString (Maybe ProcResults)
|
||||
callProc qi pgArgs returnsScalar selectQuery countQuery countTotal isSingle paramsAsSingleObject asCsv asBinary binaryField pgVer =
|
||||
unicodeStatement sql (param HE.unknown) decodeProc True
|
||||
where
|
||||
sql =[qc|
|
||||
WITH
|
||||
{argsRecord},
|
||||
{sourceCTEName} AS (
|
||||
{sourceBody}
|
||||
)
|
||||
SELECT
|
||||
{countResultF} AS total_result_set,
|
||||
pg_catalog.count(_postgrest_t) AS page_total,
|
||||
{bodyF} AS body,
|
||||
{responseHeaders} AS response_headers
|
||||
FROM ({selectQuery}) _postgrest_t;|]
|
||||
|
||||
(argsRecord, args)
|
||||
| paramsAsSingleObject = ("_args_record AS (SELECT NULL)", "$1::json")
|
||||
| null pgArgs = (ignoredBody, "")
|
||||
| otherwise = (
|
||||
unwords [
|
||||
normalizedBody <> ",",
|
||||
"_args_record AS (",
|
||||
"SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <>
|
||||
intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " " <> pgaType a) <$> pgArgs) <> ")",
|
||||
")"]
|
||||
, intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " := _args_record." <> pgFmtIdent (pgaName a)) <$> pgArgs))
|
||||
|
||||
sourceBody :: SqlFragment
|
||||
sourceBody
|
||||
| paramsAsSingleObject || null pgArgs =
|
||||
if returnsScalar
|
||||
then [qc| SELECT {fromQi qi}({args}) |]
|
||||
else [qc| SELECT * FROM {fromQi qi}({args}) |]
|
||||
| otherwise =
|
||||
if returnsScalar
|
||||
then [qc| SELECT {fromQi qi}({args}) FROM _args_record |]
|
||||
else [qc| SELECT _.*
|
||||
FROM _args_record,
|
||||
LATERAL ( SELECT * FROM {fromQi qi}({args}) ) _ |]
|
||||
|
||||
bodyF
|
||||
| returnsScalar = scalarBodyF
|
||||
| isSingle = asJsonSingleF
|
||||
| asCsv = asCsvF
|
||||
| isJust binaryField = asBinaryF $ fromJust binaryField
|
||||
| otherwise = asJsonF
|
||||
|
||||
scalarBodyF
|
||||
| asBinary = asBinaryF _procName
|
||||
| otherwise = unwords [
|
||||
"CASE",
|
||||
"WHEN pg_catalog.count(_postgrest_t) = 1",
|
||||
"THEN (json_agg(_postgrest_t." <> pgFmtIdent _procName <> ")->0)::character varying",
|
||||
"ELSE (json_agg(_postgrest_t." <> pgFmtIdent _procName <> "))::character varying",
|
||||
"END"]
|
||||
|
||||
countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text
|
||||
_procName = qiName qi
|
||||
responseHeaders =
|
||||
if pgVer >= pgVersion96
|
||||
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
|
||||
else "'[]'" :: Text
|
||||
|
||||
decodeProc = HD.rowMaybe procRow
|
||||
procRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
|
||||
<*> column HD.bytea <*> column HD.bytea
|
||||
|
||||
pgFmtIdent :: SqlFragment -> SqlFragment
|
||||
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
|
||||
|
||||
pgFmtLit :: SqlFragment -> SqlFragment
|
||||
pgFmtLit x =
|
||||
let trimmed = trimNullChars x
|
||||
escaped = "'" <> replace "'" "''" trimmed <> "'"
|
||||
slashed = replace "\\" "\\\\" escaped in
|
||||
if "\\" `isInfixOf` escaped
|
||||
then "E" <> slashed
|
||||
else slashed
|
||||
|
||||
requestToCountQuery :: Schema -> DbRequest -> SqlQuery
|
||||
requestToCountQuery _ (DbMutate _) = witness
|
||||
requestToCountQuery schema (DbRead (Node (Select{where_=logicForest}, (mainTbl, _, _, _, _)) _)) =
|
||||
unwords [
|
||||
"SELECT pg_catalog.count(*)",
|
||||
"FROM ", fromQi qi,
|
||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest
|
||||
]
|
||||
where
|
||||
qi = removeSourceCTESchema schema mainTbl
|
||||
|
||||
requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
|
||||
requestToQuery schema isParent (DbRead (Node (Select colSelects tbl tblAlias implJoins logicForest joinConditions_ ordts range, _) forest)) =
|
||||
readRequestToQuery :: ReadRequest -> SqlQuery
|
||||
readRequestToQuery (Node (Select colSelects mainQi tblAlias implJoins logicForest joinConditions_ ordts range, _) forest) =
|
||||
unwords [
|
||||
"SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
|
||||
"FROM " <> intercalate ", " (tabl : implJs),
|
||||
@@ -257,51 +43,42 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbl tblAlias imp
|
||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_))
|
||||
`emptyOnFalse` (null logicForest && null joinConditions_),
|
||||
("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts,
|
||||
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (isParent || range == allRange) ]
|
||||
|
||||
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (range == allRange)
|
||||
]
|
||||
where
|
||||
implJs = fromQi . QualifiedIdentifier schema <$> implJoins
|
||||
mainQi = removeSourceCTESchema schema tbl
|
||||
implJs = fromQi <$> implJoins
|
||||
tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias
|
||||
qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias
|
||||
(joins, selects) = foldr getJoinsSelects ([],[]) forest
|
||||
|
||||
(joins, selects) = foldr getQueryParts ([],[]) forest
|
||||
getJoinsSelects :: ReadRequest -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
|
||||
getJoinsSelects rr@(Node (_, (name, Just Relation{relType=relTyp,relTable=Table{tableName=table}}, alias, _, _)) _) (j,s) =
|
||||
let subquery = readRequestToQuery rr in
|
||||
case relTyp of
|
||||
M2O ->
|
||||
let aliasOrName = fromMaybe name alias
|
||||
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
|
||||
sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName
|
||||
joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE " in
|
||||
(joi:j,sel:s)
|
||||
_ ->
|
||||
let sel = "COALESCE (("
|
||||
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
|
||||
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table
|
||||
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias) in
|
||||
(j,sel:s)
|
||||
getJoinsSelects (Node (_, (_, Nothing, _, _, _)) _) _ = ([], [])
|
||||
|
||||
getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
|
||||
getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
|
||||
where
|
||||
sel = "COALESCE(("
|
||||
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
|
||||
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table
|
||||
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
|
||||
where subquery = requestToQuery schema False (DbRead (Node n forst))
|
||||
getQueryParts (Node n@(_, (name, Just Relation{relType=Parent,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (joi:j,sel:s)
|
||||
where
|
||||
aliasOrName = fromMaybe name alias
|
||||
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
|
||||
sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName
|
||||
joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE "
|
||||
where subquery = requestToQuery schema True (DbRead (Node n forst))
|
||||
getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
|
||||
where
|
||||
sel = "COALESCE (("
|
||||
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
|
||||
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table
|
||||
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
|
||||
where subquery = requestToQuery schema False (DbRead (Node n forst))
|
||||
--the following is just to remove the warning
|
||||
--getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only
|
||||
--posible relations are Child Parent Many
|
||||
getQueryParts _ _ = witness
|
||||
requestToQuery schema _ (DbMutate (Insert mainTbl iCols onConflct putConditions returnings)) =
|
||||
mutateRequestToQuery :: MutateRequest -> SqlQuery
|
||||
mutateRequestToQuery (Insert mainQi iCols onConflct putConditions returnings) =
|
||||
unwords [
|
||||
"WITH " <> normalizedBody,
|
||||
"INSERT INTO ", fromQi qi, if S.null iCols then " " else "(" <> cols <> ")",
|
||||
"INSERT INTO ", fromQi mainQi, if S.null iCols then " " else "(" <> cols <> ")",
|
||||
unwords [
|
||||
"SELECT " <> cols <> " FROM",
|
||||
"json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ") _",
|
||||
"json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ") _",
|
||||
-- Only used for PUT
|
||||
("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier "" "_") <$> putConditions)) `emptyOnFalse` null putConditions],
|
||||
("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) `emptyOnFalse` null putConditions],
|
||||
maybe "" (\(oncDo, oncCols) -> (
|
||||
"ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
|
||||
IgnoreDuplicates ->
|
||||
@@ -311,201 +88,94 @@ requestToQuery schema _ (DbMutate (Insert mainTbl iCols onConflct putConditions
|
||||
then "DO NOTHING"
|
||||
else "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols)
|
||||
) `emptyOnFalse` null oncCols) onConflct,
|
||||
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings]
|
||||
returningF mainQi returnings
|
||||
]
|
||||
where
|
||||
qi = QualifiedIdentifier schema mainTbl
|
||||
cols = intercalate ", " $ pgFmtIdent <$> S.toList iCols
|
||||
requestToQuery schema _ (DbMutate (Update mainTbl uCols logicForest returnings)) =
|
||||
mutateRequestToQuery (Update mainQi uCols logicForest returnings) =
|
||||
if S.null uCols
|
||||
then "WITH " <> ignoredBody <> "SELECT null WHERE false" -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
|
||||
else
|
||||
unwords [
|
||||
"WITH " <> normalizedBody,
|
||||
"UPDATE " <> fromQi qi <> " SET " <> cols,
|
||||
"FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ")) _ ",
|
||||
("WHERE " <> intercalate " AND " (pgFmtLogicTree qi <$> logicForest)) `emptyOnFalse` null logicForest,
|
||||
("RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)) `emptyOnFalse` null returnings
|
||||
"UPDATE " <> fromQi mainQi <> " SET " <> cols,
|
||||
"FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ")) _ ",
|
||||
("WHERE " <> intercalate " AND " (pgFmtLogicTree mainQi <$> logicForest)) `emptyOnFalse` null logicForest,
|
||||
returningF mainQi returnings
|
||||
]
|
||||
where
|
||||
qi = QualifiedIdentifier schema mainTbl
|
||||
cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)
|
||||
requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) =
|
||||
mutateRequestToQuery (Delete mainQi logicForest returnings) =
|
||||
unwords [
|
||||
"WITH " <> ignoredBody,
|
||||
"DELETE FROM ", fromQi qi,
|
||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest,
|
||||
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
|
||||
"DELETE FROM ", fromQi mainQi,
|
||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree mainQi) logicForest)) `emptyOnFalse` null logicForest,
|
||||
returningF mainQi returnings
|
||||
]
|
||||
where
|
||||
qi = QualifiedIdentifier schema mainTbl
|
||||
|
||||
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
|
||||
-- otherwise the query will err with a `could not determine data type of parameter $1`.
|
||||
-- This happens because `unknown` relies on the context to determine the value type.
|
||||
-- The error also happens on raw libpq used with C.
|
||||
ignoredBody :: SqlFragment
|
||||
ignoredBody = "ignored_body AS (SELECT $1::text) "
|
||||
|
||||
-- |
|
||||
-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads
|
||||
-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays
|
||||
-- We do this in SQL to avoid processing the JSON in application code
|
||||
normalizedBody :: SqlFragment
|
||||
normalizedBody =
|
||||
requestToCallProcQuery :: QualifiedIdentifier -> [PgArg] -> Bool -> Maybe PreferParameters -> SqlQuery
|
||||
requestToCallProcQuery qi pgArgs returnsScalar preferParams =
|
||||
unwords [
|
||||
"pgrst_payload AS (SELECT $1::json AS json_data),",
|
||||
"pgrst_body AS (",
|
||||
"SELECT",
|
||||
"CASE WHEN json_typeof(json_data) = 'array'",
|
||||
"THEN json_data",
|
||||
"ELSE json_build_array(json_data)",
|
||||
"END AS val",
|
||||
"FROM pgrst_payload)"]
|
||||
|
||||
selectBody :: SqlFragment
|
||||
selectBody = "(SELECT val FROM pgrst_body)"
|
||||
|
||||
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
|
||||
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
|
||||
|
||||
unquoted :: JSON.Value -> Text
|
||||
unquoted (JSON.String t) = t
|
||||
unquoted (JSON.Number n) =
|
||||
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
|
||||
unquoted (JSON.Bool b) = show b
|
||||
unquoted v = toS $ JSON.encode v
|
||||
|
||||
-- private functions
|
||||
asCsvF :: SqlFragment
|
||||
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
|
||||
"WITH",
|
||||
argsCTE,
|
||||
sourceBody ]
|
||||
where
|
||||
asCsvHeaderF =
|
||||
"(SELECT coalesce(string_agg(a.k, ','), '')" <>
|
||||
" FROM (" <>
|
||||
" SELECT json_object_keys(r)::TEXT as k" <>
|
||||
" FROM ( " <>
|
||||
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
|
||||
" ) s" <>
|
||||
" ) a" <>
|
||||
")"
|
||||
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
|
||||
paramsAsSingleObject = preferParams == Just SingleObject
|
||||
paramsAsMulitpleObjects = preferParams == Just MultipleObjects
|
||||
|
||||
asJsonF :: SqlFragment
|
||||
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
|
||||
(argsCTE, args)
|
||||
| null pgArgs = (ignoredBody, "")
|
||||
| paramsAsSingleObject = ("pgrst_args AS (SELECT NULL)", "$1::json")
|
||||
| otherwise = (
|
||||
unwords [
|
||||
normalizedBody <> ",",
|
||||
"pgrst_args AS (",
|
||||
"SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <> fmtArgs (\a -> " " <> pgaType a) <> ")",
|
||||
")"]
|
||||
, if paramsAsMulitpleObjects
|
||||
then fmtArgs (\a -> " := pgrst_args." <> pgFmtIdent (pgaName a))
|
||||
else fmtArgs (\a -> " := (SELECT " <> pgFmtIdent (pgaName a) <> " FROM pgrst_args LIMIT 1)")
|
||||
)
|
||||
|
||||
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
||||
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
|
||||
fmtArgs :: (PgArg -> SqlFragment) -> SqlFragment
|
||||
fmtArgs argFrag = intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> argFrag a) <$> pgArgs)
|
||||
|
||||
asBinaryF :: FieldName -> SqlFragment
|
||||
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
|
||||
sourceBody :: SqlFragment
|
||||
sourceBody
|
||||
| paramsAsMulitpleObjects =
|
||||
if returnsScalar
|
||||
then "SELECT " <> callIt <> " AS pgrst_scalar FROM pgrst_args"
|
||||
else unwords [ "SELECT pgrst_lat_args.*"
|
||||
, "FROM pgrst_args,"
|
||||
, "LATERAL ( SELECT * FROM " <> callIt <> " ) pgrst_lat_args" ]
|
||||
| otherwise =
|
||||
if returnsScalar
|
||||
then "SELECT " <> callIt <> " AS pgrst_scalar"
|
||||
else "SELECT * FROM " <> callIt
|
||||
|
||||
locationF :: [Text] -> SqlFragment
|
||||
locationF pKeys = [qc|(
|
||||
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
|
||||
SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
|
||||
FROM data CROSS JOIN json_each_text(data.row) AS json_data
|
||||
{("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}
|
||||
)|]
|
||||
callIt :: SqlFragment
|
||||
callIt = fromQi qi <> "(" <> args <> ")"
|
||||
|
||||
fromQi :: QualifiedIdentifier -> SqlFragment
|
||||
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
||||
where
|
||||
n = qiName t
|
||||
s = qiSchema t
|
||||
|
||||
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
|
||||
unicodeStatement = H.Statement . T.encodeUtf8
|
||||
-- | SQL query meant for COUNTing the root node of the Tree.
|
||||
-- It only takes WHERE into account and doesn't include LIMIT/OFFSET because it would reduce the COUNT.
|
||||
-- SELECT 1 is done instead of SELECT * to prevent doing expensive operations(like functions based on the columns)
|
||||
-- inside the FROM target.
|
||||
readRequestToCountQuery :: ReadRequest -> SqlQuery
|
||||
readRequestToCountQuery (Node (Select{from=qi, where_=logicForest}, _) _) =
|
||||
unwords [
|
||||
"SELECT 1",
|
||||
"FROM " <> fromQi qi,
|
||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest
|
||||
]
|
||||
|
||||
emptyOnFalse :: Text -> Bool -> Text
|
||||
emptyOnFalse val cond = if cond then "" else val
|
||||
limitedQuery :: SqlQuery -> Maybe Integer -> SqlQuery
|
||||
limitedQuery query maxRows = query <> maybe mempty (\x -> " LIMIT " <> show x) maxRows
|
||||
|
||||
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
||||
pgFmtColumn table "*" = fromQi table <> ".*"
|
||||
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
|
||||
|
||||
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
|
||||
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
|
||||
|
||||
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
|
||||
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
|
||||
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias
|
||||
|
||||
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment
|
||||
pgFmtOrderTerm qi ot = unwords [
|
||||
toS . pgFmtField qi $ otTerm ot,
|
||||
maybe "" show $ otDirection ot,
|
||||
maybe "" show $ otNullOrder ot]
|
||||
|
||||
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
|
||||
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
|
||||
Op op val -> pgFmtFieldOp op <> " " <> case op of
|
||||
"like" -> unknownLiteral (T.map star val)
|
||||
"ilike" -> unknownLiteral (T.map star val)
|
||||
"is" -> whiteList val
|
||||
_ -> unknownLiteral val
|
||||
|
||||
In vals -> pgFmtField table fld <> " " <>
|
||||
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
|
||||
case (&&) (length vals == 1) . T.null <$> headMay vals of
|
||||
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
|
||||
Just True -> emptyValForIn
|
||||
Nothing -> emptyValForIn
|
||||
|
||||
Fts op lang val ->
|
||||
pgFmtFieldOp op
|
||||
<> "("
|
||||
<> maybe "" ((<> ", ") . pgFmtLit) lang
|
||||
<> unknownLiteral val
|
||||
<> ") "
|
||||
where
|
||||
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
|
||||
sqlOperator o = HM.lookupDefault "=" o operators
|
||||
notOp = if hasNot then "NOT" else ""
|
||||
star c = if c == '*' then '%' else c
|
||||
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
||||
whiteList :: Text -> SqlFragment
|
||||
whiteList v = fromMaybe
|
||||
(toS (pgFmtLit v) <> "::unknown ")
|
||||
(find ((==) . toLower $ v) ["null","true","false"])
|
||||
|
||||
pgFmtJoinCondition :: JoinCondition -> SqlFragment
|
||||
pgFmtJoinCondition (JoinCondition (qi, col1) (QualifiedIdentifier schema fTable, col2)) =
|
||||
pgFmtColumn qi col1 <> " = " <>
|
||||
pgFmtColumn (removeSourceCTESchema schema fTable) col2
|
||||
|
||||
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
|
||||
pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"
|
||||
where notOp = if hasNot then "NOT" else ""
|
||||
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt
|
||||
|
||||
pgFmtJsonPath :: JsonPath -> SqlFragment
|
||||
pgFmtJsonPath = \case
|
||||
[] -> ""
|
||||
(JArrow x:xs) -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
|
||||
(J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
|
||||
where
|
||||
pgFmtJsonOperand (JKey k) = pgFmtLit k
|
||||
pgFmtJsonOperand (JIdx i) = pgFmtLit i <> "::int"
|
||||
|
||||
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment
|
||||
pgFmtAs _ [] Nothing = ""
|
||||
pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
|
||||
Just (JKey key) -> " AS " <> pgFmtIdent key
|
||||
Just (JIdx _) -> " AS " <> pgFmtIdent (fromMaybe fName lastKey)
|
||||
-- We get the lastKey because on:
|
||||
-- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ]
|
||||
-- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ]
|
||||
where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp)
|
||||
Nothing -> ""
|
||||
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
|
||||
|
||||
pgFmtSetLocal :: Text -> (Text, Text) -> SqlFragment
|
||||
pgFmtSetLocal prefix (k, v) =
|
||||
setLocalQuery :: Text -> (Text, Text) -> SqlQuery
|
||||
setLocalQuery prefix (k, v) =
|
||||
"SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
|
||||
|
||||
pgFmtSetLocalSearchPath :: [Text] -> SqlFragment
|
||||
pgFmtSetLocalSearchPath vals =
|
||||
setLocalSearchPathQuery :: [Text] -> SqlQuery
|
||||
setLocalSearchPathQuery vals =
|
||||
"SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
|
||||
|
||||
trimNullChars :: Text -> Text
|
||||
trimNullChars = T.takeWhile (/= '\x0')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{-|
|
||||
Module : PostgREST.RangeQuery
|
||||
Description : Logic regarding the `Range` header and `limit`, `offset` querystring arguments.
|
||||
Description : Logic regarding the `Range`/`Content-Range` headers and `limit`/`offset` querystring arguments.
|
||||
-}
|
||||
module PostgREST.RangeQuery (
|
||||
rangeParse
|
||||
@@ -11,6 +11,8 @@ module PostgREST.RangeQuery (
|
||||
, rangeGeq
|
||||
, allRange
|
||||
, NonnegRange
|
||||
, rangeStatusHeader
|
||||
, contentRangeH
|
||||
) where
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
@@ -22,6 +24,7 @@ import Control.Applicative
|
||||
import Data.Ranged.Boundaries
|
||||
import Data.Ranged.Ranges
|
||||
import Network.HTTP.Types.Header
|
||||
import Network.HTTP.Types.Status
|
||||
|
||||
import Protolude
|
||||
|
||||
@@ -70,3 +73,30 @@ allRange = rangeGeq 0
|
||||
rangeLeq :: Integer -> NonnegRange
|
||||
rangeLeq n =
|
||||
Range BoundaryBelowAll (BoundaryAbove n)
|
||||
|
||||
rangeStatusHeader :: NonnegRange -> Int64 -> Maybe Int64 -> (Status, Header)
|
||||
rangeStatusHeader topLevelRange queryTotal tableTotal =
|
||||
let lower = rangeOffset topLevelRange
|
||||
upper = lower + toInteger queryTotal - 1
|
||||
contentRange = contentRangeH lower upper (toInteger <$> tableTotal)
|
||||
status = rangeStatus lower upper (toInteger <$> tableTotal)
|
||||
in (status, contentRange)
|
||||
where
|
||||
rangeStatus :: Integer -> Integer -> Maybe Integer -> Status
|
||||
rangeStatus _ _ Nothing = status200
|
||||
rangeStatus lower upper (Just total)
|
||||
| lower > total = status416 -- 416 Range Not Satisfiable
|
||||
| (1 + upper - lower) < total = status206 -- 206 Partial Content
|
||||
| otherwise = status200 -- 200 OK
|
||||
|
||||
contentRangeH :: (Integral a, Show a) => a -> a -> Maybe a -> Header
|
||||
contentRangeH lower upper total =
|
||||
("Content-Range", headerValue)
|
||||
where
|
||||
headerValue = rangeString <> "/" <> totalString
|
||||
rangeString
|
||||
| totalNotZero && fromInRange = show lower <> "-" <> show upper
|
||||
| otherwise = "*"
|
||||
totalString = maybe "*" show total
|
||||
totalNotZero = maybe True (0 /=) total
|
||||
fromInRange = lower <= upper
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
{-|
|
||||
Module : PostgREST.Statements
|
||||
Description : PostgREST single SQL statements.
|
||||
|
||||
This module constructs single SQL statements that can be parametrized and prepared.
|
||||
|
||||
- It consumes the SqlQuery types generated by the QueryBuilder module.
|
||||
- It generates the body format and some headers of the final HTTP response.
|
||||
|
||||
TODO: Currently, createReadStatement is not using prepared statements. See https://github.com/PostgREST/postgrest/issues/718.
|
||||
-}
|
||||
module PostgREST.Statements (
|
||||
createWriteStatement
|
||||
, createReadStatement
|
||||
, callProcStatement
|
||||
, createExplainStatement
|
||||
) where
|
||||
|
||||
|
||||
import Control.Lens ((^?))
|
||||
import Data.Aeson as JSON
|
||||
import qualified Data.Aeson.Lens as L
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import Data.Maybe
|
||||
import Data.Text (unwords)
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import qualified Hasql.Decoders as HD
|
||||
import qualified Hasql.Encoders as HE
|
||||
import qualified Hasql.Statement as H
|
||||
import PostgREST.Private.Common
|
||||
import PostgREST.Private.QueryFragment
|
||||
import PostgREST.Types
|
||||
import Protolude hiding (cast,
|
||||
replace)
|
||||
import Text.InterpolatedString.Perl6 (qc)
|
||||
|
||||
{-| The generic query result format used by API responses. The location header
|
||||
is represented as a list of strings containing variable bindings like
|
||||
@"k1=eq.42"@, or the empty list if there is no location header.
|
||||
-}
|
||||
type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString, Either Text [GucHeader])
|
||||
|
||||
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
||||
PreferRepresentation -> [Text] -> PgVersion ->
|
||||
H.Statement ByteString ResultsWithCount
|
||||
createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys pgVer =
|
||||
unicodeStatement sql (param HE.unknown) decodeStandard True
|
||||
where
|
||||
sql = [qc|
|
||||
WITH
|
||||
{sourceCTEName} AS ({mutateQuery})
|
||||
SELECT
|
||||
'' AS total_result_set,
|
||||
pg_catalog.count(_postgrest_t) AS page_total,
|
||||
{locF} AS header,
|
||||
{bodyF} AS body,
|
||||
{responseHeadersF pgVer} AS response_headers
|
||||
FROM ({selectQuery}) _postgrest_t |]
|
||||
|
||||
locF =
|
||||
if isInsert && rep `elem` [Full, HeadersOnly]
|
||||
then unwords [
|
||||
"CASE WHEN pg_catalog.count(_postgrest_t) = 1",
|
||||
"THEN coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")",
|
||||
"ELSE " <> noLocationF,
|
||||
"END"]
|
||||
else noLocationF
|
||||
|
||||
bodyF
|
||||
| rep `elem` [None, HeadersOnly] = "''"
|
||||
| asCsv = asCsvF
|
||||
| wantSingle = asJsonSingleF
|
||||
| otherwise = asJsonF
|
||||
|
||||
decodeStandard :: HD.Result ResultsWithCount
|
||||
decodeStandard =
|
||||
fromMaybe (Nothing, 0, [], mempty, Right []) <$> HD.rowMaybe standardRow
|
||||
|
||||
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->
|
||||
H.Statement () ResultsWithCount
|
||||
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField pgVer =
|
||||
unicodeStatement sql HE.noParams decodeStandard False
|
||||
where
|
||||
sql = [qc|
|
||||
WITH
|
||||
{sourceCTEName} AS ({selectQuery})
|
||||
{countCTEF}
|
||||
SELECT
|
||||
{countResultF} AS total_result_set,
|
||||
pg_catalog.count(_postgrest_t) AS page_total,
|
||||
{noLocationF} AS header,
|
||||
{bodyF} AS body,
|
||||
{responseHeadersF pgVer} AS response_headers
|
||||
FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]
|
||||
|
||||
(countCTEF, countResultF) = countF countQuery countTotal
|
||||
|
||||
bodyF
|
||||
| asCsv = asCsvF
|
||||
| isSingle = asJsonSingleF
|
||||
| isJust binaryField = asBinaryF $ fromJust binaryField
|
||||
| otherwise = asJsonF
|
||||
|
||||
decodeStandard :: HD.Result ResultsWithCount
|
||||
decodeStandard =
|
||||
HD.singleRow standardRow
|
||||
|
||||
{-| Read and Write api requests use a similar response format which includes
|
||||
various record counts and possible location header. This is the decoder
|
||||
for that common type of query.
|
||||
-}
|
||||
standardRow :: HD.Row ResultsWithCount
|
||||
standardRow = (,,,,) <$> nullableColumn HD.int8 <*> column HD.int8
|
||||
<*> column header <*> column HD.bytea <*> column decodeGucHeaders
|
||||
where
|
||||
header = HD.array $ HD.dimension replicateM $ element HD.bytea
|
||||
|
||||
type ProcResults = (Maybe Int64, Int64, ByteString, Either Text [GucHeader])
|
||||
|
||||
callProcStatement :: Bool -> SqlQuery -> SqlQuery -> SqlQuery -> Bool ->
|
||||
Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->
|
||||
H.Statement ByteString ProcResults
|
||||
callProcStatement returnsScalar callProcQuery selectQuery countQuery countTotal isSingle asCsv asBinary multObjects binaryField pgVer =
|
||||
unicodeStatement sql (param HE.unknown) decodeProc True
|
||||
where
|
||||
sql = [qc|
|
||||
WITH {sourceCTEName} AS ({callProcQuery})
|
||||
{countCTEF}
|
||||
SELECT
|
||||
{countResultF} AS total_result_set,
|
||||
pg_catalog.count(_postgrest_t) AS page_total,
|
||||
{bodyF} AS body,
|
||||
{responseHeadersF pgVer} AS response_headers
|
||||
FROM ({selectQuery}) _postgrest_t;|]
|
||||
|
||||
(countCTEF, countResultF) = countF countQuery countTotal
|
||||
|
||||
bodyF
|
||||
| returnsScalar = scalarBodyF
|
||||
| isSingle = asJsonSingleF
|
||||
| asCsv = asCsvF
|
||||
| isJust binaryField = asBinaryF $ fromJust binaryField
|
||||
| otherwise = asJsonF
|
||||
|
||||
scalarBodyF
|
||||
| asBinary = asBinaryF "pgrst_scalar"
|
||||
| multObjects = "json_agg(_postgrest_t.pgrst_scalar)::character varying"
|
||||
| otherwise = "(json_agg(_postgrest_t.pgrst_scalar)->0)::character varying"
|
||||
|
||||
decodeProc :: HD.Result ProcResults
|
||||
decodeProc =
|
||||
fromMaybe (Just 0, 0, mempty, Right []) <$> HD.rowMaybe procRow
|
||||
where
|
||||
procRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
|
||||
<*> column HD.bytea <*> column decodeGucHeaders
|
||||
|
||||
createExplainStatement :: SqlQuery -> H.Statement () (Maybe Int64)
|
||||
createExplainStatement countQuery =
|
||||
unicodeStatement sql HE.noParams decodeExplain False
|
||||
where
|
||||
sql = [qc| EXPLAIN (FORMAT JSON) {countQuery} |]
|
||||
-- |
|
||||
-- An `EXPLAIN (FORMAT JSON) select * from items;` output looks like this:
|
||||
-- [{
|
||||
-- "Plan": {
|
||||
-- "Node Type": "Seq Scan", "Parallel Aware": false, "Relation Name": "items",
|
||||
-- "Alias": "items", "Startup Cost": 0.00, "Total Cost": 32.60,
|
||||
-- "Plan Rows": 2260,"Plan Width": 8} }]
|
||||
-- We only obtain the Plan Rows here.
|
||||
decodeExplain :: HD.Result (Maybe Int64)
|
||||
decodeExplain =
|
||||
let row = HD.singleRow $ column HD.bytea in
|
||||
(^? L.nth 0 . L.key "Plan" . L.key "Plan Rows" . L._Integral) <$> row
|
||||
|
||||
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
|
||||
unicodeStatement = H.Statement . encodeUtf8
|
||||
|
||||
decodeGucHeaders :: HD.Value (Either Text [GucHeader])
|
||||
decodeGucHeaders = first toS . JSON.eitherDecode . toS <$> HD.bytea
|
||||
+145
-55
@@ -2,10 +2,14 @@
|
||||
Module : PostgREST.Types
|
||||
Description : PostgREST common types and functions used by the rest of the modules
|
||||
-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
|
||||
module PostgREST.Types where
|
||||
|
||||
import Control.Lens.Getter (view)
|
||||
import Control.Lens.Tuple (_1)
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Internal as BS (c2w)
|
||||
@@ -24,7 +28,7 @@ import Protolude
|
||||
|
||||
-- | Enumeration of currently supported response content types
|
||||
data ContentType = CTApplicationJSON | CTSingularJSON
|
||||
| CTTextCSV | CTTextPlain | CTTextHtml
|
||||
| CTTextCSV | CTTextPlain
|
||||
| CTOpenAPI | CTOctetStream
|
||||
| CTAny | CTOther ByteString deriving (Show, Eq)
|
||||
|
||||
@@ -37,7 +41,6 @@ toMime :: ContentType -> ByteString
|
||||
toMime CTApplicationJSON = "application/json"
|
||||
toMime CTTextCSV = "text/csv"
|
||||
toMime CTTextPlain = "text/plain"
|
||||
toMime CTTextHtml = "text/html"
|
||||
toMime CTOpenAPI = "application/openapi+json"
|
||||
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
|
||||
toMime CTOctetStream = "application/octet-stream"
|
||||
@@ -50,7 +53,6 @@ decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of
|
||||
"application/json" -> CTApplicationJSON
|
||||
"text/csv" -> CTTextCSV
|
||||
"text/plain" -> CTTextPlain
|
||||
"text/html" -> CTTextHtml
|
||||
"application/openapi+json" -> CTOpenAPI
|
||||
"application/vnd.pgrst.object+json" -> CTSingularJSON
|
||||
"application/vnd.pgrst.object" -> CTSingularJSON
|
||||
@@ -58,22 +60,53 @@ decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of
|
||||
"*/*" -> CTAny
|
||||
ct' -> CTOther ct'
|
||||
|
||||
-- | ContentTypes that can get a raw/unwrapped response
|
||||
rawContentTypes :: [ContentType]
|
||||
rawContentTypes = [CTOctetStream, CTTextPlain, CTTextHtml]
|
||||
-- | A SQL query that can be executed independently
|
||||
type SqlQuery = Text
|
||||
|
||||
-- | A part of a SQL query that cannot be executed independently
|
||||
type SqlFragment = Text
|
||||
|
||||
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
|
||||
instance Show PreferResolution where
|
||||
show MergeDuplicates = "resolution=merge-duplicates"
|
||||
show IgnoreDuplicates = "resolution=ignore-duplicates"
|
||||
|
||||
-- | How to return the mutated data. From https://tools.ietf.org/html/rfc7240#section-4.2
|
||||
data PreferRepresentation = Full -- ^ Return the body plus the Location header(in case of POST).
|
||||
| HeadersOnly -- ^ Return the Location header(in case of POST). This needs a SELECT privilege on the pk.
|
||||
| None -- ^ Return nothing from the mutated data.
|
||||
deriving Eq
|
||||
instance Show PreferRepresentation where
|
||||
show Full = "return=representation"
|
||||
show None = "return=minimal"
|
||||
show HeadersOnly = mempty
|
||||
|
||||
data PreferParameters
|
||||
= SingleObject -- ^ Pass all parameters as a single json object to a stored procedure
|
||||
| MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure
|
||||
deriving Eq
|
||||
|
||||
instance Show PreferParameters where
|
||||
show SingleObject = "params=single-object"
|
||||
show MultipleObjects = "params=multiple-objects"
|
||||
|
||||
data PreferCount
|
||||
= ExactCount -- ^ exact count(slower)
|
||||
| PlannedCount -- ^ PostgreSQL query planner rows count guess. Done by using EXPLAIN {query}.
|
||||
| EstimatedCount -- ^ use the query planner rows if the count is superior to max-rows, otherwise get the exact count.
|
||||
deriving Eq
|
||||
|
||||
instance Show PreferCount where
|
||||
show ExactCount = "count=exact"
|
||||
show PlannedCount = "count=planned"
|
||||
show EstimatedCount = "count=estimated"
|
||||
|
||||
data DbStructure = DbStructure {
|
||||
dbTables :: [Table]
|
||||
, dbColumns :: [Column]
|
||||
, dbRelations :: [Relation]
|
||||
, dbPrimaryKeys :: [PrimaryKey]
|
||||
-- ProcDescription is a list because a function can be overloaded
|
||||
, dbProcs :: M.HashMap Text [ProcDescription]
|
||||
, dbProcs :: ProcsMap
|
||||
, pgVersion :: PgVersion
|
||||
} deriving (Show, Eq)
|
||||
|
||||
@@ -99,7 +132,8 @@ data ProcVolatility = Volatile | Stable | Immutable
|
||||
deriving (Eq, Show, Ord)
|
||||
|
||||
data ProcDescription = ProcDescription {
|
||||
pdName :: Text
|
||||
pdSchema :: Schema
|
||||
, pdName :: Text
|
||||
, pdDescription :: Maybe Text
|
||||
, pdArgs :: [PgArg]
|
||||
, pdReturnType :: RetType
|
||||
@@ -108,18 +142,23 @@ data ProcDescription = ProcDescription {
|
||||
|
||||
-- Order by least number of args in the case of overloaded functions
|
||||
instance Ord ProcDescription where
|
||||
ProcDescription name1 des1 args1 rt1 vol1 `compare` ProcDescription name2 des2 args2 rt2 vol2
|
||||
| name1 == name2 && length args1 < length args2 = LT
|
||||
| name1 == name2 && length args1 > length args2 = GT
|
||||
| otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2)
|
||||
ProcDescription schema1 name1 des1 args1 rt1 vol1 `compare` ProcDescription schema2 name2 des2 args2 rt2 vol2
|
||||
| schema1 == schema2 && name1 == name2 && length args1 < length args2 = LT
|
||||
| schema2 == schema2 && name1 == name2 && length args1 > length args2 = GT
|
||||
| otherwise = (schema1, name1, des1, args1, rt1, vol1) `compare` (schema2, name2, des2, args2, rt2, vol2)
|
||||
|
||||
-- | A map of all procs, all of which can be overloaded(one entry will have more than one ProcDescription).
|
||||
-- | It uses a HashMap for a faster lookup.
|
||||
type ProcsMap = M.HashMap QualifiedIdentifier [ProcDescription]
|
||||
|
||||
{-|
|
||||
Search a pg procedure by its parameters. Since a function can be overloaded, the name is not enough to find it.
|
||||
An overloaded function can have a different volatility or even a different return type.
|
||||
Ideally, handling overloaded functions should be left to pg itself. But we need to know certain proc attributes in advance.
|
||||
-}
|
||||
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
|
||||
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> Maybe ProcDescription
|
||||
findProc qi payloadKeys paramsAsSingleObject allProcs =
|
||||
case M.lookup (qiName qi) allProcs of
|
||||
case M.lookup qi allProcs of
|
||||
Nothing -> Nothing
|
||||
Just [proc] -> Just proc -- if it's not an overloaded function then immediately get the ProcDescription
|
||||
Just procs -> find matches procs -- Handle overloaded functions case
|
||||
@@ -141,10 +180,19 @@ specifiedProcArgs keys proc =
|
||||
in
|
||||
(\k -> fromMaybe (PgArg k "text" True) (find ((==) k . pgaName) args)) <$> S.toList keys
|
||||
|
||||
procReturnsScalar :: ProcDescription -> Bool
|
||||
procReturnsScalar proc = case proc of
|
||||
ProcDescription{pdReturnType = (Single (Scalar _))} -> True
|
||||
_ -> False
|
||||
|
||||
procTableName :: ProcDescription -> Maybe TableName
|
||||
procTableName proc = case pdReturnType proc of
|
||||
SetOf (Composite qi) -> Just $ qiName qi
|
||||
Single (Composite qi) -> Just $ qiName qi
|
||||
_ -> Nothing
|
||||
|
||||
type Schema = Text
|
||||
type TableName = Text
|
||||
type SqlQuery = Text
|
||||
type SqlFragment = Text
|
||||
|
||||
data Table = Table {
|
||||
tableSchema :: Schema
|
||||
@@ -156,6 +204,9 @@ data Table = Table {
|
||||
instance Eq Table where
|
||||
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
|
||||
|
||||
tableQi :: Table -> QualifiedIdentifier
|
||||
tableQi Table{tableSchema=s, tableName=n} = QualifiedIdentifier s n
|
||||
|
||||
newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
|
||||
|
||||
data Column =
|
||||
@@ -177,8 +228,8 @@ data Column =
|
||||
instance Eq Column where
|
||||
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
|
||||
|
||||
-- | A view column that refers to a table column
|
||||
type Synonym = (Column, ViewColumn)
|
||||
-- | The source table column a view column refers to
|
||||
type SourceColumn = (Column, ViewColumn)
|
||||
type ViewColumn = Column
|
||||
|
||||
data PrimaryKey = PrimaryKey {
|
||||
@@ -209,33 +260,48 @@ data OrderTerm = OrderTerm {
|
||||
data QualifiedIdentifier = QualifiedIdentifier {
|
||||
qiSchema :: Schema
|
||||
, qiName :: TableName
|
||||
} deriving (Show, Eq, Ord)
|
||||
} deriving (Show, Eq, Ord, Generic)
|
||||
instance Hashable QualifiedIdentifier
|
||||
|
||||
-- | The relationship [cardinality](https://en.wikipedia.org/wiki/Cardinality_(data_modeling)).
|
||||
-- | TODO: missing one-to-one
|
||||
data Cardinality = O2M -- ^ one-to-many, previously known as Parent
|
||||
| M2O -- ^ many-to-one, previously known as Child
|
||||
| M2M -- ^ many-to-many, previously known as Many
|
||||
deriving Eq
|
||||
instance Show Cardinality where
|
||||
show O2M = "o2m"
|
||||
show M2O = "m2o"
|
||||
show M2M = "m2m"
|
||||
|
||||
data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
|
||||
type ConstraintName = Text
|
||||
|
||||
{-|
|
||||
The name 'Relation' here is used with the meaning
|
||||
"What is the relation between the current node and the parent node".
|
||||
It has nothing to do with PostgreSQL referring to tables/views as relations.
|
||||
The order of the relColumns and relFColumns should be maintained to get
|
||||
the join conditions right.
|
||||
"Relation"ship between two tables.
|
||||
The order of the relColumns and relFColumns should be maintained to get the join conditions right.
|
||||
TODO merge relColumns and relFColumns to a tuple or Data.Bimap
|
||||
-}
|
||||
data Relation = Relation {
|
||||
relTable :: Table
|
||||
, relColumns :: [Column]
|
||||
, relFTable :: Table
|
||||
, relFColumns :: [Column]
|
||||
, relType :: RelationType
|
||||
-- The Link attrs are used when RelationType == Many
|
||||
, relLinkTable :: Maybe Table
|
||||
, relLinkCols1 :: Maybe [Column]
|
||||
, relLinkCols2 :: Maybe [Column]
|
||||
relTable :: Table
|
||||
, relColumns :: [Column]
|
||||
, relConstraint :: Maybe ConstraintName -- ^ Just on O2M/M2O, Nothing on M2M
|
||||
, relFTable :: Table
|
||||
, relFColumns :: [Column]
|
||||
, relType :: Cardinality
|
||||
, relJunction :: Maybe Junction -- ^ Junction for M2M Cardinality
|
||||
} deriving (Show, Eq)
|
||||
|
||||
isSelfJoin :: Relation -> Bool
|
||||
isSelfJoin r = relType r /= Root && relTable r == relFTable r
|
||||
-- | Junction table on an M2M relationship
|
||||
data Junction = Junction {
|
||||
junTable :: Table
|
||||
, junConstraint1 :: Maybe ConstraintName
|
||||
, junCols1 :: [Column]
|
||||
, junConstraint2 :: Maybe ConstraintName
|
||||
, junCols2 :: [Column]
|
||||
} deriving (Show, Eq)
|
||||
|
||||
isSelfReference :: Relation -> Bool
|
||||
isSelfReference r = relTable r == relFTable r
|
||||
|
||||
data PayloadJSON =
|
||||
-- | Cached attributes of a JSON payload
|
||||
@@ -287,7 +353,8 @@ ftsOperators :: M.HashMap Operator SqlFragment
|
||||
ftsOperators = M.fromList [
|
||||
("fts", "@@ to_tsquery"),
|
||||
("plfts", "@@ plainto_tsquery"),
|
||||
("phfts", "@@ phraseto_tsquery")
|
||||
("phfts", "@@ phraseto_tsquery"),
|
||||
("wfts", "@@ websearch_to_tsquery")
|
||||
]
|
||||
|
||||
data OpExpr = OpExpr Bool Operation deriving (Eq, Show)
|
||||
@@ -338,24 +405,33 @@ type RpcQParam = (Text, Text)
|
||||
Custom guc header, it's obtained by parsing the json in a:
|
||||
`SET LOCAL "response.headers" = '[{"Set-Cookie": ".."}]'
|
||||
-}
|
||||
newtype GucHeader = GucHeader (Text, Text)
|
||||
newtype GucHeader = GucHeader (CI.CI ByteString, ByteString)
|
||||
deriving (Show, Eq)
|
||||
|
||||
instance JSON.FromJSON GucHeader where
|
||||
parseJSON (JSON.Object o) = case headMay (M.toList o) of
|
||||
Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (k, s)
|
||||
Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (CI.mk $ toS k, toS s)
|
||||
| otherwise -> mzero
|
||||
_ -> mzero
|
||||
parseJSON _ = mzero
|
||||
|
||||
toHeaders :: [GucHeader] -> [Header]
|
||||
toHeaders = map $ \(GucHeader (k, v)) -> (CI.mk $ toS k, toS v)
|
||||
unwrapGucHeader :: GucHeader -> Header
|
||||
unwrapGucHeader (GucHeader (k, v)) = (k, v)
|
||||
|
||||
-- | Add headers not already included to allow the user to override them instead of duplicating them
|
||||
addHeadersIfNotIncluded :: [Header] -> [Header] -> [Header]
|
||||
addHeadersIfNotIncluded newHeaders initialHeaders =
|
||||
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
|
||||
initialHeaders
|
||||
|
||||
{-|
|
||||
This type will hold information about which particular 'Relation' between two tables to choose when there are multiple ones.
|
||||
Specifically, it will contain the name of the foreign key or the join table in many to many relations.
|
||||
-}
|
||||
type RelationDetail = Text
|
||||
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe RelationDetail)
|
||||
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe EmbedHint)
|
||||
-- | Disambiguates an embedding operation when there's multiple relationships between two tables.
|
||||
-- | Can be the name of a foreign key constraint, column name or the junction in an m2m relationship.
|
||||
type EmbedHint = Text
|
||||
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]
|
||||
type EmbedPath = [Text]
|
||||
data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)
|
||||
@@ -364,11 +440,11 @@ data JoinCondition = JoinCondition (QualifiedIdentifier, FieldName)
|
||||
|
||||
data ReadQuery = Select {
|
||||
select :: [SelectItem]
|
||||
, from :: TableName
|
||||
, from :: QualifiedIdentifier
|
||||
-- | A table alias is used in case of self joins
|
||||
, fromAlias :: Maybe Alias
|
||||
-- | Only used for Many to Many joins. Parent and Child joins use explicit joins.
|
||||
, implicitJoins :: [TableName]
|
||||
, implicitJoins :: [QualifiedIdentifier]
|
||||
, where_ :: [LogicTree]
|
||||
, joinConditions :: [JoinCondition]
|
||||
, order :: [OrderTerm]
|
||||
@@ -377,31 +453,35 @@ data ReadQuery = Select {
|
||||
|
||||
data MutateQuery =
|
||||
Insert {
|
||||
in_ :: TableName
|
||||
in_ :: QualifiedIdentifier
|
||||
, insCols :: S.Set FieldName
|
||||
, onConflict :: Maybe (PreferResolution, [FieldName])
|
||||
, where_ :: [LogicTree]
|
||||
, returning :: [FieldName]
|
||||
}|
|
||||
Update {
|
||||
in_ :: TableName
|
||||
in_ :: QualifiedIdentifier
|
||||
, updCols :: S.Set FieldName
|
||||
, where_ :: [LogicTree]
|
||||
, returning :: [FieldName]
|
||||
}|
|
||||
Delete {
|
||||
in_ :: TableName
|
||||
in_ :: QualifiedIdentifier
|
||||
, where_ :: [LogicTree]
|
||||
, returning :: [FieldName]
|
||||
} deriving (Show, Eq)
|
||||
|
||||
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
|
||||
type ReadRequest = Tree ReadNode
|
||||
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail, Depth))
|
||||
-- Depth of the ReadRequest tree
|
||||
type Depth = Integer
|
||||
type MutateRequest = MutateQuery
|
||||
|
||||
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe EmbedHint, Depth))
|
||||
type Depth = Integer
|
||||
|
||||
-- First level FieldNames(e.g get a,b from /table?select=a,b,other(c,d))
|
||||
fstFieldNames :: ReadRequest -> [FieldName]
|
||||
fstFieldNames (Node (sel, _) _) =
|
||||
fst . view _1 <$> select sel
|
||||
|
||||
data PgVersion = PgVersion {
|
||||
pgvNum :: Int32
|
||||
, pgvName :: Text
|
||||
@@ -426,9 +506,21 @@ pgVersion96 = PgVersion 90600 "9.6"
|
||||
pgVersion100 :: PgVersion
|
||||
pgVersion100 = PgVersion 100000 "10"
|
||||
|
||||
pgVersion109 :: PgVersion
|
||||
pgVersion109 = PgVersion 100009 "10.9"
|
||||
|
||||
pgVersion110 :: PgVersion
|
||||
pgVersion110 = PgVersion 110000 "11.0"
|
||||
|
||||
pgVersion112 :: PgVersion
|
||||
pgVersion112 = PgVersion 110002 "11.2"
|
||||
|
||||
pgVersion114 :: PgVersion
|
||||
pgVersion114 = PgVersion 110004 "11.4"
|
||||
|
||||
pgVersion121 :: PgVersion
|
||||
pgVersion121 = PgVersion 120001 "12.1"
|
||||
|
||||
sourceCTEName :: SqlFragment
|
||||
sourceCTEName = "pg_source"
|
||||
|
||||
@@ -437,8 +529,6 @@ type JSPath = [JSPathExp]
|
||||
-- | jspath expression, e.g. .property, .property[0] or ."property-dash"
|
||||
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
|
||||
|
||||
|
||||
|
||||
-- | Current database connection status data ConnectionStatus
|
||||
data ConnectionStatus
|
||||
= NotConnected
|
||||
|
||||
+17
-15
@@ -1,18 +1,20 @@
|
||||
# stack.yaml is used for circle-ci tests. Profiling build fails on circleci
|
||||
# with GHC 8.6, so we build with 8.4 for now.
|
||||
|
||||
resolver: lts-12.26
|
||||
extra-deps:
|
||||
- Ranged-sets-0.4.0
|
||||
- configurator-pg-0.1.0.3
|
||||
- http-types-0.12.3
|
||||
- hasql-1.4
|
||||
- hasql-pool-0.5.1
|
||||
- hasql-transaction-0.7.2
|
||||
- text-builder-0.6.5.1
|
||||
- deferred-folds-0.9.10.1
|
||||
- primitive-0.6.4.0
|
||||
# stack is used for circleci and appveyor CI builds
|
||||
resolver: lts-14.3
|
||||
ghc-options:
|
||||
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints
|
||||
# -fno-spec-constr may help keep compile time memory use in check,
|
||||
# see https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304
|
||||
# -optP-Wno-nonportable-include-path
|
||||
# prevents build failures on case-insensitive filesystems (macos),
|
||||
# see https://github.com/commercialhaskell/stack/issues/3918
|
||||
postgrest: -O2 -Werror -Wall -fwarn-identities
|
||||
-fno-spec-constr -optP-Wno-nonportable-include-path
|
||||
nix:
|
||||
packages: [pcre, pkgconfig, postgresql, zlib]
|
||||
|
||||
# needed by stylish haskell, this only runs on ci
|
||||
extra-deps:
|
||||
- HsYAML-0.2.1.0@sha256:e4677daeba57f7a1e9a709a1f3022fe937336c91513e893166bd1f023f530d68,5311
|
||||
- HsYAML-aeson-0.2.0.0@sha256:04796abfc01cffded83f37a10e6edba4f0c0a15d45bef44fc5bb4313d9c87757,1791
|
||||
- configurator-pg-0.2.0@sha256:08dcfadbe31e9e505d0bed1ab034105e1735141499734ad52d1c5ac980cde4a6,2939
|
||||
- hspec-wai-0.10.1@sha256:56dd9ec1d56f47ef1946f71f7cbf070e4c285f718cac1b158400ae5e7172ef47,2290
|
||||
- hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# This file was autogenerated by Stack.
|
||||
# You should not edit this file by hand.
|
||||
# For more information, please see the documentation at:
|
||||
# https://docs.haskellstack.org/en/stable/lock_files
|
||||
|
||||
packages:
|
||||
- completed:
|
||||
hackage: HsYAML-0.2.1.0@sha256:e4677daeba57f7a1e9a709a1f3022fe937336c91513e893166bd1f023f530d68,5311
|
||||
pantry-tree:
|
||||
size: 1340
|
||||
sha256: 21f61bf9cad31674126b106071dd9b852e408796aeffc90eec1792f784107eff
|
||||
original:
|
||||
hackage: HsYAML-0.2.1.0@sha256:e4677daeba57f7a1e9a709a1f3022fe937336c91513e893166bd1f023f530d68,5311
|
||||
- completed:
|
||||
hackage: HsYAML-aeson-0.2.0.0@sha256:04796abfc01cffded83f37a10e6edba4f0c0a15d45bef44fc5bb4313d9c87757,1791
|
||||
pantry-tree:
|
||||
size: 234
|
||||
sha256: 67cc9ba17c79e71d3abdb465a3ee2825477856fff3b8b7d543cbbbefdae9a9d9
|
||||
original:
|
||||
hackage: HsYAML-aeson-0.2.0.0@sha256:04796abfc01cffded83f37a10e6edba4f0c0a15d45bef44fc5bb4313d9c87757,1791
|
||||
- completed:
|
||||
hackage: configurator-pg-0.2.0@sha256:08dcfadbe31e9e505d0bed1ab034105e1735141499734ad52d1c5ac980cde4a6,2939
|
||||
pantry-tree:
|
||||
size: 1748
|
||||
sha256: 760eb12ee3d81b95b68ee10d5d85171b117826f44242b2749d48791efad6c891
|
||||
original:
|
||||
hackage: configurator-pg-0.2.0@sha256:08dcfadbe31e9e505d0bed1ab034105e1735141499734ad52d1c5ac980cde4a6,2939
|
||||
- completed:
|
||||
hackage: hspec-wai-0.10.1@sha256:56dd9ec1d56f47ef1946f71f7cbf070e4c285f718cac1b158400ae5e7172ef47,2290
|
||||
pantry-tree:
|
||||
size: 809
|
||||
sha256: 17af1c2e709cd84bfda066b9ebb04cdde7f92660c51a1f7401a1e9f766524e93
|
||||
original:
|
||||
hackage: hspec-wai-0.10.1@sha256:56dd9ec1d56f47ef1946f71f7cbf070e4c285f718cac1b158400ae5e7172ef47,2290
|
||||
- completed:
|
||||
hackage: hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
|
||||
pantry-tree:
|
||||
size: 349
|
||||
sha256: fb9e89b79cde3276baa484c860c6b9eeebdbc1a5c43301293351a25bc4c08e87
|
||||
original:
|
||||
hackage: hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
|
||||
snapshots:
|
||||
- completed:
|
||||
size: 523878
|
||||
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/14/3.yaml
|
||||
sha256: 470c46c27746a48c7c50f829efc0cf00112787a7804ee4ac7a27754658f6d92c
|
||||
original: lts-14.3
|
||||
@@ -4,7 +4,7 @@ ENV PATH /root/.local/bin:$PATH
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget libpq-dev pkg-config libpcre3 libpcre3-dev \
|
||||
postgresql-client debconf locales \
|
||||
postgresql-client debconf locales build-essential libffi-dev libgmp-dev git \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
||||
&& echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \
|
||||
&& locale-gen \
|
||||
|
||||
@@ -7,12 +7,12 @@ import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import PostgREST.Types (PgVersion, pgVersion112)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion =
|
||||
describe "and/or params used for complex boolean logic" $ do
|
||||
context "used with GET" $ do
|
||||
context "or param" $ do
|
||||
@@ -80,6 +80,19 @@ spec =
|
||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
when (actualPgVersion >= pgVersion112) $
|
||||
it "can handle wfts (websearch_to_tsquery)" $
|
||||
get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"text_search_vector": "'also':2 'fun':3 'possibl':8" },
|
||||
{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },
|
||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7" }
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can handle cs and cd" $
|
||||
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
@@ -114,6 +127,34 @@ spec =
|
||||
get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can handle array operators" $ do
|
||||
get "/entities?arr=eq.{1,2,3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=neq.{1,2}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lt.{2,3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lt.{2,0}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=gt.{1,1}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=gt.{3}&select=id" `shouldRespondWith`
|
||||
[json|[]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lte.{2,1}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lte.{1,2,3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lte.{1,2}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=cs.{1,2}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=cd.{1,2,6}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=ov.{3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=ov.{2,3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "operators with not" $ do
|
||||
it "eq, cs, like can be negated" $
|
||||
get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
|
||||
|
||||
@@ -11,7 +11,7 @@ import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "server started with asymmetric JWK" $
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
|
||||
@@ -11,7 +11,7 @@ import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "test handling of aud claims in JWT" $ do
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
|
||||
@@ -12,7 +12,7 @@ import PostgREST.Types (PgVersion, pgVersion112)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith Application
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion = describe "authorization" $ do
|
||||
let single = ("Accept","application/vnd.pgrst.object+json")
|
||||
|
||||
@@ -117,11 +117,11 @@ spec actualPgVersion = describe "authorization" $ do
|
||||
it "hides tables from users with invalid JWT" $ do
|
||||
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError \"expected 3 parts, got 2\")"} |]
|
||||
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"} |]
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = [
|
||||
"WWW-Authenticate" <:>
|
||||
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError \\\"expected 3 parts, got 2\\\")\""
|
||||
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "server started with binary JWT secret" $
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
|
||||
@@ -19,9 +19,9 @@ import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Queryiny in parallel" $
|
||||
describe "Querying in parallel" $
|
||||
it "should not raise 'transaction in progress' error" $
|
||||
raceTest 10 $
|
||||
get "/fakefake"
|
||||
@@ -35,13 +35,13 @@ spec =
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
raceTest :: Int -> WaiExpectation -> WaiExpectation
|
||||
raceTest :: Int -> WaiExpectation st -> WaiExpectation st
|
||||
raceTest times = liftBaseDiscard go
|
||||
where
|
||||
go test = void $ mapConcurrently (const test) [1..times]
|
||||
|
||||
instance MonadBaseControl IO WaiSession where
|
||||
type StM WaiSession a = StM Session a
|
||||
instance MonadBaseControl IO (WaiSession st) where
|
||||
type StM (WaiSession st) a = StM Session a
|
||||
liftBaseWith f = WaiSession $
|
||||
liftBaseWith $ \runInBase ->
|
||||
f $ \k -> runInBase (unWaiSession k)
|
||||
@@ -49,5 +49,5 @@ instance MonadBaseControl IO WaiSession where
|
||||
{-# INLINE liftBaseWith #-}
|
||||
{-# INLINE restoreM #-}
|
||||
|
||||
instance MonadBase IO WaiSession where
|
||||
instance MonadBase IO (WaiSession st) where
|
||||
liftBase = liftIO
|
||||
|
||||
@@ -14,7 +14,7 @@ import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "CORS" $ do
|
||||
let preflightHeaders = [
|
||||
|
||||
@@ -5,11 +5,12 @@ import Network.Wai (Application)
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Text.Heredoc
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Deleting" $ do
|
||||
context "existing record" $ do
|
||||
@@ -36,7 +37,7 @@ spec =
|
||||
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
|
||||
it "can embed (parent) entities" $
|
||||
request methodDelete "/tasks?id=eq.8&select=id,name,project(id)" [("Prefer", "return=representation")] ""
|
||||
request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
@@ -62,3 +63,24 @@ spec =
|
||||
context "totally unknown route" $
|
||||
it "fails with 404" $
|
||||
request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
|
||||
|
||||
context "table with limited privileges" $ do
|
||||
it "fails deleting the row when return=representation and selecting all the columns" $
|
||||
request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty
|
||||
`shouldRespondWith` 401
|
||||
|
||||
it "succeeds deleting the row when return=representation and selecting only the privileged columns" $
|
||||
request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]
|
||||
[json| { "password": "passxyz" } |]
|
||||
`shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "suceeds deleting the row with no explicit select when using return=minimal" $
|
||||
request methodDelete "/app_users?id=eq.2" [("Prefer", "return=minimal")] mempty
|
||||
`shouldRespondWith` 204
|
||||
|
||||
it "suceeds deleting the row with no explicit select by default" $
|
||||
request methodDelete "/app_users?id=eq.3" [] mempty
|
||||
`shouldRespondWith` 204
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
module Feature.EmbedDisambiguationSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Text.Heredoc
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "resource embedding disambiguation" $ do
|
||||
context "ambiguous requests that give 300 Multiple Choices" $ do
|
||||
it "errs when there's a table and view that point to the same fk" $
|
||||
get "/message?select=id,body,sender(name,sent)" `shouldRespondWith`
|
||||
[json|
|
||||
{
|
||||
"details": [
|
||||
{
|
||||
"cardinality": "m2o",
|
||||
"relationship": "message_sender_fkey[sender][id]",
|
||||
"origin": "test.message",
|
||||
"target": "test.person"
|
||||
},
|
||||
{
|
||||
"cardinality": "m2o",
|
||||
"relationship": "message_sender_fkey[sender][id]",
|
||||
"origin": "test.message",
|
||||
"target": "test.person_detail"
|
||||
}
|
||||
],
|
||||
"hint": "By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)",
|
||||
"message": "More than one relationship was found for message and sender"
|
||||
}
|
||||
|]
|
||||
{ matchStatus = 300
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "errs when there are o2m and m2m cardinalities to the target table" $
|
||||
get "/sites?select=*,big_projects(*)" `shouldRespondWith`
|
||||
[json|
|
||||
{
|
||||
"details": [
|
||||
{
|
||||
"cardinality": "m2o",
|
||||
"relationship": "main_project[main_project_id][big_project_id]",
|
||||
"origin": "test.sites",
|
||||
"target": "test.big_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "m2m",
|
||||
"relationship": "test.jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
|
||||
"origin": "test.sites",
|
||||
"target": "test.big_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "m2m",
|
||||
"relationship": "test.main_jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
|
||||
"origin": "test.sites",
|
||||
"target": "test.big_projects"
|
||||
}
|
||||
],
|
||||
"hint": "By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)",
|
||||
"message": "More than one relationship was found for sites and big_projects"
|
||||
}
|
||||
|]
|
||||
{ matchStatus = 300
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "errs on an ambiguous embed that has a circular reference" $
|
||||
get "/agents?select=*,departments(*)" `shouldRespondWith`
|
||||
[json|
|
||||
{
|
||||
"details": [
|
||||
{
|
||||
"cardinality": "m2o",
|
||||
"relationship": "agents_department_id_fkey[department_id][id]",
|
||||
"origin": "test.agents",
|
||||
"target": "test.departments"
|
||||
},
|
||||
{
|
||||
"cardinality": "o2m",
|
||||
"relationship": "departments_head_id_fkey[id][head_id]",
|
||||
"origin": "test.agents",
|
||||
"target": "test.departments"
|
||||
}
|
||||
],
|
||||
"hint": "By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)",
|
||||
"message": "More than one relationship was found for agents and departments"
|
||||
}
|
||||
|]
|
||||
{ matchStatus = 300
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "errs when there are more than two fks on a junction table(currently impossible to disambiguate, only choice is to split the table)" $
|
||||
-- We have 4 possibilities for doing the junction JOIN here.
|
||||
-- This could be solved by specifying two additional fks, like whatev_projects!fk1!fk2(*)
|
||||
-- If the need arises this capability can be added later without causing a breaking change
|
||||
get "/whatev_sites?select=*,whatev_projects(*)" `shouldRespondWith`
|
||||
[json|
|
||||
{
|
||||
"details": [
|
||||
{
|
||||
"cardinality": "m2m",
|
||||
"relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_1_fkey]",
|
||||
"origin": "test.whatev_sites",
|
||||
"target": "test.whatev_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "m2m",
|
||||
"relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_2_fkey]",
|
||||
"origin": "test.whatev_sites",
|
||||
"target": "test.whatev_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "m2m",
|
||||
"relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_1_fkey]",
|
||||
"origin": "test.whatev_sites",
|
||||
"target": "test.whatev_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "m2m",
|
||||
"relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_2_fkey]",
|
||||
"origin": "test.whatev_sites",
|
||||
"target": "test.whatev_projects"
|
||||
}
|
||||
],
|
||||
"hint": "By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)",
|
||||
"message": "More than one relationship was found for whatev_sites and whatev_projects"
|
||||
}
|
||||
|]
|
||||
{ matchStatus = 300
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "disambiguating requests with embed hints" $ do
|
||||
|
||||
context "using FK to specify the relationship" $ do
|
||||
it "can embed by FK name" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed by FK name and select the FK column at the same time" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed parent with view!fk and grandparent by using fk" $
|
||||
get "/tasks?id=eq.1&select=id,name,projects_view!project(id,name,client(id,name))" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Design w7","projects_view":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||
|
||||
it "can embed by using a composite FK name" $
|
||||
get "/unit_workdays?select=unit_id,day,fst_shift(car_id,schedule(name)),snd_shift(camera_id,schedule(name))" `shouldRespondWith`
|
||||
[json| [
|
||||
{
|
||||
"day": "2019-12-02",
|
||||
"fst_shift": {
|
||||
"car_id": "CAR-349",
|
||||
"schedule": {
|
||||
"name": "morning"
|
||||
}
|
||||
},
|
||||
"snd_shift": {
|
||||
"camera_id": "CAM-123",
|
||||
"schedule": {
|
||||
"name": "night"
|
||||
}
|
||||
},
|
||||
"unit_id": 1
|
||||
}
|
||||
] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds by using two fks pointing to the same table" $
|
||||
get "/orders?id=eq.1&select=id, name, billing(address), shipping(address)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"order 1","billing":{"address": "address 1"},"shipping":{"address": "address 2"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails if the fk is not known" $
|
||||
get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`
|
||||
[json|{"message":"Could not find foreign keys between these entities. No relationship found between message and person"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can request a parent with fk" $
|
||||
get "/comments?select=content,user(name)" `shouldRespondWith`
|
||||
[json|[ { "content": "Needs to be delivered ASAP", "user": { "name": "Angela Martin" } } ]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can request two parents with fks" $
|
||||
get "/articleStars?select=createdAt,article(owner),user(name)&limit=1" `shouldRespondWith`
|
||||
[json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can specify a view!fk" $
|
||||
get "/message?select=id,body,sender:person_detail!message_sender_fkey(name,sent),recipient:person_detail!message_recipient_fkey(name,received)&id=lt.4" `shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
|
||||
{"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
|
||||
{"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can specify a table!fk hint and request children 2 levels" $
|
||||
get "/clients?id=eq.1&select=id,projects:projects!client(id,tasks(id))" `shouldRespondWith`
|
||||
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can disambiguate with the fk in case of an o2m and m2m relationship to the same table" $
|
||||
get "/sites?select=name,main_project(name)&site_id=eq.1" `shouldRespondWith`
|
||||
[json| [ { "name": "site 1", "main_project": { "name": "big project 1" } } ] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "using the column name of the FK to specify the relationship" $ do
|
||||
it "can embed by column" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed by column and select the column at the same time, if aliased" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed parent by using view!column and grandparent by using the column" $
|
||||
get "/tasks?id=eq.1&select=id,name,project:projects_view!project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||
|
||||
it "can specify table!column" $
|
||||
get "/message?select=id,body,sender:person!sender(name),recipient:person!recipient(name)&id=lt.4" `shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
|
||||
{"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
|
||||
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "will embed using a column that has uppercase chars" $
|
||||
get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
|
||||
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds by using two columns pointing to the same table" $
|
||||
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can disambiguate with the column in case of an o2m and m2m relationship to the same table" $
|
||||
get "/sites?select=name,main_project_id(name)&site_id=eq.1" `shouldRespondWith`
|
||||
[json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "using the junction to disambiguate the request" $
|
||||
it "can specify the junction of an m2m relationship" $ do
|
||||
get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
|
||||
[json|
|
||||
[
|
||||
{
|
||||
"big_projects": [
|
||||
{
|
||||
"name": "big project 1"
|
||||
}
|
||||
],
|
||||
"main_project_id": 1,
|
||||
"name": "site 1",
|
||||
"site_id": 1
|
||||
},
|
||||
{
|
||||
"big_projects": [
|
||||
{
|
||||
"name": "big project 1"
|
||||
},
|
||||
{
|
||||
"name": "big project 2"
|
||||
}
|
||||
],
|
||||
"main_project_id": null,
|
||||
"name": "site 2",
|
||||
"site_id": 2
|
||||
}
|
||||
]
|
||||
|]
|
||||
get "/sites?select=*,big_projects!main_jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
|
||||
[json|
|
||||
[
|
||||
{
|
||||
"big_projects": [
|
||||
{
|
||||
"name": "big project 1"
|
||||
}
|
||||
],
|
||||
"main_project_id": 1,
|
||||
"name": "site 1",
|
||||
"site_id": 1
|
||||
},
|
||||
{
|
||||
"big_projects": [],
|
||||
"main_project_id": null,
|
||||
"name": "site 2",
|
||||
"site_id": 2
|
||||
}
|
||||
]
|
||||
|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "using a FK column and a FK to specify the relationship" $
|
||||
it "embeds by using a column and a fk pointing to the same table" $
|
||||
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping(id)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping":{"id":2}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "tables with self reference foreign keys" $ do
|
||||
context "one self reference foreign key" $ do
|
||||
it "embeds parents recursively" $
|
||||
get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`
|
||||
[json|[
|
||||
{ "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },
|
||||
{ "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds childs recursively" $
|
||||
get "/family_tree?id=eq.1&select=id,name, childs:family_tree!parent(id,name,childs:family_tree!parent(id,name))" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": "1", "name": "Parental Unit", "childs": [
|
||||
{ "id": "2", "name": "Kid One", "childs": [ { "id": "4", "name": "Grandkid One" } ] },
|
||||
{ "id": "3", "name": "Kid Two", "childs": [ { "id": "5", "name": "Grandkid Two" } ] }
|
||||
]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds parent and then embeds childs" $
|
||||
get "/family_tree?id=eq.2&select=id,name,parent(id,name,childs:family_tree!parent(id,name))" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": "2", "name": "Kid One", "parent": {
|
||||
"id": "1", "name": "Parental Unit", "childs": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
|
||||
}
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "two self reference foreign keys" $ do
|
||||
it "embeds parents" $
|
||||
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": 3, "name": "Acme",
|
||||
"referee": {
|
||||
"id": 1,
|
||||
"name": "Referee Org"
|
||||
},
|
||||
"auditor": {
|
||||
"id": 2,
|
||||
"name": "Auditor Org"
|
||||
}
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds childs" $ do
|
||||
get "/organizations?select=id,name,refereeds:organizations!referee(id,name)&id=eq.1" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": 1, "name": "Referee Org",
|
||||
"refereeds": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Acme"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Umbrella"
|
||||
}
|
||||
]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/organizations?select=id,name,auditees:organizations!auditor(id,name)&id=eq.2" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": 2, "name": "Auditor Org",
|
||||
"auditees": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Acme"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Umbrella"
|
||||
}
|
||||
]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds other relations(manager) besides the self reference" $ do
|
||||
get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`
|
||||
[json|[{
|
||||
"name":"Cyberdyne",
|
||||
"manager":{"name":"Cyberdyne Manager"},
|
||||
"referee":{
|
||||
"name":"Acme",
|
||||
"manager":{"name":"Acme Manager"},
|
||||
"auditor":{
|
||||
"name":"Auditor Org",
|
||||
"manager":{"name":"Auditor Manager"}}},
|
||||
"auditor":{
|
||||
"name":"Umbrella",
|
||||
"manager":{"name":"Umbrella Manager"},
|
||||
"referee":{
|
||||
"name":"Referee Org",
|
||||
"manager":{"name":"Referee Manager"}}}
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
get "/organizations?select=name,manager(name),auditees:organizations!auditor(name,manager(name),refereeds:organizations!referee(name,manager(name)))&id=eq.2" `shouldRespondWith`
|
||||
[json|[{
|
||||
"name":"Auditor Org",
|
||||
"manager":{"name":"Auditor Manager"},
|
||||
"auditees":[
|
||||
{"name":"Acme",
|
||||
"manager":{"name":"Acme Manager"},
|
||||
"refereeds":[
|
||||
{"name":"Cyberdyne",
|
||||
"manager":{"name":"Cyberdyne Manager"}},
|
||||
{"name":"Oscorp",
|
||||
"manager":{"name":"Oscorp Manager"}}]},
|
||||
{"name":"Umbrella",
|
||||
"manager":{"name":"Umbrella Manager"},
|
||||
"refereeds":[]}]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
-- TODO Remove in next major version
|
||||
describe "old dot '.' symbol, deprecated" $
|
||||
it "still works" $ do
|
||||
get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith`
|
||||
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/tasks?select=id,users:users.users_tasks(id)" `shouldRespondWith`
|
||||
[json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
@@ -9,7 +9,7 @@ import Test.Hspec.Wai.JSON
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "extra search path" $ do
|
||||
|
||||
it "finds the ltree <@ operator on the public schema" $
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
module Feature.HtmlRawOutputSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Text.Heredoc
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper (acceptHdrs)
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "When raw-media-types is set to \"text/html\"" $
|
||||
it "can get raw output with Accept: text/html" $
|
||||
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
|
||||
`shouldRespondWith`
|
||||
[str|
|
||||
|<html>
|
||||
| <head>
|
||||
| <title>PostgREST</title>
|
||||
| </head>
|
||||
| <body>
|
||||
| <h1>Welcome to PostgREST</h1>
|
||||
| </body>
|
||||
|</html>
|
||||
|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/html; charset=utf-8"]
|
||||
}
|
||||
+128
-41
@@ -20,7 +20,7 @@ import PostgREST.Types (PgVersion, pgVersion112)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith Application
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion = do
|
||||
describe "Posting new record" $ do
|
||||
context "disparate json types" $ do
|
||||
@@ -148,15 +148,6 @@ spec actualPgVersion = do
|
||||
simpleBody p `shouldBe` [json| [] |]
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
it "can insert in tables with no select privileges" $ do
|
||||
p <- request methodPost "/insertonly"
|
||||
[("Prefer", "return=minimal")]
|
||||
[json| { "v":"some value" } |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` ""
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
|
||||
it "can post nulls" $ do
|
||||
p <- request methodPost "/no_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
@@ -260,36 +251,6 @@ spec actualPgVersion = do
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
context "table with limited privileges" $ do
|
||||
it "succeeds if correct select is applied" $
|
||||
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [str|[{"article_id":2,"user_id":1}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "fails if more columns are selected" $
|
||||
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
|
||||
else
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "fails if select is not specified" $
|
||||
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
|
||||
else
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
context "POST with ?columns parameter" $ do
|
||||
it "ignores json keys not included in ?columns" $ do
|
||||
request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")]
|
||||
@@ -537,7 +498,7 @@ spec actualPgVersion = do
|
||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $
|
||||
it "makes no updates and returns 200, when patching with an empty json object and return=rep" $
|
||||
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
|
||||
`shouldRespondWith` "[]"
|
||||
{
|
||||
@@ -588,3 +549,129 @@ spec actualPgVersion = do
|
||||
liftIO $ do
|
||||
simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|]
|
||||
simpleStatus p2 `shouldBe` created201
|
||||
|
||||
context "tables with self reference foreign keys" $ do
|
||||
it "embeds parent after insert" $
|
||||
request methodPost "/web_content?select=id,name,parent_content:p_web_id(name)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"id":6, "name":"wot", "p_web_id":4}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":6,"name":"wot","parent_content":{"name":"wut"}}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]
|
||||
}
|
||||
|
||||
it "embeds childs after update" $
|
||||
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"name": "tardis-patched"}|]
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[ { "id": 0, "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|
||||
|]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "embeds parent, childs and grandchilds after update" $
|
||||
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name,web_content(name)),parent_content:p_web_id(name)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"name": "tardis-patched-2"}|]
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "tardis-patched-2",
|
||||
"parent_content": { "name": "wat" },
|
||||
"web_content": [
|
||||
{ "name": "fezz", "web_content": [ { "name": "wut" } ] },
|
||||
{ "name": "foo", "web_content": [] },
|
||||
{ "name": "bar", "web_content": [] }
|
||||
]
|
||||
}
|
||||
] |]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "embeds childs after update without explicitly including the id in the ?select" $
|
||||
request methodPatch "/web_content?id=eq.0&select=name,web_content(name)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"name": "tardis-patched"}|]
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[ { "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|
||||
|]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "embeds an M2M relationship plus parent after update" $
|
||||
request methodPatch "/users?id=eq.1&select=name,tasks(name,project:projects(name))"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"name": "Kevin Malone"}|]
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{
|
||||
"name": "Kevin Malone",
|
||||
"tasks": [
|
||||
{ "name": "Design w7", "project": { "name": "Windows 7" } },
|
||||
{ "name": "Code w7", "project": { "name": "Windows 7" } },
|
||||
{ "name": "Design w10", "project": { "name": "Windows 10" } },
|
||||
{ "name": "Code w10", "project": { "name": "Windows 10" } }
|
||||
]
|
||||
}
|
||||
]|]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "table with limited privileges" $ do
|
||||
it "succeeds inserting if correct select is applied" $
|
||||
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [str|[{"article_id":2,"user_id":1}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "fails inserting if more columns are selected" $
|
||||
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
|
||||
else
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "fails inserting if select is not specified" $
|
||||
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
|
||||
else
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "can insert in a table with no select and return=minimal" $ do
|
||||
p <- request methodPost "/insertonly"
|
||||
[("Prefer", "return=minimal")]
|
||||
[json| { "v":"some value" } |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` ""
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
it "succeeds updating row and gives a 204 when using return=minimal" $
|
||||
request methodPatch "/app_users?id=eq.1" [("Prefer", "return=minimal")]
|
||||
[json| { "password": "passxyz" } |]
|
||||
`shouldRespondWith` 204
|
||||
|
||||
it "can update without return=minimal and no explicit select" $
|
||||
request methodPatch "/app_users?id=eq.1" []
|
||||
[json| { "password": "passabc" } |]
|
||||
`shouldRespondWith` 204
|
||||
|
||||
@@ -7,11 +7,11 @@ import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import PostgREST.Types (PgVersion, pgVersion112)
|
||||
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith Application
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion = describe "json and jsonb operators" $ do
|
||||
context "Shaping response with select parameter" $ do
|
||||
it "obtains a json subfield one level with casting" $
|
||||
@@ -26,7 +26,12 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
|
||||
|
||||
it "fails on bad casting (data of the wrong format)" $
|
||||
get "/complex_items?select=settings->foo->>bar::integer"
|
||||
`shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
||||
`shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion121 then
|
||||
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]
|
||||
else
|
||||
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
||||
)
|
||||
{ matchStatus = 400 , matchHeaders = [] }
|
||||
|
||||
it "obtains a json subfield two levels (string)" $
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
module Feature.MultipleSchemaSpec where
|
||||
|
||||
import Control.Lens ((^?))
|
||||
import Data.Aeson.Lens
|
||||
import Data.Aeson.QQ
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleHeaders), simpleBody)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
|
||||
import PostgREST.Types (PgVersion, pgVersion96)
|
||||
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion =
|
||||
describe "multiple schemas in single instance" $ do
|
||||
context "Reading tables on different schemas" $ do
|
||||
it "succeeds in reading table from default schema v1 if no schema is selected via header" $
|
||||
request methodGet "/parents" [] "" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"name":"parent v1-1"},
|
||||
{"id":2,"name":"parent v1-2"}
|
||||
]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $
|
||||
request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"name":"parent v1-1"},
|
||||
{"id":2,"name":"parent v1-2"}
|
||||
]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in reading table from schema v2" $
|
||||
request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":3,"name":"parent v2-3"},
|
||||
{"id":4,"name":"parent v2-4"}
|
||||
]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
it "succeeds in reading another_table from schema v2" $
|
||||
request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":5,"another_value":"value 5"},
|
||||
{"id":6,"another_value":"value 6"}
|
||||
]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
it "doesn't find another_table in schema v1" $
|
||||
request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404
|
||||
|
||||
it "fails trying to read table from unkown schema" $
|
||||
request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
|
||||
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||
{
|
||||
matchStatus = 406
|
||||
}
|
||||
|
||||
context "Inserting tables on different schemas" $ do
|
||||
it "succeeds inserting on default schema and returning it" $
|
||||
request methodPost "/childs" [("Prefer", "return=representation")] [json|{"name": "child v1-1", "parent_id": 1}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1, "name": "child v1-1", "parent_id": 1}]|]
|
||||
{
|
||||
matchStatus = 201
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds inserting on the v1 schema and returning its parent" $
|
||||
request methodPost "/childs?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v1")]
|
||||
[json|{"name": "child v1-2", "parent_id": 2}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":2, "parent": {"id": 2, "name": "parent v1-2"}}]|]
|
||||
{
|
||||
matchStatus = 201
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds inserting on the v2 schema and returning its parent" $
|
||||
request methodPost "/childs?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v2")]
|
||||
[json|{"name": "child v2-3", "parent_id": 3}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1, "parent": {"id": 3, "name": "parent v2-3"}}]|]
|
||||
{
|
||||
matchStatus = 201
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
it "fails when inserting on an unknown schema" $
|
||||
request methodPost "/childs" [("Content-Profile", "unknown")]
|
||||
[json|{"name": "child 4", "parent_id": 4}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||
{
|
||||
matchStatus = 406
|
||||
}
|
||||
|
||||
context "calling procs on different schemas" $ do
|
||||
it "succeeds in calling the default schema proc" $
|
||||
request methodGet "/rpc/get_parents_below?id=6" [] ""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in calling the v1 schema proc and embedding" $
|
||||
request methodGet "/rpc/get_parents_below?id=6&select=id,name,childs(id,name)" [("Accept-Profile", "v1")] ""
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{"id":1,"name":"parent v1-1","childs":[{"id":1,"name":"child v1-1"}]},
|
||||
{"id":2,"name":"parent v1-2","childs":[{"id":2,"name":"child v1-2"}]}] |]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in calling the v2 schema proc and embedding" $
|
||||
request methodGet "/rpc/get_parents_below?id=6&select=id,name,childs(id,name)" [("Accept-Profile", "v2")] ""
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{"id":3,"name":"parent v2-3","childs":[{"id":1,"name":"child v2-3"}]},
|
||||
{"id":4,"name":"parent v2-4","childs":[]}] |]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
context "Modifying tables on different schemas" $ do
|
||||
it "succeeds in patching on the v1 schema and returning its parent" $
|
||||
request methodPatch "/childs?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]
|
||||
[json|{"name": "child v1-1 updated"}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in patching on the v2 schema and returning its parent" $
|
||||
request methodPatch "/childs?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
|
||||
[json|{"name": "child v2-1 updated"}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
it "succeeds on deleting on the v2 schema" $ do
|
||||
request methodDelete "/childs?id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` [json|[{"id": 1, "name": "child v2-1 updated", "parent_id": 3}]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
request methodGet "/childs?id=eq.1" [("Accept-Profile", "v2")] ""
|
||||
`shouldRespondWith` "[]"
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
when (actualPgVersion >= pgVersion96) $
|
||||
it "succeeds on PUT on the v2 schema" $
|
||||
request methodPut "/childs?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
|
||||
[json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
context "OpenAPI output" $ do
|
||||
it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do
|
||||
req <- request methodGet "/" [] ""
|
||||
|
||||
liftIO $ do
|
||||
simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"
|
||||
|
||||
let def = simpleBody req ^? key "definitions" . key "parents"
|
||||
|
||||
def `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||
"format" : "integer",
|
||||
"type" : "integer"
|
||||
},
|
||||
"name" : {
|
||||
"format" : "text",
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do
|
||||
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"
|
||||
|
||||
let def = simpleBody r ^? key "definitions" . key "parents"
|
||||
|
||||
def `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||
"format" : "integer",
|
||||
"type" : "integer"
|
||||
},
|
||||
"name" : {
|
||||
"format" : "text",
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
it "succeeds in reading table definition from schema v2" $ do
|
||||
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
|
||||
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
|
||||
|
||||
let def = simpleBody r ^? key "definitions" . key "parents"
|
||||
|
||||
def `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||
"format" : "integer",
|
||||
"type" : "integer"
|
||||
},
|
||||
"name" : {
|
||||
"format" : "text",
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
it "succeeds in reading another_table definition from schema v2" $ do
|
||||
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
|
||||
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
|
||||
|
||||
let def = simpleBody r ^? key "definitions" . key "another_table"
|
||||
|
||||
def `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||
"format" : "integer",
|
||||
"type" : "integer"
|
||||
},
|
||||
"another_value" : {
|
||||
"format" : "text",
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
it "doesn't find another_table definition in schema v1" $ do
|
||||
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||
|
||||
liftIO $ do
|
||||
let def = simpleBody r ^? key "definitions" . key "another_table"
|
||||
def `shouldBe` Nothing
|
||||
|
||||
it "fails trying to read definitions from unkown schema" $
|
||||
request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
|
||||
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||
{
|
||||
matchStatus = 406
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "server started without JWT secret" $ do
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
|
||||
@@ -7,7 +7,7 @@ import Test.Hspec.Wai
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Non existent api schema" $ do
|
||||
it "succeeds when requesting root path" $
|
||||
|
||||
@@ -9,7 +9,7 @@ import Test.Hspec.Wai.JSON
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "features supported on PostgreSQL 9.5" $
|
||||
context "json array negative index" $ do
|
||||
it "can select with negative indexes" $ do
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
module Feature.PgVersion96Spec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleHeaders))
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
@@ -9,10 +11,10 @@ import Test.Hspec.Wai.JSON
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "features supported on PostgreSQL 9.6" $ do
|
||||
context "GUC headers" $ do
|
||||
context "GUC headers on function calls" $ do
|
||||
it "succeeds setting the headers" $ do
|
||||
get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"
|
||||
`shouldRespondWith` [json|[{"id": 2}]|]
|
||||
@@ -67,6 +69,98 @@ spec =
|
||||
"Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/",
|
||||
"Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"]}
|
||||
|
||||
context "GUC headers on all other methods via pre-request" $ do
|
||||
it "succeeds setting the headers on GET and HEAD" $ do
|
||||
request methodGet "/items?id=eq.1" [("User-Agent", "MSIE 6.0")] mempty
|
||||
`shouldRespondWith` [json|[{"id": 1}]|]
|
||||
{matchHeaders = [
|
||||
matchContentTypeJson,
|
||||
"Cache-Control" <:> "no-cache, no-store, must-revalidate"]}
|
||||
|
||||
request methodHead "/items?id=eq.1" [("User-Agent", "MSIE 7.0")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"]}
|
||||
|
||||
request methodHead "/projects" [("Accept", "text/csv")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{matchHeaders = ["Content-Disposition" <:> "attachment; filename=projects.csv"]}
|
||||
|
||||
it "succeeds setting the headers on POST" $
|
||||
request methodPost "/items" [] [json|[{"id": 11111}]|]
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
|
||||
}
|
||||
|
||||
it "succeeds setting the headers on PATCH" $
|
||||
request methodPatch "/items?id=eq.11111" [] [json|[{"id": 11111}]|]
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
|
||||
}
|
||||
|
||||
it "succeeds setting the headers on PUT" $
|
||||
request methodPut "/items?id=eq.11111" [] [json|[{"id": 11111}]|]
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
|
||||
}
|
||||
|
||||
it "succeeds setting the headers on DELETE" $
|
||||
request methodDelete "/items?id=eq.11111" [] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
|
||||
}
|
||||
|
||||
context "Override provided headers by using GUC headers" $ do
|
||||
it "can override the Content-Type header" $ do
|
||||
request methodHead "/clients?id=eq.1" [] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/geo+json"]
|
||||
}
|
||||
request methodHead "/rpc/getallprojects" [] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/geo+json"]
|
||||
}
|
||||
|
||||
it "can override the Location header" $
|
||||
request methodPost "/stuff" [] [json|[{"id": 1, "name": "stuff 1"}]|]
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Location" <:> "/stuff?id=eq.1&overriden=true"]
|
||||
}
|
||||
|
||||
-- On https://github.com/PostgREST/postgrest/issues/1427#issuecomment-595907535
|
||||
-- it was reported that blank headers ` : ` where added and that cause proxies to fail the requests.
|
||||
-- These tests are to ensure no blank headers are added.
|
||||
context "Blank headers bug" $ do
|
||||
it "shouldn't add blank headers on POST" $ do
|
||||
r <- request methodPost "/loc_test" [] [json|{"id": "1", "c": "c1"}|]
|
||||
liftIO $ do
|
||||
let respHeaders = simpleHeaders r
|
||||
respHeaders `shouldSatisfy` noBlankHeader
|
||||
|
||||
it "shouldn't add blank headers on PATCH" $ do
|
||||
r <- request methodPatch "/loc_test?id=eq.1" [] [json|{"c": "c2"}|]
|
||||
liftIO $ do
|
||||
let respHeaders = simpleHeaders r
|
||||
respHeaders `shouldSatisfy` noBlankHeader
|
||||
|
||||
it "shouldn't add blank headers on GET" $ do
|
||||
r <- request methodGet "/loc_test" [] ""
|
||||
liftIO $ do
|
||||
let respHeaders = simpleHeaders r
|
||||
respHeaders `shouldSatisfy` noBlankHeader
|
||||
|
||||
it "shouldn't add blank headers on DELETE" $ do
|
||||
r <- request methodDelete "/loc_test?id=eq.1" [] ""
|
||||
liftIO $ do
|
||||
let respHeaders = simpleHeaders r
|
||||
respHeaders `shouldSatisfy` noBlankHeader
|
||||
|
||||
context "Use of the phraseto_tsquery function" $ do
|
||||
it "finds matches" $
|
||||
get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`
|
||||
|
||||
@@ -6,7 +6,7 @@ import Test.Hspec hiding (pendingWith)
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "GET / with proxy" $
|
||||
it "returns a valid openapi spec with proxy" $
|
||||
|
||||
@@ -11,9 +11,9 @@ import Test.Hspec.Wai.JSON
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Requesting many items with server limits enabled" $ do
|
||||
describe "Requesting many items with server limits(max-rows) enabled" $ do
|
||||
it "restricts results" $
|
||||
get "/items"
|
||||
`shouldRespondWith` [json| [{"id":1},{"id":2}] |]
|
||||
@@ -29,16 +29,45 @@ spec =
|
||||
matchHeader "Content-Range" "0-0/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "limit works on all levels" $
|
||||
it "works on all levels" $
|
||||
get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
|
||||
`shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||
}
|
||||
|
||||
it "limit is not applied to parent embeds" $
|
||||
get "/tasks?select=id,project(id)&id=gt.5"
|
||||
it "succeeds in getting parent embeds despite the limit, see #647" $
|
||||
get "/tasks?select=id,project:projects(id)&id=gt.5"
|
||||
`shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||
}
|
||||
|
||||
it "can offset the parent embed, being consistent with the other embed types" $
|
||||
get "/tasks?select=id,project:projects(id)&id=gt.5&project.offset=1"
|
||||
`shouldRespondWith` [json|[{"id":6,"project":null}, {"id":7,"project":null}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||
}
|
||||
|
||||
context "count=estimated" $ do
|
||||
it "uses the query planner guess when query rows > maxRows" $
|
||||
request methodHead "/getallprojects_view" [("Prefer", "count=estimated")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/2019"]
|
||||
}
|
||||
|
||||
it "gives exact count when query rows <= maxRows" $
|
||||
request methodHead "/getallprojects_view?id=lt.3" [("Prefer", "count=estimated")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
||||
}
|
||||
|
||||
it "only uses the query planner guess if it's indeed greater than the exact count" $
|
||||
request methodHead "/get_projects_above_view" [("Prefer", "count=estimated")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/3"]
|
||||
}
|
||||
|
||||
+72
-266
@@ -4,17 +4,18 @@ import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleHeaders))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Text.Heredoc
|
||||
|
||||
import Protolude hiding (get)
|
||||
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec = do
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion = do
|
||||
|
||||
describe "Querying a table with a column called count" $
|
||||
it "should not confuse count column with pg_catalog.count aggregate" $
|
||||
@@ -119,6 +120,29 @@ spec = do
|
||||
[json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
when (actualPgVersion >= pgVersion112) $ do
|
||||
it "finds matches with websearch_to_tsquery" $
|
||||
get "/tsearch?text_search_vector=wfts.The%20Fat%20Rats" `shouldRespondWith`
|
||||
[json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can use boolean operators(and, or, -) in websearch_to_tsquery" $ do
|
||||
get "/tsearch?text_search_vector=wfts.fun%20and%20possible"
|
||||
`shouldRespondWith`
|
||||
[json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/tsearch?text_search_vector=wfts.impossible%20or%20possible"
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
|
||||
{"text_search_vector": "'also':2 'fun':3 'possibl':8"}]
|
||||
|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/tsearch?text_search_vector=wfts.fun%20and%20-possible"
|
||||
`shouldRespondWith`
|
||||
[json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "finds matches with different dictionaries" $ do
|
||||
get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`
|
||||
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
|
||||
@@ -127,6 +151,12 @@ spec = do
|
||||
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
when (actualPgVersion >= pgVersion112) $
|
||||
get "/tsearch?text_search_vector=wfts(french).amusant%20impossible"
|
||||
`shouldRespondWith`
|
||||
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can be negated with not operator" $ do
|
||||
get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`
|
||||
[json| [
|
||||
@@ -145,6 +175,13 @@ spec = do
|
||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
|
||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
when (actualPgVersion >= pgVersion112) $
|
||||
get "/tsearch?text_search_vector=not.wfts(english).impossible%20or%20fat%20or%20fun"
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
|
||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "matches with computed column" $
|
||||
get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
|
||||
@@ -161,7 +198,7 @@ spec = do
|
||||
|
||||
it "matches filtering nested items 2" $
|
||||
get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*"
|
||||
`shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities, No relation found between projects and tasks2"}|]
|
||||
`shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities. No relationship found between projects and tasks2"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
@@ -228,19 +265,8 @@ spec = do
|
||||
[json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "requesting parent without specifying primary key" $
|
||||
get "/projects?select=name,client(name)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Windows 7","client":{"name": "Microsoft"}},
|
||||
{"name":"Windows 10","client":{"name": "Microsoft"}},
|
||||
{"name":"IOS","client":{"name": "Apple"}},
|
||||
{"name":"OSX","client":{"name": "Apple"}},
|
||||
{"name":"Orphan","client":null}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "requesting parent and renaming primary key" $
|
||||
get "/projects?select=name,client(clientId:id,name)" `shouldRespondWith`
|
||||
get "/projects?select=name,client:clients(clientId:id,name)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},
|
||||
{"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},
|
||||
@@ -258,24 +284,11 @@ spec = do
|
||||
[json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embed data with two fk pointing to the same table" $
|
||||
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
|
||||
|
||||
|
||||
it "requesting parents and children while renaming them" $
|
||||
get "/projects?id=eq.1&select=myId:id, name, project_client:client_id(*), project_tasks:tasks(id, name)" `shouldRespondWith`
|
||||
get "/projects?id=eq.1&select=myId:id, name, project_client:clients(*), project_tasks:tasks(id, name)" `shouldRespondWith`
|
||||
[json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "requesting parents two levels up while using FK to specify the link" $
|
||||
get "/tasks?id=eq.1&select=id,name,project:project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||
|
||||
it "requesting parents two levels up while using FK to specify the link (with rename)" $
|
||||
get "/tasks?id=eq.1&select=id,name,project:project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||
|
||||
it "requesting parents and filtering parent columns" $
|
||||
get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
|
||||
@@ -314,21 +327,6 @@ spec = do
|
||||
[json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed by FK column name" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed by FK column name and select the FK value at the same time, if aliased" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can select by column name sans id" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
describe "view embedding" $ do
|
||||
it "can detect fk relations through views to tables in the public schema" $
|
||||
get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200
|
||||
@@ -336,8 +334,8 @@ spec = do
|
||||
it "can detect fk relations through materialized views to tables in the public schema" $
|
||||
get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200
|
||||
|
||||
it "can request parent without specifying primary key" $
|
||||
get "/articleStars?select=createdAt,article(owner),user(name)&limit=1" `shouldRespondWith`
|
||||
it "can request two parents" $
|
||||
get "/articleStars?select=createdAt,article:articles(owner),user:users(name)&limit=1" `shouldRespondWith`
|
||||
[json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
@@ -357,16 +355,16 @@ spec = do
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "detects parent relations when having many views of a private table" $ do
|
||||
get "/books?select=title,author(name)&id=eq.5" `shouldRespondWith`
|
||||
get "/books?select=title,author:authors(name)&id=eq.5" `shouldRespondWith`
|
||||
[json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/forties_books?select=title,author(name)&limit=1" `shouldRespondWith`
|
||||
get "/forties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
|
||||
[json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/fifties_books?select=title,author(name)&limit=1" `shouldRespondWith`
|
||||
get "/fifties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
|
||||
[json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/sixties_books?select=title,author(name)&limit=1" `shouldRespondWith`
|
||||
get "/sixties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
|
||||
[json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
@@ -429,7 +427,7 @@ spec = do
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed a view that has group by" $
|
||||
get "/projects_count_grouped_by?select=number_of_projects,client(name)&order=number_of_projects" `shouldRespondWith`
|
||||
get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`
|
||||
[json|
|
||||
[{"number_of_projects":1,"client":null},
|
||||
{"number_of_projects":2,"client":{"name":"Microsoft"}},
|
||||
@@ -441,48 +439,6 @@ spec = do
|
||||
[json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
describe "path fixed" $ do
|
||||
it "works when requesting children 2 levels" $
|
||||
get "/clients?id=eq.1&select=id,projects:projects%2Bclient_id(id,tasks(id))" `shouldRespondWith`
|
||||
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "works with parent relation" $
|
||||
get "/message?select=id,body,sender:person%2Bsender(name),recipient:person%2Brecipient(name)&id=lt.4" `shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
|
||||
{"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
|
||||
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails with an unknown relation" $
|
||||
get "/message?select=id,sender:person.space(name)&id=lt.4" `shouldRespondWith`
|
||||
[json|{"message":"Could not find foreign keys between these entities, No relation found between message and person"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "works with a parent view relation" $
|
||||
get "/message?select=id,body,sender:person_detail%2Bsender(name,sent),recipient:person_detail%2Brecipient(name,received)&id=lt.4" `shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
|
||||
{"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
|
||||
{"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "works with many<->many relation" $
|
||||
get "/tasks?select=id,users:users%2Busers_tasks(id)" `shouldRespondWith`
|
||||
[json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
describe "old dot '.' symbol, deprecated" $
|
||||
it "still works" $ do
|
||||
get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith`
|
||||
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/tasks?select=id,users:users.users_tasks(id)" `shouldRespondWith`
|
||||
[json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
describe "aliased embeds" $ do
|
||||
it "works with child relation" $
|
||||
get "/space?select=id,zones:zone(id,name),stores:zone(id,name)&zones.zone_type_id=eq.2&stores.zone_type_id=eq.3" `shouldRespondWith`
|
||||
@@ -540,114 +496,6 @@ spec = do
|
||||
{ "id":4,"childs":[]}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
describe "tables with self reference foreign keys" $ do
|
||||
context "one self reference foreign key" $ do
|
||||
it "embeds parents recursively" $
|
||||
get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`
|
||||
[json|[
|
||||
{ "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },
|
||||
{ "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds childs recursively" $
|
||||
get "/family_tree?id=eq.1&select=id,name, childs:family_tree%2Bparent(id,name,childs:family_tree%2Bparent(id,name))" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": "1", "name": "Parental Unit", "childs": [
|
||||
{ "id": "2", "name": "Kid One", "childs": [ { "id": "4", "name": "Grandkid One" } ] },
|
||||
{ "id": "3", "name": "Kid Two", "childs": [ { "id": "5", "name": "Grandkid Two" } ] }
|
||||
]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds parent and then embeds childs" $
|
||||
get "/family_tree?id=eq.2&select=id,name,parent(id,name,childs:family_tree%2Bparent(id,name))" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": "2", "name": "Kid One", "parent": {
|
||||
"id": "1", "name": "Parental Unit", "childs": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
|
||||
}
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "two self reference foreign keys" $ do
|
||||
it "embeds parents" $
|
||||
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": 3, "name": "Acme",
|
||||
"referee": {
|
||||
"id": 1,
|
||||
"name": "Referee Org"
|
||||
},
|
||||
"auditor": {
|
||||
"id": 2,
|
||||
"name": "Auditor Org"
|
||||
}
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds childs" $ do
|
||||
get "/organizations?select=id,name,refereeds:organizations%2Breferee(id,name)&id=eq.1" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": 1, "name": "Referee Org",
|
||||
"refereeds": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Acme"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Umbrella"
|
||||
}
|
||||
]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/organizations?select=id,name,auditees:organizations%2Bauditor(id,name)&id=eq.2" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": 2, "name": "Auditor Org",
|
||||
"auditees": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Acme"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Umbrella"
|
||||
}
|
||||
]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds other relations(manager) besides the self reference" $ do
|
||||
get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`
|
||||
[json|[{
|
||||
"name":"Cyberdyne",
|
||||
"manager":{"name":"Cyberdyne Manager"},
|
||||
"referee":{
|
||||
"name":"Acme",
|
||||
"manager":{"name":"Acme Manager"},
|
||||
"auditor":{
|
||||
"name":"Auditor Org",
|
||||
"manager":{"name":"Auditor Manager"}}},
|
||||
"auditor":{
|
||||
"name":"Umbrella",
|
||||
"manager":{"name":"Umbrella Manager"},
|
||||
"referee":{
|
||||
"name":"Referee Org",
|
||||
"manager":{"name":"Referee Manager"}}}
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
get "/organizations?select=name,manager(name),auditees:organizations%2Bauditor(name,manager(name),refereeds:organizations%2Breferee(name,manager(name)))&id=eq.2" `shouldRespondWith`
|
||||
[json|[{
|
||||
"name":"Auditor Org",
|
||||
"manager":{"name":"Auditor Manager"},
|
||||
"auditees":[
|
||||
{"name":"Acme",
|
||||
"manager":{"name":"Acme Manager"},
|
||||
"refereeds":[
|
||||
{"name":"Cyberdyne",
|
||||
"manager":{"name":"Cyberdyne Manager"}},
|
||||
{"name":"Oscorp",
|
||||
"manager":{"name":"Oscorp Manager"}}]},
|
||||
{"name":"Umbrella",
|
||||
"manager":{"name":"Umbrella Manager"},
|
||||
"refereeds":[]}]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
describe "ordering response" $ do
|
||||
it "by a column asc" $
|
||||
get "/items?id=lte.2&order=id.asc"
|
||||
@@ -743,10 +591,6 @@ spec = do
|
||||
get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]
|
||||
|
||||
it "ordering embeded parents does not break things when using ducktape names" $
|
||||
get "/projects?id=eq.1&select=id, name, client(id, name)&client.order=name.asc" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}]|]
|
||||
|
||||
context "order syntax errors" $ do
|
||||
it "gives meaningful error messages when asc/desc/nulls{first,last} are misspelled" $ do
|
||||
get "/items?order=id.ac" `shouldRespondWith`
|
||||
@@ -876,11 +720,6 @@ spec = do
|
||||
[json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "will embed using a column" $
|
||||
get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
|
||||
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "will select and filter a column that has spaces" $
|
||||
get "/Server%20Today?select=Just%20A%20Server%20Model&Just%20A%20Server%20Model=like.*91*" `shouldRespondWith`
|
||||
[json|[
|
||||
@@ -913,7 +752,7 @@ spec = do
|
||||
it "fails if a single column is not selected" $ do
|
||||
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"application/octet-stream requested but a single column was not selected"} |]
|
||||
[json| {"message":"application/octet-stream requested but more than one column was selected"} |]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
@@ -929,58 +768,6 @@ spec = do
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||
}
|
||||
|
||||
describe "HTTP request env vars" $ do
|
||||
it "custom header is set" $
|
||||
request methodPost "/rpc/get_guc_value"
|
||||
[("Custom-Header", "test")]
|
||||
[json| { "name": "request.header.custom-header" } |]
|
||||
`shouldRespondWith`
|
||||
[str|"test"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
it "standard header is set" $
|
||||
request methodPost "/rpc/get_guc_value"
|
||||
[("Origin", "http://example.com")]
|
||||
[json| { "name": "request.header.origin" } |]
|
||||
`shouldRespondWith`
|
||||
[str|"http://example.com"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
it "current role is available as GUC claim" $
|
||||
request methodPost "/rpc/get_guc_value" []
|
||||
[json| { "name": "request.jwt.claim.role" } |]
|
||||
`shouldRespondWith`
|
||||
[str|"postgrest_test_anonymous"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
it "single cookie ends up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
|
||||
[json| {"name":"request.cookie.acookie"} |]
|
||||
`shouldRespondWith`
|
||||
[str|"cookievalue"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "multiple cookies ends up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
|
||||
[json| {"name":"request.cookie.secondcookie"} |]
|
||||
`shouldRespondWith`
|
||||
[str|"anothervalue"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "app settings available" $
|
||||
request methodPost "/rpc/get_guc_value" []
|
||||
[json| { "name": "app.settings.app_host" } |]
|
||||
`shouldRespondWith`
|
||||
[str|"localhost"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
describe "values with quotes in IN and NOT IN" $ do
|
||||
it "succeeds when only quoted values are present" $ do
|
||||
get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`
|
||||
@@ -1046,8 +833,12 @@ spec = do
|
||||
|
||||
it "only returns an empty result set if the in value is empty" $
|
||||
get "/items_with_different_col_types?int_data=in.( ,3,4)"
|
||||
`shouldRespondWith`
|
||||
`shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion121 then
|
||||
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"\""} |]
|
||||
else
|
||||
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
|
||||
)
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
@@ -1074,3 +865,18 @@ spec = do
|
||||
|
||||
it "cannot use ltree(in public schema) extension operators if no extra search path added" $
|
||||
get "/ltree_sample?path=cd.Top.Science.Astronomy" `shouldRespondWith` 400
|
||||
|
||||
context "VIEW that has a source FK based on a UNIQUE key" $
|
||||
it "can be embedded" $
|
||||
get "/referrals?select=site,link:pages(url)" `shouldRespondWith`
|
||||
[json| [
|
||||
{"site":"github.com", "link":{"url":"http://postgrest.org/en/v6.0/api.html"}},
|
||||
{"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "shouldn't produce a Content-Profile header since only a single schema is exposed" $ do
|
||||
r <- get "/items"
|
||||
liftIO $ do
|
||||
let respHeaders = simpleHeaders r
|
||||
respHeaders `shouldSatisfy` noProfileHeader
|
||||
|
||||
@@ -19,7 +19,7 @@ defaultRange = [json| { "min": 0, "max": 15 } |]
|
||||
emptyRange :: BL.ByteString
|
||||
emptyRange = [json| { "min": 2, "max": 2 } |]
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = do
|
||||
describe "POST /rpc/getitemrange" $ do
|
||||
context "without range headers" $ do
|
||||
@@ -155,12 +155,17 @@ spec = do
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
it "limit and offset works on first level" $
|
||||
it "limit and offset works on first level" $ do
|
||||
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
||||
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
||||
}
|
||||
request methodHead "/items?select=id&order=id.asc&limit=3&offset=2" [] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
||||
}
|
||||
|
||||
it "succeeds if offset equals 0 as a no-op" $
|
||||
get "/items?select=id&offset=0"
|
||||
@@ -192,6 +197,65 @@ spec = do
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "when count=planned" $ do
|
||||
it "obtains a filtered range" $ do
|
||||
request methodGet "/items?select=id&id=gt.8" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` [json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-6/8"]
|
||||
}
|
||||
request methodGet "/child_entities?select=id&id=gt.3" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` [json|[{"id":4}, {"id":5}, {"id":6}]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/4"]
|
||||
}
|
||||
request methodGet "/getallprojects_view?select=id&id=lt.3" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` [json|[{"id":1}, {"id":2}]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/673"]
|
||||
}
|
||||
|
||||
it "obtains the full range" $ do
|
||||
request methodHead "/items" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-14/15"]
|
||||
}
|
||||
request methodHead "/child_entities" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-5/6"]
|
||||
}
|
||||
request methodHead "/getallprojects_view" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-4/2019"]
|
||||
}
|
||||
|
||||
it "ignores limit/offset on the planned count" $ do
|
||||
request methodHead "/items?limit=2&offset=3" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "3-4/15"]
|
||||
}
|
||||
request methodHead "/child_entities?limit=2" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/6"]
|
||||
}
|
||||
request methodHead "/getallprojects_view?limit=2" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/2019"]
|
||||
}
|
||||
|
||||
it "works with two levels" $
|
||||
request methodHead "/child_entities?select=*,entities(*)" [("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-5/6"]
|
||||
}
|
||||
|
||||
context "with range headers" $ do
|
||||
context "of acceptable range" $ do
|
||||
it "succeeds with partial content" $ do
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
module Feature.RawOutputTypesSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude
|
||||
import SpecHelper (acceptHdrs)
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "When raw-media-types config variable is missing or left empty" $ do
|
||||
let firefoxAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
|
||||
it "responds json to a GET request with Firefox Accept headers" $
|
||||
request methodGet "/items?id=eq.1" firefoxAcceptHdrs ""
|
||||
`shouldRespondWith` [json| [{"id":1}] |]
|
||||
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||
it "responds json to a GET request with Chrome Accept headers" $
|
||||
request methodGet "/items?id=eq.1" chromeAcceptHdrs ""
|
||||
`shouldRespondWith` [json| [{"id":1}] |]
|
||||
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||
|
||||
it "responds json to a GET request to RPC with Firefox Accept headers" $
|
||||
request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
|
||||
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
|
||||
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||
it "responds json to a GET request to RPC with Chrome Accept headers" $
|
||||
request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
|
||||
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
|
||||
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||
@@ -11,7 +11,7 @@ import Protolude hiding (get)
|
||||
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "root spec function" $ do
|
||||
it "accepts application/openapi+json" $
|
||||
|
||||
+152
-52
@@ -11,12 +11,13 @@ import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Text.Heredoc
|
||||
|
||||
import PostgREST.Types (PgVersion, pgVersion100, pgVersion95,
|
||||
pgVersion96)
|
||||
import PostgREST.Types (PgVersion, pgVersion100, pgVersion109,
|
||||
pgVersion110, pgVersion112, pgVersion114,
|
||||
pgVersion95)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith Application
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion =
|
||||
describe "remote procedure call" $ do
|
||||
context "a proc that returns a set" $ do
|
||||
@@ -33,6 +34,12 @@ spec actualPgVersion =
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
request methodHead "/rpc/getitemrange?min=2&max=4"
|
||||
(rangeHdrs (ByteRangeFromTo 0 0)) ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
it "includes total count if requested" $ do
|
||||
request methodPost "/rpc/getitemrange"
|
||||
@@ -48,6 +55,12 @@ spec actualPgVersion =
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
||||
}
|
||||
request methodHead "/rpc/getitemrange?min=2&max=4"
|
||||
(rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
||||
}
|
||||
|
||||
it "returns proper json" $ do
|
||||
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
|
||||
@@ -71,6 +84,12 @@ spec actualPgVersion =
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||
}
|
||||
request methodHead "/rpc/getitemrange?min=2&max=4"
|
||||
(acceptHdrs "text/csv") ""
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "unknown function" $ do
|
||||
it "returns 404" $
|
||||
@@ -139,10 +158,10 @@ spec actualPgVersion =
|
||||
|
||||
context "foreign entities embedding" $ do
|
||||
it "can embed if related tables are in the exposed schema" $ do
|
||||
post "/rpc/getproject?select=id,name,client(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
|
||||
post "/rpc/getproject?select=id,name,client:clients(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/getproject?id=1&select=id,name,client(id),tasks(id)" `shouldRespondWith`
|
||||
get "/rpc/getproject?id=1&select=id,name,client:clients(id),tasks(id)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
@@ -160,6 +179,29 @@ spec actualPgVersion =
|
||||
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed an M2M relationship table" $
|
||||
get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"
|
||||
`shouldRespondWith` [json|[
|
||||
{"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},
|
||||
{"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed an M2M relationship table that has a parent relationship table" $
|
||||
get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"
|
||||
`shouldRespondWith` [json|[
|
||||
{"name":"Michael Scott","tasks":[
|
||||
{"name":"Design IOS","project":{"name":"IOS"}},
|
||||
{"name":"Code IOS","project":{"name":"IOS"}},
|
||||
{"name":"Design OSX","project":{"name":"OSX"}}
|
||||
]},
|
||||
{"name":"Dwight Schrute","tasks":[
|
||||
{"name":"Design w7","project":{"name":"Windows 7"}},
|
||||
{"name":"Design IOS","project":{"name":"IOS"}}
|
||||
]}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "a proc that returns an empty rowset" $
|
||||
it "returns empty json array" $ do
|
||||
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
|
||||
@@ -256,38 +298,23 @@ spec actualPgVersion =
|
||||
[json|"object"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
when (actualPgVersion <= pgVersion96) $
|
||||
it "parses quoted JSON arguments as JSON (Postgres <= 9.6)" $
|
||||
when (actualPgVersion < pgVersion100) $
|
||||
it "parses quoted JSON arguments as JSON (Postgres < 10)" $
|
||||
post "/rpc/json_argument"
|
||||
[json| { "arg": "{ \"key\": 3 }" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"object"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
when (actualPgVersion >= pgVersion100) $ do
|
||||
it "parses quoted JSON arguments as JSON string (Postgres >= 10)" $ do
|
||||
-- Postgres bug report:
|
||||
-- https://www.postgresql.org/message-id/D6921B37-BD8E-4664-8D5F-DB3525765DCD%40vllmrt.net
|
||||
-- * json_to_record fails (see following test)
|
||||
-- * jsonb_to_record parses the embedded quoted JSON to a JSON string,
|
||||
-- so that's probably the expected behavior for Postgres >= 10
|
||||
pendingWith "Postgres >= 10 fails to parse quoted embedded JSON"
|
||||
when ((actualPgVersion >= pgVersion109 && actualPgVersion < pgVersion110)
|
||||
|| actualPgVersion >= pgVersion114) $
|
||||
it "parses quoted JSON arguments as JSON string (from Postgres 10.9, 11.4)" $
|
||||
post "/rpc/json_argument"
|
||||
[json| { "arg": "{ \"key\": 3 }" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"string"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails to parse quoted JSON arguments (Postgres >= 10)" $
|
||||
-- Confirming buggy Postgres behavior (see previous test)
|
||||
post "/rpc/json_argument"
|
||||
[json| { "arg": "{ \"key\": 3 }" } |]
|
||||
`shouldRespondWith`
|
||||
[json|{"hint":null,"details":"Token \"key\" is invalid.","code":"22P02","message":"invalid input syntax for type json"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "improper input" $ do
|
||||
it "rejects unknown content type even if payload is good" $ do
|
||||
request methodPost "/rpc/sayhello"
|
||||
@@ -429,24 +456,34 @@ spec actualPgVersion =
|
||||
|
||||
it "ignores json keys not included in ?columns" $
|
||||
post "/rpc/sayhello?columns=name"
|
||||
[json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|] `shouldRespondWith`
|
||||
[json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|]
|
||||
`shouldRespondWith`
|
||||
[json|"Hello, John"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "bulk RPC" $ do
|
||||
it "works with a scalar function an returns a json array" $
|
||||
it "only takes the first object in case of array of objects payload" $
|
||||
post "/rpc/add_them"
|
||||
[json|[
|
||||
{"a": 1, "b": 2},
|
||||
{"a": 4, "b": 6},
|
||||
{"a": 100, "b": 200}
|
||||
]|] `shouldRespondWith`
|
||||
{"a": 100, "b": 200} ]|]
|
||||
`shouldRespondWith` "3"
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "bulk RPC with params=multiple-objects" $ do
|
||||
it "works with a scalar function an returns a json array" $
|
||||
request methodPost "/rpc/add_them" [("Prefer", "params=multiple-objects")]
|
||||
[json|[
|
||||
{"a": 1, "b": 2},
|
||||
{"a": 4, "b": 6},
|
||||
{"a": 100, "b": 200} ]|]
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[3, 10, 300]
|
||||
|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "works with a scalar function an returns a json array when posting CSV" $
|
||||
request methodPost "/rpc/add_them" [("Content-Type", "text/csv")]
|
||||
request methodPost "/rpc/add_them" [("Content-Type", "text/csv"), ("Prefer", "params=multiple-objects")]
|
||||
"a,b\n1,2\n4,6\n100,200"
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
@@ -457,11 +494,11 @@ spec actualPgVersion =
|
||||
}
|
||||
|
||||
it "works with a non-scalar result" $
|
||||
post "/rpc/get_projects_below?select=id,name"
|
||||
request methodPost "/rpc/get_projects_below?select=id,name" [("Prefer", "params=multiple-objects")]
|
||||
[json|[
|
||||
{"id": 1},
|
||||
{"id": 5}
|
||||
]|] `shouldRespondWith`
|
||||
{"id": 5} ]|]
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"name":"Windows 7"},
|
||||
{"id":2,"name":"Windows 10"},
|
||||
@@ -469,6 +506,82 @@ spec actualPgVersion =
|
||||
{"id":4,"name":"OSX"}]
|
||||
|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "HTTP request env vars" $ do
|
||||
it "custom header is set" $
|
||||
request methodPost "/rpc/get_guc_value"
|
||||
[("Custom-Header", "test")]
|
||||
[json| { "name": "request.header.custom-header" } |]
|
||||
`shouldRespondWith`
|
||||
[str|"test"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
it "standard header is set" $
|
||||
request methodPost "/rpc/get_guc_value"
|
||||
[("Origin", "http://example.com")]
|
||||
[json| { "name": "request.header.origin" } |]
|
||||
`shouldRespondWith`
|
||||
[str|"http://example.com"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
it "current role is available as GUC claim" $
|
||||
request methodPost "/rpc/get_guc_value" []
|
||||
[json| { "name": "request.jwt.claim.role" } |]
|
||||
`shouldRespondWith`
|
||||
[str|"postgrest_test_anonymous"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
it "single cookie ends up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
|
||||
[json| {"name":"request.cookie.acookie"} |]
|
||||
`shouldRespondWith`
|
||||
[str|"cookievalue"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "multiple cookies ends up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
|
||||
[json| {"name":"request.cookie.secondcookie"} |]
|
||||
`shouldRespondWith`
|
||||
[str|"anothervalue"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "app settings available" $
|
||||
request methodPost "/rpc/get_guc_value" []
|
||||
[json| { "name": "app.settings.app_host" } |]
|
||||
`shouldRespondWith`
|
||||
[str|"localhost"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
it "gets the Authorization value" $
|
||||
request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
|
||||
[json| {"name":"request.header.authorization"} |]
|
||||
`shouldRespondWith`
|
||||
[str|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "gets the http method" $
|
||||
request methodPost "/rpc/get_guc_value" []
|
||||
[json| {"name":"request.method"} |]
|
||||
`shouldRespondWith`
|
||||
[str|"POST"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "gets the http path" $
|
||||
request methodPost "/rpc/get_guc_value" []
|
||||
[json| {"name":"request.path"} |]
|
||||
`shouldRespondWith`
|
||||
[str|"/rpc/get_guc_value"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
context "binary output" $ do
|
||||
context "Proc that returns scalar" $ do
|
||||
it "can query without selecting column" $
|
||||
@@ -478,23 +591,6 @@ spec actualPgVersion =
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "can get raw output with Accept: text/html" $
|
||||
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
|
||||
`shouldRespondWith`
|
||||
[str|
|
||||
|<html>
|
||||
| <head>
|
||||
| <title>PostgREST</title>
|
||||
| </head>
|
||||
| <body>
|
||||
| <h1>Welcome to PostgREST</h1>
|
||||
| </body>
|
||||
|</html>
|
||||
|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/html; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "can get raw output with Accept: text/plain" $
|
||||
request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
|
||||
`shouldRespondWith` "Welcome to PostgREST"
|
||||
@@ -513,7 +609,7 @@ spec actualPgVersion =
|
||||
it "fails if a single column is not selected" $
|
||||
request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") ""
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"application/octet-stream requested but a single column was not selected"} |]
|
||||
[json| {"message":"application/octet-stream requested but more than one column was selected"} |]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
@@ -546,6 +642,10 @@ spec actualPgVersion =
|
||||
get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`
|
||||
[json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
when (actualPgVersion >= pgVersion112) $
|
||||
get "/rpc/get_tsearch?text_search_vector=wfts.impossible" `shouldRespondWith`
|
||||
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "should work with an argument of custom type in public schema" $
|
||||
get "/rpc/test_arg?my_arg=something" `shouldRespondWith`
|
||||
|
||||
@@ -13,7 +13,7 @@ import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Requesting singular json object" $ do
|
||||
let pgrstObj = "application/vnd.pgrst.object+json"
|
||||
@@ -56,7 +56,7 @@ spec =
|
||||
_ <- post "/addresses" [json| { id: 98, address: "xxx" } |]
|
||||
_ <- post "/addresses" [json| { id: 99, address: "yyy" } |]
|
||||
p <- request methodPatch "/addresses?id=gt.0"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[singular]
|
||||
[json| { address: "zzz" } |]
|
||||
liftIO $ do
|
||||
simpleStatus p `shouldBe` notAcceptable406
|
||||
@@ -65,7 +65,29 @@ spec =
|
||||
-- the rows should not be updated, either
|
||||
get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|]
|
||||
|
||||
it "raises an error for multiple rows with return=rep" $ do
|
||||
_ <- post "/addresses" [json| { id: 100, address: "xxx" } |]
|
||||
_ <- post "/addresses" [json| { id: 101, address: "yyy" } |]
|
||||
p <- request methodPatch "/addresses?id=gt.0"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| { address: "zzz" } |]
|
||||
liftIO $ do
|
||||
simpleStatus p `shouldBe` notAcceptable406
|
||||
isErrorFormat (simpleBody p) `shouldBe` True
|
||||
|
||||
-- the rows should not be updated, either
|
||||
get "/addresses?id=eq.100" `shouldRespondWith` [str|[{"id":100,"address":"xxx"}]|]
|
||||
|
||||
it "raises an error for zero rows" $
|
||||
request methodPatch "/items?id=gt.0&id=lt.0"
|
||||
[singular] [json|{"id":1}|]
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "raises an error for zero rows with return=rep" $
|
||||
request methodPatch "/items?id=gt.0&id=lt.0"
|
||||
[("Prefer", "return=representation"), singular] [json|{"id":1}|]
|
||||
`shouldRespondWith`
|
||||
@@ -79,20 +101,20 @@ spec =
|
||||
p <- request methodPost
|
||||
"/addresses"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| [ { id: 100, address: "xxx" } ] |]
|
||||
liftIO $ simpleBody p `shouldBe` [str|{"id":100,"address":"xxx"}|]
|
||||
[json| [ { id: 102, address: "xxx" } ] |]
|
||||
liftIO $ simpleBody p `shouldBe` [str|{"id":102,"address":"xxx"}|]
|
||||
|
||||
it "works for one row even with return=minimal" $ do
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "return=minimal"), singular]
|
||||
[json| [ { id: 101, address: "xxx" } ] |]
|
||||
[json| [ { id: 103, address: "xxx" } ] |]
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
-- and the element should exist
|
||||
get "/addresses?id=eq.101"
|
||||
`shouldRespondWith` [str|[{"id":101,"address":"xxx"}]|]
|
||||
get "/addresses?id=eq.103"
|
||||
`shouldRespondWith` [str|[{"id":103,"address":"xxx"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
@@ -100,23 +122,47 @@ spec =
|
||||
it "raises an error when attempting to create multiple entities" $ do
|
||||
p <- request methodPost
|
||||
"/addresses"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[singular]
|
||||
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
||||
liftIO $ simpleStatus p `shouldBe` notAcceptable406
|
||||
|
||||
-- the rows should not exist, either
|
||||
get "/addresses?id=eq.200" `shouldRespondWith` "[]"
|
||||
|
||||
it "return=minimal allows request to create multiple elements" $
|
||||
it "raises an error when attempting to create multiple entities with return=rep" $ do
|
||||
p <- request methodPost
|
||||
"/addresses"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| [ { id: 202, address: "xxx" }, { id: 203, address: "yyy" } ] |]
|
||||
liftIO $ simpleStatus p `shouldBe` notAcceptable406
|
||||
|
||||
-- the rows should not exist, either
|
||||
get "/addresses?id=eq.202" `shouldRespondWith` "[]"
|
||||
|
||||
it "raises an error regardless of return=minimal" $ do
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "return=minimal"), singular]
|
||||
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
[("Prefer", "return=minimal"), singular]
|
||||
[json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
-- the rows should not exist, either
|
||||
get "/addresses?id=eq.204" `shouldRespondWith` "[]"
|
||||
|
||||
it "raises an error when creating zero entities" $
|
||||
request methodPost "/addresses"
|
||||
[singular]
|
||||
[json| [ ] |]
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "raises an error when creating zero entities with return=rep" $
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| [ ] |]
|
||||
@@ -134,18 +180,39 @@ spec =
|
||||
liftIO $ simpleBody p `shouldBe` [str|{"id":11}|]
|
||||
|
||||
it "raises an error when attempting to delete multiple entities" $ do
|
||||
let firstItems = "/items?id=gt.0&id=lt.11"
|
||||
let firstItems = "/items?id=gt.0&id=lt.6"
|
||||
request methodDelete firstItems
|
||||
[singular] ""
|
||||
`shouldRespondWith` 406
|
||||
|
||||
get firstItems
|
||||
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-4/*"]
|
||||
}
|
||||
|
||||
it "raises an error when attempting to delete multiple entities with return=rep" $ do
|
||||
let firstItems = "/items?id=gt.5&id=lt.11"
|
||||
request methodDelete firstItems
|
||||
[("Prefer", "return=representation"), singular] ""
|
||||
`shouldRespondWith` 406
|
||||
|
||||
get firstItems
|
||||
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
|
||||
`shouldRespondWith` [json| [{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-9/*"]
|
||||
, matchHeaders = ["Content-Range" <:> "0-4/*"]
|
||||
}
|
||||
|
||||
it "raises an error when deleting zero entities" $
|
||||
request methodDelete "/items?id=lt.0"
|
||||
[singular] ""
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "raises an error when deleting zero entities with return=rep" $
|
||||
request methodDelete "/items?id=lt.0"
|
||||
[("Prefer", "return=representation"), singular] ""
|
||||
`shouldRespondWith`
|
||||
|
||||
@@ -17,12 +17,14 @@ import PostgREST.Config (docsVersion)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = do
|
||||
|
||||
describe "OpenAPI" $ do
|
||||
it "root path returns a valid openapi spec" $
|
||||
it "root path returns a valid openapi spec" $ do
|
||||
validateOpenApiResponse [("Accept", "application/openapi+json")]
|
||||
request methodHead "/" (acceptHdrs "application/openapi+json") ""
|
||||
`shouldRespondWith` "" { matchStatus = 200 }
|
||||
|
||||
it "should respond to openapi request on none root path with 415" $
|
||||
request methodGet "/items"
|
||||
@@ -45,6 +47,7 @@ spec = do
|
||||
childGetSummary = r ^? method "get" . key "summary"
|
||||
childGetDescription = r ^? method "get" . key "description"
|
||||
getParameters = r ^? method "get" . key "parameters"
|
||||
postParameters = r ^? method "post" . key "parameters"
|
||||
postResponse = r ^? method "post" . key "responses" . key "201" . key "description"
|
||||
patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"
|
||||
deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"
|
||||
@@ -79,6 +82,15 @@ spec = do
|
||||
]
|
||||
|]
|
||||
|
||||
postParameters `shouldBe` Just
|
||||
[aesonQQ|
|
||||
[
|
||||
{ "$ref": "#/parameters/body.child_entities" },
|
||||
{ "$ref": "#/parameters/select" },
|
||||
{ "$ref": "#/parameters/preferReturn" }
|
||||
]
|
||||
|]
|
||||
|
||||
postResponse `shouldBe` Just "Created"
|
||||
|
||||
patchResponse `shouldBe` Just "No Content"
|
||||
@@ -125,7 +137,7 @@ spec = do
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"description": "child_entities name comment",
|
||||
"description": "child_entities name comment. Can be longer than sixty-three characters long",
|
||||
"format": "text",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -219,6 +231,23 @@ spec = do
|
||||
]
|
||||
|]
|
||||
|
||||
describe "VIEW that has a source FK based on a UNIQUE key" $
|
||||
|
||||
it "includes fk description" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let referralLink = r ^? key "definitions" . key "referrals" . key "properties" . key "link"
|
||||
|
||||
liftIO $
|
||||
referralLink `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "integer",
|
||||
"type": "integer",
|
||||
"description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' column='link'/>"
|
||||
}
|
||||
|]
|
||||
|
||||
describe "PostgreSQL to Swagger Type Mapping" $ do
|
||||
|
||||
it "character varying to string" $ do
|
||||
|
||||
@@ -10,7 +10,7 @@ import Test.Hspec.Wai.JSON
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Reading and writing to unicode schema and table names" $
|
||||
it "Can read and write values" $ do
|
||||
|
||||
@@ -11,7 +11,7 @@ import Text.Heredoc
|
||||
import Protolude hiding (get, put)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "UPSERT" $ do
|
||||
context "with POST" $ do
|
||||
@@ -49,6 +49,31 @@ spec =
|
||||
[json|[]|] `shouldRespondWith`
|
||||
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "INSERTs and UPDATEs rows on single unique key conflict" $
|
||||
request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json| [
|
||||
{ "unique_key": 1, "value": "B" },
|
||||
{ "unique_key": 2, "value": "C" }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "unique_key": 1, "value": "B" },
|
||||
{ "unique_key": 2, "value": "C" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "INSERTs and UPDATEs rows on compound unique keys conflict" $
|
||||
request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json| [
|
||||
{ "key1": 1, "key2": 1, "value": "B" },
|
||||
{ "key1": 1, "key2": 2, "value": "C" }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "key1": 1, "key2": 1, "value": "B" },
|
||||
{ "key1": 1, "key2": 2, "value": "C" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "when Prefer: resolution=ignore-duplicates is specified" $ do
|
||||
it "INSERTs and ignores rows on pk conflict" $
|
||||
@@ -75,6 +100,32 @@ spec =
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "INSERTs and ignores rows on single unique key conflict" $
|
||||
request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json| [
|
||||
{ "unique_key": 1, "value": "B" },
|
||||
{ "unique_key": 2, "value": "C" },
|
||||
{ "unique_key": 3, "value": "D" }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "unique_key": 3, "value": "D" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "INSERTs and UPDATEs rows on compound unique keys conflict" $
|
||||
request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json| [
|
||||
{ "key1": 1, "key2": 1, "value": "B" },
|
||||
{ "key1": 1, "key2": 2, "value": "C" },
|
||||
{ "key1": 1, "key2": 3, "value": "D" }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "key1": 1, "key2": 3, "value": "D" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "succeeds if the table has only PK cols and no other cols" $ do
|
||||
request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]
|
||||
|
||||
+90
-50
@@ -6,16 +6,17 @@ import qualified Hasql.Transaction.Sessions as HT
|
||||
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
|
||||
updateAction)
|
||||
import Data.Function (id)
|
||||
import Data.List.NonEmpty (toList)
|
||||
import Data.Time.Clock (getCurrentTime)
|
||||
|
||||
import Data.IORef
|
||||
import Test.Hspec
|
||||
|
||||
import PostgREST.App (postgrest)
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.DbStructure (getDbStructure, getPgVersion)
|
||||
import PostgREST.Types (DbStructure (..), pgVersion95,
|
||||
pgVersion96)
|
||||
import Protolude
|
||||
import PostgREST.Types (pgVersion95, pgVersion96)
|
||||
import Protolude hiding (toList)
|
||||
import SpecHelper
|
||||
|
||||
import qualified Feature.AndOrParamsSpec
|
||||
@@ -26,9 +27,12 @@ import qualified Feature.BinaryJwtSecretSpec
|
||||
import qualified Feature.ConcurrentSpec
|
||||
import qualified Feature.CorsSpec
|
||||
import qualified Feature.DeleteSpec
|
||||
import qualified Feature.EmbedDisambiguationSpec
|
||||
import qualified Feature.ExtraSearchPathSpec
|
||||
import qualified Feature.HtmlRawOutputSpec
|
||||
import qualified Feature.InsertSpec
|
||||
import qualified Feature.JsonOperatorSpec
|
||||
import qualified Feature.MultipleSchemaSpec
|
||||
import qualified Feature.NoJwtSpec
|
||||
import qualified Feature.NonexistentSchemaSpec
|
||||
import qualified Feature.PgVersion95Spec
|
||||
@@ -37,6 +41,7 @@ import qualified Feature.ProxySpec
|
||||
import qualified Feature.QueryLimitedSpec
|
||||
import qualified Feature.QuerySpec
|
||||
import qualified Feature.RangeSpec
|
||||
import qualified Feature.RawOutputTypesSpec
|
||||
import qualified Feature.RootSpec
|
||||
import qualified Feature.RpcSpec
|
||||
import qualified Feature.SingularSpec
|
||||
@@ -47,102 +52,137 @@ import qualified Feature.UpsertSpec
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
|
||||
|
||||
testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"
|
||||
setupDb testDbConn
|
||||
|
||||
pool <- P.acquire (3, 10, toS testDbConn)
|
||||
|
||||
result <- P.use pool $ do
|
||||
ver <- getPgVersion
|
||||
HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver
|
||||
actualPgVersion <- either (panic.show) id <$> P.use pool getPgVersion
|
||||
|
||||
let dbStructure = either (panic.show) id result
|
||||
refDbStructure <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ testCfg testDbConn) actualPgVersion
|
||||
|
||||
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
|
||||
let
|
||||
-- For tests that run with the same refDbStructure
|
||||
app cfg = return ((), postgrest (cfg testDbConn) refDbStructure pool getTime $ pure ())
|
||||
|
||||
refDbStructure <- newIORef $ Just dbStructure
|
||||
-- For tests that run with a different DbStructure(depends on configSchemas)
|
||||
appDbs cfg = do
|
||||
dbs <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ cfg testDbConn) actualPgVersion
|
||||
return ((), postgrest (cfg testDbConn) dbs pool getTime $ pure ())
|
||||
|
||||
let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime $ pure ()
|
||||
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime $ pure ()
|
||||
audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool getTime $ pure ()
|
||||
asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool getTime $ pure ()
|
||||
asymJwkSetApp = return $ postgrest (testCfgAsymJWKSet testDbConn) refDbStructure pool getTime $ pure ()
|
||||
nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||
extraSearchPathApp = return $ postgrest (testCfgExtraSearchPath testDbConn) refDbStructure pool getTime $ pure ()
|
||||
rootSpecApp = return $ postgrest (testCfgRootSpec testDbConn) refDbStructure pool getTime $ pure ()
|
||||
let withApp = app testCfg
|
||||
maxRowsApp = app testMaxRowsCfg
|
||||
proxyApp = app testProxyCfg
|
||||
noJwtApp = app testCfgNoJWT
|
||||
binaryJwtApp = app testCfgBinaryJWT
|
||||
audJwtApp = app testCfgAudienceJWT
|
||||
asymJwkApp = app testCfgAsymJWK
|
||||
asymJwkSetApp = app testCfgAsymJWKSet
|
||||
extraSearchPathApp = app testCfgExtraSearchPath
|
||||
rootSpecApp = app testCfgRootSpec
|
||||
htmlRawOutputApp = app testCfgHtmlRawOutput
|
||||
responseHeadersApp = app testCfgResponseHeaders
|
||||
|
||||
let reset :: IO ()
|
||||
unicodeApp = appDbs testUnicodeCfg
|
||||
nonexistentSchemaApp = appDbs testNonexistentSchemaCfg
|
||||
multipleSchemaApp = appDbs testMultipleSchemaCfg
|
||||
|
||||
let reset, analyze :: IO ()
|
||||
reset = resetDb testDbConn
|
||||
analyze = do
|
||||
analyzeTable testDbConn "items"
|
||||
analyzeTable testDbConn "child_entities"
|
||||
|
||||
actualPgVersion = pgVersion dbStructure
|
||||
extraSpecs =
|
||||
[("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
|
||||
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95] ++
|
||||
[("Feature.PgVersion96Spec", Feature.PgVersion96Spec.spec) | actualPgVersion >= pgVersion96]
|
||||
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95]
|
||||
|
||||
specs = uncurry describe <$> [
|
||||
("Feature.AuthSpec" , Feature.AuthSpec.spec actualPgVersion)
|
||||
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
|
||||
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
|
||||
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
|
||||
, ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)
|
||||
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
|
||||
, ("Feature.QuerySpec" , Feature.QuerySpec.spec)
|
||||
, ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion)
|
||||
, ("Feature.RangeSpec" , Feature.RangeSpec.spec)
|
||||
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
|
||||
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
|
||||
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)
|
||||
("Feature.AuthSpec" , Feature.AuthSpec.spec actualPgVersion)
|
||||
, ("Feature.RawOutputTypesSpec" , Feature.RawOutputTypesSpec.spec)
|
||||
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
|
||||
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
|
||||
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
|
||||
, ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion)
|
||||
, ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)
|
||||
, ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion)
|
||||
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
|
||||
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec actualPgVersion)
|
||||
] ++ extraSpecs
|
||||
|
||||
mutSpecs = uncurry describe <$> [
|
||||
("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
|
||||
, ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)
|
||||
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
|
||||
]
|
||||
|
||||
hspec $ do
|
||||
mapM_ (beforeAll_ reset . before withApp) specs
|
||||
-- Only certain Specs need a database reset, this should be used with care as it slows down the whole test suite.
|
||||
mapM_ (afterAll_ reset . before withApp) mutSpecs
|
||||
|
||||
mapM_ (before withApp) specs
|
||||
|
||||
-- we analyze to get accurate results from EXPLAIN
|
||||
beforeAll_ analyze . before withApp $
|
||||
describe "Feature.RangeSpec" Feature.RangeSpec.spec
|
||||
|
||||
-- this test runs with a raw-output-media-types set to text/html
|
||||
before htmlRawOutputApp $
|
||||
describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec
|
||||
|
||||
-- this test runs with a different server flag
|
||||
beforeAll_ reset . before ltdApp $
|
||||
before maxRowsApp $
|
||||
describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec
|
||||
|
||||
-- this test runs with a different schema
|
||||
beforeAll_ reset . before unicodeApp $
|
||||
before unicodeApp $
|
||||
describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec
|
||||
|
||||
-- this test runs with a proxy
|
||||
beforeAll_ reset . before proxyApp $
|
||||
before proxyApp $
|
||||
describe "Feature.ProxySpec" Feature.ProxySpec.spec
|
||||
|
||||
-- this test runs without a JWT secret
|
||||
beforeAll_ reset . before noJwtApp $
|
||||
before noJwtApp $
|
||||
describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec
|
||||
|
||||
-- this test runs with a binary JWT secret
|
||||
beforeAll_ reset . before binaryJwtApp $
|
||||
before binaryJwtApp $
|
||||
describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec
|
||||
|
||||
-- this test runs with a binary JWT secret and an audience claim
|
||||
beforeAll_ reset . before audJwtApp $
|
||||
before audJwtApp $
|
||||
describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec
|
||||
|
||||
-- this test runs with asymmetric JWK
|
||||
beforeAll_ reset . before asymJwkApp $
|
||||
before asymJwkApp $
|
||||
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
|
||||
|
||||
-- this test runs with asymmetric JWKSet
|
||||
beforeAll_ reset . before asymJwkSetApp $
|
||||
before asymJwkSetApp $
|
||||
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
|
||||
|
||||
-- this test runs with a nonexistent db-schema
|
||||
beforeAll_ reset . before nonexistentSchemaApp $
|
||||
before nonexistentSchemaApp $
|
||||
describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
|
||||
|
||||
-- this test runs with an extra search path
|
||||
beforeAll_ reset . before extraSearchPathApp $
|
||||
before extraSearchPathApp $
|
||||
describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec
|
||||
|
||||
-- this test runs with a root spec function override
|
||||
when (actualPgVersion >= pgVersion96) $
|
||||
beforeAll_ reset . before rootSpecApp $
|
||||
when (actualPgVersion >= pgVersion96) $ do
|
||||
before rootSpecApp $
|
||||
describe "Feature.RootSpec" Feature.RootSpec.spec
|
||||
before responseHeadersApp $
|
||||
describe "Feature.PgVersion96Spec" Feature.PgVersion96Spec.spec
|
||||
|
||||
-- this test runs with multiple schemas
|
||||
before multipleSchemaApp $
|
||||
describe "Feature.MultipleSchemaSpec" $ Feature.MultipleSchemaSpec.spec actualPgVersion
|
||||
|
||||
where
|
||||
setupDbStructure pool schemas ver =
|
||||
either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ getDbStructure (toList schemas) ver)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
module Main where
|
||||
|
||||
import Control.Lens ((^?))
|
||||
import qualified Data.Aeson.Lens as L
|
||||
import qualified Hasql.Decoders as HD
|
||||
import qualified Hasql.Encoders as HE
|
||||
import qualified Hasql.Pool as P
|
||||
import qualified Hasql.Statement as H
|
||||
import qualified Hasql.Transaction as HT
|
||||
import qualified Hasql.Transaction.Sessions as HT
|
||||
import Text.Heredoc
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
import PostgREST.QueryBuilder (requestToCallProcQuery)
|
||||
import PostgREST.Types
|
||||
|
||||
import SpecHelper (getEnvVarWithDefault)
|
||||
|
||||
import Test.Hspec
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"
|
||||
-- To speed things up, assume setupDb has ben ran in the previous spec.
|
||||
pool <- P.acquire (3, 10, toS testDbConn)
|
||||
|
||||
hspec $ describe "QueryCost" $
|
||||
context "call proc query" $ do
|
||||
it "should not exceed cost when calling setof composite proc" $ do
|
||||
cost <- exec pool [str| {"id": 3} |] $
|
||||
requestToCallProcQuery (QualifiedIdentifier "test" "get_projects_below") [PgArg "id" "int" True] False Nothing
|
||||
liftIO $
|
||||
cost `shouldSatisfy` (< Just 40)
|
||||
|
||||
it "should not exceed cost when calling setof composite proc with empty params" $ do
|
||||
cost <- exec pool mempty $
|
||||
requestToCallProcQuery (QualifiedIdentifier "test" "getallprojects") [] False Nothing
|
||||
liftIO $
|
||||
cost `shouldSatisfy` (< Just 30)
|
||||
|
||||
it "should not exceed cost when calling scalar proc" $ do
|
||||
cost <- exec pool [str| {"a": 3, "b": 4} |] $
|
||||
requestToCallProcQuery (QualifiedIdentifier "test" "add_them") [PgArg "a" "int" True, PgArg "b" "int" True] True Nothing
|
||||
liftIO $
|
||||
cost `shouldSatisfy` (< Just 10)
|
||||
|
||||
context "params=multiple-objects" $ do
|
||||
it "should not exceed cost when calling setof composite proc" $ do
|
||||
cost <- exec pool [str| [{"id": 1}, {"id": 4}] |] $
|
||||
requestToCallProcQuery (QualifiedIdentifier "test" "get_projects_below") [PgArg "id" "int" True] False (Just MultipleObjects)
|
||||
liftIO $ do
|
||||
cost `shouldSatisfy` (> Just 2000)
|
||||
cost `shouldSatisfy` (< Just 2100)
|
||||
|
||||
it "should not exceed cost when calling scalar proc" $ do
|
||||
cost <- exec pool [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |] $
|
||||
requestToCallProcQuery (QualifiedIdentifier "test" "add_them") [PgArg "a" "int" True, PgArg "b" "int" True] True Nothing
|
||||
liftIO $
|
||||
cost `shouldSatisfy` (< Just 10)
|
||||
|
||||
|
||||
exec :: P.Pool -> ByteString -> SqlQuery -> IO (Maybe Int64)
|
||||
exec pool input query =
|
||||
join . rightToMaybe <$>
|
||||
P.use pool (HT.transaction HT.ReadCommitted HT.Read $ HT.statement input $ explainCost query)
|
||||
|
||||
explainCost :: SqlQuery -> H.Statement ByteString (Maybe Int64)
|
||||
explainCost query =
|
||||
H.Statement (encodeUtf8 sql) (HE.param $ HE.nonNullable HE.unknown) decodeExplain False
|
||||
where
|
||||
sql = "EXPLAIN (FORMAT JSON) " <> query
|
||||
decodeExplain :: HD.Result (Maybe Int64)
|
||||
decodeExplain =
|
||||
let row = HD.singleRow $ HD.column $ HD.nonNullable HD.bytea in
|
||||
(^? L.nth 0 . L.key "Plan" . L.key "Total Cost" . L._Integral) <$> row
|
||||
+34
-10
@@ -11,6 +11,7 @@ import Control.Monad (void)
|
||||
import Data.Aeson (Value (..), decode, encode)
|
||||
import Data.CaseInsensitive (CI (..))
|
||||
import Data.List (lookup)
|
||||
import Data.List.NonEmpty (fromList)
|
||||
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
|
||||
import System.Environment (getEnv)
|
||||
import System.Process (readProcess)
|
||||
@@ -23,7 +24,7 @@ import Test.Hspec.Wai
|
||||
import Text.Heredoc
|
||||
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.Types (JSPathExp (..), QualifiedIdentifier (..))
|
||||
import PostgREST.Types (JSPathExp (..))
|
||||
import Protolude
|
||||
|
||||
matchContentTypeJson :: MatchHeader
|
||||
@@ -32,7 +33,7 @@ matchContentTypeJson = "Content-Type" <:> "application/json; charset=utf-8"
|
||||
matchContentTypeSingular :: MatchHeader
|
||||
matchContentTypeSingular = "Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"
|
||||
|
||||
validateOpenApiResponse :: [Header] -> WaiSession ()
|
||||
validateOpenApiResponse :: [Header] -> WaiSession () ()
|
||||
validateOpenApiResponse headers = do
|
||||
r <- request methodGet "/" headers ""
|
||||
liftIO $
|
||||
@@ -63,9 +64,11 @@ getEnvVarWithDefault var def = toS <$>
|
||||
|
||||
_baseCfg :: AppConfig
|
||||
_baseCfg = -- Connection Settings
|
||||
AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000
|
||||
AppConfig mempty "postgrest_test_anonymous" Nothing (fromList ["test"]) "localhost" 3000
|
||||
-- No user configured Unix Socket
|
||||
Nothing
|
||||
-- No user configured Unix Socket file mode (defaults to 660)
|
||||
(Right 432)
|
||||
-- Jwt settings
|
||||
(Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False Nothing
|
||||
-- Connection Modifiers
|
||||
@@ -81,6 +84,8 @@ _baseCfg = -- Connection Settings
|
||||
[]
|
||||
-- No root spec override
|
||||
Nothing
|
||||
-- Raw output media types
|
||||
[]
|
||||
|
||||
testCfg :: Text -> AppConfig
|
||||
testCfg testDbConn = _baseCfg { configDatabase = testDbConn }
|
||||
@@ -89,13 +94,13 @@ testCfgNoJWT :: Text -> AppConfig
|
||||
testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }
|
||||
|
||||
testUnicodeCfg :: Text -> AppConfig
|
||||
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchema = "تست" }
|
||||
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["تست"] }
|
||||
|
||||
testLtdRowsCfg :: Text -> AppConfig
|
||||
testLtdRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
|
||||
testMaxRowsCfg :: Text -> AppConfig
|
||||
testMaxRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
|
||||
|
||||
testProxyCfg :: Text -> AppConfig
|
||||
testProxyCfg testDbConn = (testCfg testDbConn) { configProxyUri = Just "https://postgrest.com/openapi.json" }
|
||||
testProxyCfg testDbConn = (testCfg testDbConn) { configOpenAPIProxyUri = Just "https://postgrest.com/openapi.json" }
|
||||
|
||||
testCfgBinaryJWT :: Text -> AppConfig
|
||||
testCfgBinaryJWT testDbConn = (testCfg testDbConn) {
|
||||
@@ -123,13 +128,22 @@ testCfgAsymJWKSet testDbConn = (testCfg testDbConn) {
|
||||
}
|
||||
|
||||
testNonexistentSchemaCfg :: Text -> AppConfig
|
||||
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" }
|
||||
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["nonexistent"] }
|
||||
|
||||
testCfgExtraSearchPath :: Text -> AppConfig
|
||||
testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }
|
||||
|
||||
testCfgRootSpec :: Text -> AppConfig
|
||||
testCfgRootSpec testDbConn = (testCfg testDbConn) { configRootSpec = Just $ QualifiedIdentifier "test" "root"}
|
||||
testCfgRootSpec testDbConn = (testCfg testDbConn) { configRootSpec = Just "root"}
|
||||
|
||||
testCfgHtmlRawOutput :: Text -> AppConfig
|
||||
testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = ["text/html"] }
|
||||
|
||||
testCfgResponseHeaders :: Text -> AppConfig
|
||||
testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configReqCheck = Just "custom_headers" }
|
||||
|
||||
testMultipleSchemaCfg :: Text -> AppConfig
|
||||
testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["v1", "v2"] }
|
||||
|
||||
setupDb :: Text -> IO ()
|
||||
setupDb dbConn = do
|
||||
@@ -144,9 +158,13 @@ setupDb dbConn = do
|
||||
resetDb :: Text -> IO ()
|
||||
resetDb dbConn = loadFixture dbConn "data"
|
||||
|
||||
analyzeTable :: Text -> Text -> IO ()
|
||||
analyzeTable dbConn tableName =
|
||||
void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-a", "-c", toS $ "ANALYZE test.\"" <> tableName <> "\""] []
|
||||
|
||||
loadFixture :: Text -> FilePath -> IO()
|
||||
loadFixture dbConn name =
|
||||
void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-a", "-f", "test/fixtures/" ++ name ++ ".sql"] []
|
||||
void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-q", "-f", "test/fixtures/" ++ name ++ ".sql"] []
|
||||
|
||||
rangeHdrs :: ByteRange -> [Header]
|
||||
rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
|
||||
@@ -164,6 +182,12 @@ matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool
|
||||
matchHeader name valRegex headers =
|
||||
maybe False (=~ valRegex) $ lookup name headers
|
||||
|
||||
noBlankHeader :: [Header] -> Bool
|
||||
noBlankHeader = notElem mempty
|
||||
|
||||
noProfileHeader :: [Header] -> Bool
|
||||
noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers
|
||||
|
||||
authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header
|
||||
authHeaderBasic u p =
|
||||
(hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p))
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
version: "3.6"
|
||||
|
||||
services:
|
||||
test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test
|
||||
depends_on:
|
||||
- postgres
|
||||
working_dir: /root/postgrest
|
||||
volumes:
|
||||
- ../:/root/postgrest
|
||||
- stack-linux:/root/.stack
|
||||
- stack-work:/root/postgrest/.stack-work
|
||||
command: bash -c "POSTGREST_TEST_CONNECTION=$$(test/create_test_db 'postgres://postgres:postgres@postgres' test_db) stack test"
|
||||
|
||||
postgres:
|
||||
image: postgres:11
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver_opts:
|
||||
type: tmpfs
|
||||
device: tmpfs
|
||||
stack-linux:
|
||||
stack-work:
|
||||
Vendored
+82
@@ -344,6 +344,12 @@ INSERT INTO employees VALUES
|
||||
TRUNCATE TABLE tiobe_pls CASCADE;
|
||||
INSERT INTO tiobe_pls VALUES ('Java', 1), ('C', 2), ('Python', 4);
|
||||
|
||||
TRUNCATE TABLE single_unique CASCADE;
|
||||
INSERT INTO single_unique (unique_key, value) VALUES (1, 'A');
|
||||
|
||||
TRUNCATE TABLE compound_unique CASCADE;
|
||||
INSERT INTO compound_unique (key1, key2, value) VALUES (1, 1, 'A');
|
||||
|
||||
TRUNCATE TABLE only_pk CASCADE;
|
||||
INSERT INTO only_pk VALUES (1), (2);
|
||||
|
||||
@@ -499,3 +505,79 @@ COPY pgrst_reserved_chars ("*id*", ":arr->ow::cast", "(inside,parens)", "a.dotte
|
||||
2 | arrow-2 | parens-2 | dotted-2 | space-2
|
||||
3 | arrow-3 | parens-3 | dotted-3 | space-3
|
||||
\.
|
||||
|
||||
TRUNCATE TABLE web_content CASCADE;
|
||||
INSERT INTO web_content VALUES (5, 'wat', null);
|
||||
INSERT INTO web_content VALUES (0, 'tardis', 5);
|
||||
INSERT INTO web_content VALUES (1, 'fezz', 0);
|
||||
INSERT INTO web_content VALUES (2, 'foo', 0);
|
||||
INSERT INTO web_content VALUES (3, 'bar', 0);
|
||||
INSERT INTO web_content VALUES (4, 'wut', 1);
|
||||
|
||||
TRUNCATE TABLE app_users CASCADE;
|
||||
INSERT INTO app_users (id, email, "password") VALUES (1, 'test@123.com','pass');
|
||||
INSERT INTO app_users (id, email, "password") VALUES (2, 'abc@123.com','pass');
|
||||
INSERT INTO app_users (id, email, "password") VALUES (3, 'def@123.com','pass');
|
||||
|
||||
TRUNCATE TABLE private.pages CASCADE;
|
||||
INSERT INTO private.pages VALUES (1, 'http://postgrest.org/en/v6.0/api.html');
|
||||
INSERT INTO private.pages VALUES (2, 'http://postgrest.org/en/v6.0/admin.html');
|
||||
|
||||
TRUNCATE TABLE private.referrals CASCADE;
|
||||
INSERT INTO private.referrals VALUES ('github.com', 1);
|
||||
INSERT INTO private.referrals VALUES ('hub.docker.com', 2);
|
||||
|
||||
TRUNCATE TABLE big_projects CASCADE;
|
||||
INSERT INTO big_projects (big_project_id, name)
|
||||
VALUES (1, 'big project 1'),
|
||||
(2, 'big project 2');
|
||||
|
||||
TRUNCATE TABLE sites CASCADE;
|
||||
INSERT INTO sites (site_id, name, main_project_id)
|
||||
VALUES (1, 'site 1', 1),
|
||||
(2, 'site 2', null),
|
||||
(3, 'site 3', 2),
|
||||
(4, 'site 4', null);
|
||||
|
||||
TRUNCATE TABLE jobs CASCADE;
|
||||
INSERT INTO jobs (job_id, name, site_id, big_project_id)
|
||||
VALUES ('bc5d5362-b881-438f-b9f5-7417e08704ed', 'job 1-1', 1, 1),
|
||||
('3bd52697-033b-4edd-8a28-46a9c04b7c1e', 'job 2-1', 2, 1),
|
||||
('e6e67e4e-19b1-11e9-ab14-d663bd873d93', 'job 2-2', 2, 2);
|
||||
|
||||
TRUNCATE TABLE departments CASCADE;
|
||||
TRUNCATE TABLE agents CASCADE;
|
||||
INSERT INTO agents (id, name)
|
||||
VALUES (1, 'agent 1'),
|
||||
(2, 'agent 2'),
|
||||
(3, 'agent 3'),
|
||||
(4, 'agent 4');
|
||||
|
||||
INSERT INTO departments (id, name, head_id)
|
||||
VALUES (1, 'dep 1', 1),
|
||||
(2, 'dep 3', 3);
|
||||
|
||||
UPDATE agents SET department_id = 1 WHERE id in (1, 2);
|
||||
UPDATE agents SET department_id = 2 WHERE id in (3, 4);
|
||||
|
||||
TRUNCATE TABLE schedules CASCADE;
|
||||
INSERT INTO schedules VALUES(1, 'morning', '06:00:00', '11:59:00');
|
||||
INSERT INTO schedules VALUES(2, 'afternoon', '12:00:00', '17:59:00');
|
||||
INSERT INTO schedules VALUES(3, 'night', '18:00:00', '23:59:00');
|
||||
INSERT INTO schedules VALUES(4, 'early morning', '00:00:00', '05:59:00');
|
||||
|
||||
TRUNCATE TABLE activities CASCADE;
|
||||
INSERT INTO activities(id, schedule_id, car_id) VALUES(1, 1, 'CAR-349');
|
||||
INSERT INTO activities(id, schedule_id, camera_id) VALUES(2, 3, 'CAM-123');
|
||||
|
||||
TRUNCATE TABLE unit_workdays CASCADE;
|
||||
INSERT INTO unit_workdays VALUES(1, '2019-12-02', 1, 1, 2, 3);
|
||||
|
||||
TRUNCATE TABLE v1.parents CASCADE;
|
||||
INSERT INTO v1.parents VALUES(1, 'parent v1-1'), (2, 'parent v1-2');
|
||||
|
||||
TRUNCATE TABLE v2.parents CASCADE;
|
||||
INSERT INTO v2.parents VALUES(3, 'parent v2-3'), (4, 'parent v2-4');
|
||||
|
||||
TRUNCATE TABLE v2.another_table CASCADE;
|
||||
INSERT INTO v2.another_table VALUES(5, 'value 5'), (6, 'value 6');
|
||||
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
set client_min_messages to warning;
|
||||
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions CASCADE;
|
||||
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions, v1, v2 CASCADE;
|
||||
DROP TYPE IF EXISTS jwt_token CASCADE;
|
||||
|
||||
Vendored
+34
@@ -6,6 +6,8 @@ GRANT USAGE ON SCHEMA
|
||||
, public
|
||||
, "تست"
|
||||
, extensions
|
||||
, v1
|
||||
, v2
|
||||
TO postgrest_test_anonymous;
|
||||
|
||||
-- Schema test objects
|
||||
@@ -66,6 +68,8 @@ GRANT ALL ON TABLE
|
||||
, perf_articles
|
||||
, employees
|
||||
, tiobe_pls
|
||||
, single_unique
|
||||
, compound_unique
|
||||
, only_pk
|
||||
, family_tree
|
||||
, managers
|
||||
@@ -101,6 +105,30 @@ GRANT ALL ON TABLE
|
||||
, pgrst_reserved_chars
|
||||
, authors_w_entities
|
||||
, openapi_types
|
||||
, getallprojects_view
|
||||
, get_projects_above_view
|
||||
, web_content
|
||||
, pages
|
||||
, referrals
|
||||
, big_projects
|
||||
, sites
|
||||
, jobs
|
||||
, main_jobs
|
||||
, whatev_projects
|
||||
, whatev_sites
|
||||
, whatev_jobs
|
||||
, agents
|
||||
, departments
|
||||
, schedules
|
||||
, activities
|
||||
, unit_workdays
|
||||
, stuff
|
||||
, loc_test
|
||||
, v1.parents
|
||||
, v2.parents
|
||||
, v2.another_table
|
||||
, v1.childs
|
||||
, v2.childs
|
||||
TO postgrest_test_anonymous;
|
||||
|
||||
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
|
||||
@@ -110,6 +138,8 @@ GRANT USAGE ON SEQUENCE
|
||||
, items_id_seq
|
||||
, callcounter_count
|
||||
, leak_id_seq
|
||||
, v1.childs_id_seq
|
||||
, v2.childs_id_seq
|
||||
TO postgrest_test_anonymous;
|
||||
|
||||
-- Privileges for non anonymous users
|
||||
@@ -120,6 +150,10 @@ GRANT SELECT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_t
|
||||
GRANT INSERT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
||||
GRANT UPDATE (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
||||
|
||||
GRANT SELECT(id, email) ON TABLE app_users TO postgrest_test_anonymous;
|
||||
GRANT INSERT, UPDATE ON TABLE app_users TO postgrest_test_anonymous;
|
||||
GRANT DELETE ON TABLE app_users TO postgrest_test_anonymous;
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION privileged_hello(text) FROM PUBLIC; -- All functions are available to every role(PUBLIC) by default
|
||||
GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author;
|
||||
|
||||
|
||||
Vendored
+318
-311
@@ -18,6 +18,8 @@ CREATE SCHEMA private;
|
||||
CREATE SCHEMA test;
|
||||
CREATE SCHEMA تست;
|
||||
CREATE SCHEMA extensions;
|
||||
CREATE SCHEMA v1;
|
||||
CREATE SCHEMA v2;
|
||||
|
||||
--
|
||||
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
|
||||
@@ -103,15 +105,10 @@ SET default_tablespace = '';
|
||||
|
||||
SET default_with_oids = false;
|
||||
|
||||
--
|
||||
-- Name: items; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE items (
|
||||
id bigint NOT NULL
|
||||
id bigserial primary key
|
||||
);
|
||||
|
||||
|
||||
CREATE FUNCTION always_true(test.items) RETURNS boolean
|
||||
LANGUAGE sql STABLE
|
||||
AS $$ SELECT true $$;
|
||||
@@ -364,59 +361,8 @@ CREATE TABLE auth (
|
||||
pass character(60) NOT NULL
|
||||
);
|
||||
|
||||
|
||||
SET search_path = private, pg_catalog;
|
||||
|
||||
--
|
||||
-- Name: article_stars; Type: TABLE; Schema: private; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE article_stars (
|
||||
article_id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
created_at timestamp without time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: articles; Type: TABLE; Schema: private; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE articles (
|
||||
id integer NOT NULL,
|
||||
body text,
|
||||
owner name NOT NULL
|
||||
);
|
||||
|
||||
|
||||
SET search_path = test, pg_catalog;
|
||||
|
||||
CREATE VIEW limited_article_stars AS
|
||||
SELECT article_id, user_id, created_at FROM private.article_stars;
|
||||
|
||||
|
||||
--
|
||||
-- Name: articleStars; Type: VIEW; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
CREATE VIEW "articleStars" AS
|
||||
SELECT article_stars.article_id AS "articleId",
|
||||
article_stars.user_id AS "userId",
|
||||
article_stars.created_at AS "createdAt"
|
||||
FROM private.article_stars;
|
||||
|
||||
|
||||
--
|
||||
-- Name: articles; Type: VIEW; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
CREATE VIEW articles AS
|
||||
SELECT articles.id,
|
||||
articles.body,
|
||||
articles.owner
|
||||
FROM private.articles;
|
||||
|
||||
|
||||
--
|
||||
-- Name: authors_only; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
@@ -463,24 +409,10 @@ ALTER SEQUENCE auto_incrementing_pk_id_seq OWNED BY auto_incrementing_pk.id;
|
||||
--
|
||||
|
||||
CREATE TABLE clients (
|
||||
id integer NOT NULL,
|
||||
id integer primary key,
|
||||
name text NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: comments; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE comments (
|
||||
id integer NOT NULL,
|
||||
commenter_id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
task_id integer NOT NULL,
|
||||
content text NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: complex_items; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
@@ -574,26 +506,6 @@ CREATE VIEW insertable_view_with_join AS
|
||||
FROM (has_fk
|
||||
JOIN auto_incrementing_pk USING (id));
|
||||
|
||||
|
||||
--
|
||||
-- Name: items_id_seq; Type: SEQUENCE; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE items_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: items_id_seq; Type: SEQUENCE OWNED BY; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE items_id_seq OWNED BY items.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: json; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
@@ -663,11 +575,11 @@ CREATE TABLE insertonly (
|
||||
--
|
||||
|
||||
CREATE TABLE projects (
|
||||
id integer NOT NULL,
|
||||
id integer primary key,
|
||||
name text NOT NULL,
|
||||
client_id integer
|
||||
client_id integer REFERENCES clients(id)
|
||||
);
|
||||
|
||||
alter table projects rename constraint projects_client_id_fkey to client;
|
||||
|
||||
--
|
||||
-- Name: projects_view; Type: VIEW; Schema: test; Owner: -
|
||||
@@ -695,25 +607,23 @@ CREATE TABLE simple_pk (
|
||||
extra character varying NOT NULL
|
||||
);
|
||||
|
||||
--
|
||||
-- Name: users_projects; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
CREATE TABLE users (
|
||||
id integer primary key,
|
||||
name text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE users_projects (
|
||||
user_id integer NOT NULL,
|
||||
project_id integer NOT NULL
|
||||
user_id integer NOT NULL REFERENCES users(id),
|
||||
project_id integer NOT NULL REFERENCES projects(id),
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id integer NOT NULL,
|
||||
id integer primary key,
|
||||
name text NOT NULL,
|
||||
project_id integer
|
||||
project_id integer REFERENCES projects(id)
|
||||
);
|
||||
alter table tasks rename constraint tasks_project_id_fkey to project;
|
||||
|
||||
CREATE OR REPLACE VIEW filtered_tasks AS
|
||||
SELECT id AS "myId", name, project_id AS "projectID"
|
||||
@@ -725,6 +635,53 @@ project_id IN (
|
||||
SELECT project_id FROM users_projects WHERE user_id = 1
|
||||
);
|
||||
|
||||
CREATE TABLE users_tasks (
|
||||
user_id integer NOT NULL REFERENCES users(id),
|
||||
task_id integer NOT NULL REFERENCES tasks(id),
|
||||
primary key (task_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE comments (
|
||||
id integer primary key,
|
||||
commenter_id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
task_id integer NOT NULL,
|
||||
content text NOT NULL
|
||||
);
|
||||
alter table only comments
|
||||
add constraint "user" foreign key (commenter_id) references users(id),
|
||||
add constraint comments_task_id_fkey foreign key (task_id, user_id) references users_tasks(task_id, user_id);
|
||||
|
||||
create table private.articles (
|
||||
id integer primary key,
|
||||
body text,
|
||||
owner name not null
|
||||
);
|
||||
|
||||
create table private.article_stars (
|
||||
article_id integer not null,
|
||||
user_id integer not null,
|
||||
created_at timestamp without time zone default now() not null,
|
||||
primary key (article_id, user_id)
|
||||
);
|
||||
alter table only private.article_stars
|
||||
add constraint article foreign key (article_id) references private.articles(id),
|
||||
add constraint "user" foreign key (user_id) references test.users(id);
|
||||
|
||||
CREATE VIEW limited_article_stars AS
|
||||
SELECT article_id, user_id, created_at FROM private.article_stars;
|
||||
|
||||
CREATE VIEW "articleStars" AS
|
||||
SELECT article_stars.article_id AS "articleId",
|
||||
article_stars.user_id AS "userId",
|
||||
article_stars.created_at AS "createdAt"
|
||||
FROM private.article_stars;
|
||||
|
||||
CREATE VIEW articles AS
|
||||
SELECT articles.id,
|
||||
articles.body,
|
||||
articles.owner
|
||||
FROM private.articles;
|
||||
|
||||
--
|
||||
-- Name: tsearch; Type: TABLE; Schema: test; Owner: -
|
||||
@@ -734,28 +691,6 @@ CREATE TABLE tsearch (
|
||||
text_search_vector tsvector
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE users (
|
||||
id integer NOT NULL,
|
||||
name text NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_tasks; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE users_tasks (
|
||||
user_id integer NOT NULL,
|
||||
task_id integer NOT NULL
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE "Escap3e;" (
|
||||
"so6meIdColumn" integer primary key
|
||||
);
|
||||
@@ -773,7 +708,6 @@ CREATE TABLE clashing_column (
|
||||
t text
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: id; Type: DEFAULT; Schema: test; Owner: -
|
||||
--
|
||||
@@ -788,13 +722,6 @@ ALTER TABLE ONLY auto_incrementing_pk ALTER COLUMN id SET DEFAULT nextval('auto_
|
||||
ALTER TABLE ONLY has_fk ALTER COLUMN id SET DEFAULT nextval('has_fk_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: id; Type: DEFAULT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY items ALTER COLUMN id SET DEFAULT nextval('items_id_seq'::regclass);
|
||||
|
||||
|
||||
SET search_path = postgrest, pg_catalog;
|
||||
|
||||
--
|
||||
@@ -805,24 +732,6 @@ ALTER TABLE ONLY auth
|
||||
ADD CONSTRAINT auth_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
SET search_path = private, pg_catalog;
|
||||
|
||||
--
|
||||
-- Name: articles_pkey; Type: CONSTRAINT; Schema: private; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY articles
|
||||
ADD CONSTRAINT articles_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_article; Type: CONSTRAINT; Schema: private; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY article_stars
|
||||
ADD CONSTRAINT user_article PRIMARY KEY (article_id, user_id);
|
||||
|
||||
|
||||
SET search_path = test, pg_catalog;
|
||||
|
||||
--
|
||||
@@ -840,23 +749,6 @@ ALTER TABLE ONLY authors_only
|
||||
ALTER TABLE ONLY auto_incrementing_pk
|
||||
ADD CONSTRAINT auto_incrementing_pk_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: clients_pkey; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY clients
|
||||
ADD CONSTRAINT clients_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: comments_pkey; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY comments
|
||||
ADD CONSTRAINT comments_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: complex_items_pkey; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
@@ -888,15 +780,6 @@ ALTER TABLE ONLY simple_pk
|
||||
ALTER TABLE ONLY has_fk
|
||||
ADD CONSTRAINT has_fk_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: items_pkey; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY items
|
||||
ADD CONSTRAINT items_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: menagerie_pkey; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
@@ -905,46 +788,6 @@ ALTER TABLE ONLY menagerie
|
||||
ADD CONSTRAINT menagerie_pkey PRIMARY KEY ("integer");
|
||||
|
||||
|
||||
--
|
||||
-- Name: project_user; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY users_projects
|
||||
ADD CONSTRAINT project_user PRIMARY KEY (project_id, user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: projects_pkey; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY projects
|
||||
ADD CONSTRAINT projects_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: task_user; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY users_tasks
|
||||
ADD CONSTRAINT task_user PRIMARY KEY (task_id, user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks_pkey; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY tasks
|
||||
ADD CONSTRAINT tasks_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_pkey; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
SET search_path = postgrest, pg_catalog;
|
||||
|
||||
--
|
||||
@@ -971,43 +814,8 @@ SET search_path = test, pg_catalog;
|
||||
|
||||
CREATE TRIGGER secrets_owner_track BEFORE INSERT OR UPDATE ON authors_only FOR EACH ROW EXECUTE PROCEDURE postgrest.set_authors_only_owner();
|
||||
|
||||
|
||||
SET search_path = private, pg_catalog;
|
||||
|
||||
--
|
||||
-- Name: article_stars_article_id_fkey; Type: FK CONSTRAINT; Schema: private; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY article_stars
|
||||
ADD CONSTRAINT article_stars_article_id_fkey FOREIGN KEY (article_id) REFERENCES articles(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: article_stars_user_id_fkey; Type: FK CONSTRAINT; Schema: private; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY article_stars
|
||||
ADD CONSTRAINT article_stars_user_id_fkey FOREIGN KEY (user_id) REFERENCES test.users(id);
|
||||
|
||||
|
||||
SET search_path = test, pg_catalog;
|
||||
|
||||
--
|
||||
-- Name: comments_commenter_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY comments
|
||||
ADD CONSTRAINT comments_commenter_id_fkey FOREIGN KEY (commenter_id) REFERENCES users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: comments_task_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY comments
|
||||
ADD CONSTRAINT comments_task_id_fkey FOREIGN KEY (task_id, user_id) REFERENCES users_tasks(task_id, user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: has_fk_fk_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
@@ -1023,55 +831,6 @@ ALTER TABLE ONLY has_fk
|
||||
ALTER TABLE ONLY has_fk
|
||||
ADD CONSTRAINT has_fk_simple_fk_fkey FOREIGN KEY (simple_fk) REFERENCES simple_pk(k);
|
||||
|
||||
|
||||
--
|
||||
-- Name: projects_client_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY projects
|
||||
ADD CONSTRAINT projects_client_id_fkey FOREIGN KEY (client_id) REFERENCES clients(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks_project_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY tasks
|
||||
ADD CONSTRAINT tasks_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_projects_project_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY users_projects
|
||||
ADD CONSTRAINT users_projects_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_projects_user_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY users_projects
|
||||
ADD CONSTRAINT users_projects_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_tasks_task_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY users_tasks
|
||||
ADD CONSTRAINT users_tasks_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_tasks_user_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY users_tasks
|
||||
ADD CONSTRAINT users_tasks_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
|
||||
create table addresses (
|
||||
id int not null unique,
|
||||
address text not null
|
||||
@@ -1083,6 +842,8 @@ create table orders (
|
||||
billing_address_id int references addresses(id),
|
||||
shipping_address_id int references addresses(id)
|
||||
);
|
||||
alter table orders rename constraint orders_billing_address_id_fkey to billing;
|
||||
alter table orders rename constraint orders_shipping_address_id_fkey to shipping;
|
||||
|
||||
CREATE FUNCTION getproject(id int) RETURNS SETOF projects
|
||||
LANGUAGE sql
|
||||
@@ -1096,11 +857,17 @@ CREATE FUNCTION get_projects_below(id int) RETURNS SETOF projects
|
||||
SELECT * FROM test.projects WHERE id < $1;
|
||||
$_$;
|
||||
|
||||
CREATE FUNCTION get_projects_above(id int) RETURNS SETOF projects
|
||||
LANGUAGE sql
|
||||
AS $_$
|
||||
SELECT * FROM test.projects WHERE id > $1;
|
||||
$_$ ROWS 1;
|
||||
|
||||
CREATE FUNCTION getallprojects() RETURNS SETOF projects
|
||||
LANGUAGE sql
|
||||
AS $_$
|
||||
SELECT * FROM test.projects;
|
||||
$_$;
|
||||
$_$ ROWS 2019;
|
||||
|
||||
CREATE FUNCTION setprojects(id_l int, id_h int, name text) RETURNS SETOF projects
|
||||
LANGUAGE sql
|
||||
@@ -1226,7 +993,7 @@ create table ranges (
|
||||
|
||||
comment on table child_entities is 'child_entities comment';
|
||||
comment on column child_entities.id is 'child_entities id comment';
|
||||
comment on column child_entities.name is 'child_entities name comment';
|
||||
comment on column child_entities.name is 'child_entities name comment. Can be longer than sixty-three characters long';
|
||||
|
||||
comment on table grandchild_entities is
|
||||
$$grandchild_entities summary
|
||||
@@ -1373,6 +1140,18 @@ create table test.tiobe_pls(
|
||||
rank smallint
|
||||
);
|
||||
|
||||
create table test.single_unique(
|
||||
unique_key integer unique not null,
|
||||
value text
|
||||
);
|
||||
|
||||
create table test.compound_unique(
|
||||
key1 integer not null,
|
||||
key2 integer not null,
|
||||
value text,
|
||||
unique(key1, key2)
|
||||
);
|
||||
|
||||
create table test.family_tree (
|
||||
id text not null primary key,
|
||||
name text not null,
|
||||
@@ -1388,12 +1167,11 @@ create table test.managers (
|
||||
create table test.organizations (
|
||||
id integer primary key,
|
||||
name text,
|
||||
referee integer,
|
||||
auditor integer,
|
||||
referee integer references organizations(id),
|
||||
auditor integer references organizations(id),
|
||||
manager_id integer references managers(id)
|
||||
);
|
||||
alter table only test.organizations add constraint pptr1 foreign key (referee) references test.organizations(id);
|
||||
alter table only test.organizations add constraint pptr2 foreign key (auditor) references test.organizations(id);
|
||||
alter table only test.organizations rename constraint organizations_manager_id_fkey to manager;
|
||||
|
||||
create table private.authors(
|
||||
id integer primary key,
|
||||
@@ -1730,3 +1508,232 @@ select $$
|
||||
</html>
|
||||
$$::text;
|
||||
$_$ language sql;
|
||||
|
||||
create view getallprojects_view as
|
||||
select * from getallprojects();
|
||||
|
||||
create view get_projects_above_view as
|
||||
select * from get_projects_above(1);
|
||||
|
||||
CREATE TABLE web_content (
|
||||
id integer,
|
||||
name text,
|
||||
p_web_id integer references web_content(id),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
CREATE FUNCTION getallusers() RETURNS SETOF users AS $$
|
||||
SELECT * FROM test.users;
|
||||
$$ LANGUAGE sql;
|
||||
|
||||
create table app_users (
|
||||
id integer primary key,
|
||||
email text unique not null,
|
||||
password text not null
|
||||
);
|
||||
|
||||
create table private.pages (
|
||||
link int not null unique
|
||||
, url text
|
||||
);
|
||||
|
||||
create table private.referrals (
|
||||
site text
|
||||
, link int references private.pages(link) not null
|
||||
);
|
||||
|
||||
create view test.pages as select * from private.pages;
|
||||
|
||||
create view test.referrals as select * from private.referrals;
|
||||
|
||||
create table big_projects (
|
||||
big_project_id serial primary key,
|
||||
name text
|
||||
);
|
||||
|
||||
create table sites (
|
||||
site_id serial primary key
|
||||
, name text
|
||||
, main_project_id int null references big_projects (big_project_id)
|
||||
);
|
||||
alter table sites rename constraint sites_main_project_id_fkey to main_project;
|
||||
|
||||
create table jobs (
|
||||
job_id uuid primary key
|
||||
, name text
|
||||
, site_id int not null references sites (site_id)
|
||||
, big_project_id int not null references big_projects (big_project_id)
|
||||
);
|
||||
|
||||
create view main_jobs as
|
||||
select * from jobs
|
||||
where site_id in (select site_id from sites where main_project_id is not null);
|
||||
|
||||
-- junction in a private schema, just to make sure we don't leak it on resource embedding
|
||||
-- if it leaks it would show on the disambiguation error tests
|
||||
create view private.priv_jobs as
|
||||
select * from jobs;
|
||||
|
||||
-- tables to show our limitation when trying to do an m2m embed
|
||||
-- with a junction table that has more than two foreign keys
|
||||
create table whatev_projects (
|
||||
id serial primary key,
|
||||
name text
|
||||
);
|
||||
|
||||
create table whatev_sites (
|
||||
id serial primary key
|
||||
, name text
|
||||
);
|
||||
|
||||
create table whatev_jobs (
|
||||
job_id uuid primary key
|
||||
, name text
|
||||
, site_id_1 int not null references whatev_sites (id)
|
||||
, project_id_1 int not null references whatev_projects (id)
|
||||
, site_id_2 int not null references whatev_sites (id)
|
||||
, project_id_2 int not null references whatev_projects (id)
|
||||
);
|
||||
|
||||
-- circular reference
|
||||
create table agents (
|
||||
id int primary key
|
||||
, name text
|
||||
, department_id int
|
||||
);
|
||||
|
||||
create table departments (
|
||||
id int primary key
|
||||
, name text
|
||||
, head_id int references agents(id)
|
||||
);
|
||||
|
||||
ALTER TABLE agents
|
||||
ADD CONSTRAINT agents_department_id_fkey foreign key (department_id) REFERENCES departments(id);
|
||||
|
||||
-- composite key disambiguation
|
||||
create table schedules (
|
||||
id int primary key
|
||||
, name text
|
||||
, start_at timetz
|
||||
, end_at timetz
|
||||
);
|
||||
|
||||
create table activities (
|
||||
id int
|
||||
, schedule_id int
|
||||
, car_id text
|
||||
, camera_id text
|
||||
, primary key (id, schedule_id)
|
||||
);
|
||||
alter table activities
|
||||
add constraint schedule foreign key (schedule_id)
|
||||
references schedules (id);
|
||||
|
||||
create table unit_workdays (
|
||||
unit_id int
|
||||
, day date
|
||||
, fst_shift_activity_id int
|
||||
, fst_shift_schedule_id int
|
||||
, snd_shift_activity_id int
|
||||
, snd_shift_schedule_id int
|
||||
, primary key (unit_id, day)
|
||||
);
|
||||
alter table unit_workdays
|
||||
add constraint fst_shift foreign key (fst_shift_activity_id, fst_shift_schedule_id)
|
||||
references activities (id, schedule_id),
|
||||
add constraint snd_shift foreign key (snd_shift_activity_id, snd_shift_schedule_id)
|
||||
references activities (id, schedule_id);
|
||||
|
||||
-- for a pre-request function
|
||||
create or replace function custom_headers() returns void as $$
|
||||
declare
|
||||
user_agent text := current_setting('request.header.user-agent', true);
|
||||
req_path text := current_setting('request.path', true);
|
||||
req_accept text := current_setting('request.header.accept', true);
|
||||
begin
|
||||
if user_agent similar to 'MSIE (6.0|7.0)' then
|
||||
perform set_config('response.headers',
|
||||
'[{"Cache-Control": "no-cache, no-store, must-revalidate"}]', false);
|
||||
elsif req_path similar to '/(items|projects)' and req_accept = 'text/csv' then
|
||||
perform set_config('response.headers',
|
||||
format('[{"Content-Disposition": "attachment; filename=%s.csv"}]', trim('/' from req_path)), false);
|
||||
elsif req_path similar to '/(clients|rpc/getallprojects)' then
|
||||
perform set_config('response.headers',
|
||||
'[{"Content-Type": "application/geo+json"}]', false);
|
||||
else
|
||||
perform set_config('response.headers',
|
||||
'[{"X-Custom-Header": "mykey=myval"}]', false);
|
||||
end if;
|
||||
end; $$ language plpgsql;
|
||||
|
||||
create table private.stuff(
|
||||
id integer primary key
|
||||
, name text
|
||||
);
|
||||
|
||||
create view test.stuff as select * from private.stuff;
|
||||
|
||||
create or replace function location_for_stuff() returns trigger
|
||||
as $$
|
||||
begin
|
||||
insert into private.stuff values (new.id, new.name);
|
||||
if new.id is not null
|
||||
then
|
||||
perform set_config(
|
||||
'response.headers'
|
||||
, format('[{"Location": "/%s?id=eq.%s&overriden=true"}]', tg_table_name, new.id)
|
||||
, true
|
||||
);
|
||||
end if;
|
||||
return new;
|
||||
end
|
||||
$$ language plpgsql security definer;
|
||||
create trigger location_for_stuff instead of insert on test.stuff for each row execute procedure test.location_for_stuff();
|
||||
|
||||
create table loc_test (
|
||||
id int primary key
|
||||
, c text
|
||||
);
|
||||
|
||||
-- tables to test multi schema access in one instance
|
||||
create table v1.parents (
|
||||
id int primary key
|
||||
, name text
|
||||
);
|
||||
|
||||
create table v1.childs (
|
||||
id serial primary key
|
||||
, name text
|
||||
, parent_id int
|
||||
, constraint parent foreign key(parent_id)
|
||||
references v1.parents(id)
|
||||
);
|
||||
|
||||
create function v1.get_parents_below(id int)
|
||||
returns setof v1.parents as $$
|
||||
select * from v1.parents where id < $1;
|
||||
$$ language sql;
|
||||
|
||||
create table v2.parents (
|
||||
id int primary key
|
||||
, name text
|
||||
);
|
||||
|
||||
create table v2.childs (
|
||||
id serial primary key
|
||||
, name text
|
||||
, parent_id int
|
||||
, constraint parent foreign key(parent_id)
|
||||
references v2.parents(id)
|
||||
);
|
||||
|
||||
create table v2.another_table (
|
||||
id int primary key
|
||||
, another_value text
|
||||
);
|
||||
|
||||
create function v2.get_parents_below(id int)
|
||||
returns setof v2.parents as $$
|
||||
select * from v2.parents where id < $1;
|
||||
$$ language sql;
|
||||
|
||||
+14
-14
@@ -7,9 +7,9 @@ ko(){ result 'not ok' "- $1"; failedTests=$(( $failedTests + 1 )); }
|
||||
|
||||
pgrPort=49421
|
||||
|
||||
pgrStopAll(){ pkill -f "$(stack path --local-install-root)/bin/postgrest"; }
|
||||
pgrStopAll(){ pkill -f "$(stack path --profile --local-install-root)/bin/postgrest"; }
|
||||
|
||||
pgrStart(){ stack exec -- postgrest test/memory-tests/config +RTS -p -h >/dev/null & pgrPID="$!"; }
|
||||
pgrStart(){ stack exec --profile -- postgrest test/memory-tests/config +RTS -p -h >/dev/null & pgrPID="$!"; }
|
||||
pgrStop(){ kill "$pgrPID" 2>/dev/null; }
|
||||
|
||||
setUp(){ pgrStopAll; }
|
||||
@@ -94,21 +94,21 @@ setUp
|
||||
|
||||
echo "Running memory usage tests.."
|
||||
|
||||
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "12M"
|
||||
jsonKeyTest "1M" "POST" "/leak?columns=blob" "12M"
|
||||
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "12M"
|
||||
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "13M"
|
||||
jsonKeyTest "1M" "POST" "/leak?columns=blob" "13M"
|
||||
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "13M"
|
||||
|
||||
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "40M"
|
||||
jsonKeyTest "10M" "POST" "/leak?columns=blob" "40M"
|
||||
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "40M"
|
||||
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "41M"
|
||||
jsonKeyTest "10M" "POST" "/leak?columns=blob" "41M"
|
||||
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "41M"
|
||||
|
||||
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "170M"
|
||||
jsonKeyTest "50M" "POST" "/leak?columns=blob" "170M"
|
||||
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "170M"
|
||||
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "171M"
|
||||
jsonKeyTest "50M" "POST" "/leak?columns=blob" "171M"
|
||||
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "171M"
|
||||
|
||||
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "10M"
|
||||
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "10M"
|
||||
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "20M"
|
||||
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "11M"
|
||||
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "11M"
|
||||
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "21M"
|
||||
|
||||
cleanUp
|
||||
|
||||
|
||||
Reference in New Issue
Block a user