Compare commits

...
62 Commits
Author SHA1 Message Date
steve-chavez 2b61a63686 Bump to version v7.0.0 2020-04-03 11:22:11 -05:00
steve-chavez 18e45659ea Fix compiling on windows(unix socket issue)
Group unix socket functionality into its own module
2020-04-03 11:22:11 -05:00
steve-chavez 426637a47c Fix circleci golang docker image
Upgrade golang otherwise ghr throws:
unrecognized import path "math/bits"
2020-04-03 11:22:11 -05:00
Steve ChavezandGitHub ababf7d4fa Change docker image to support scram-sha-256 auth (#1467)
Debian stretch has a libpq < 10: https://packages.debian.org/stretch/libpq5.
But buster has libpq >= 10 https://packages.debian.org/buster/libpq5.

Fixes https://github.com/PostgREST/postgrest/issues/1443.
2020-04-01 10:14:00 -05:00
691bb5640d Allow multiple schemas to be exposed in one instance (#1450)
The schema to use can be selected through the headers `Accept-Profile` for GET/HEAD and `Content-Profile` for POST/PATCH/PUT/DELETE.

This is based on the https://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/ spec.

Also increase all memory tests by 1M(otherwise CI fails).

Co-authored-by: Mahmoud Kassem <MKassem@gk-software.com>
Co-authored-by: Mahmoud Kassem <mahmoud_k@mail.com>
2020-03-30 14:04:20 -05:00
Wolfgang WaltherandGitHub a80eb2ff0e Add docker-compose file to run tests (#1463) 2020-03-24 09:50:06 -05:00
Duncan OgilvieandGitHub fe59f9bedf Improve command line help (#1465)
Make jwt-secret explicit about 32 characters minimum.
2020-03-21 18:44:08 -05:00
Steve ChavezandGitHub 0f8838623b Fix empty headers being added on POST/PATCH/DELETE (#1458) 2020-03-13 11:02:38 -05:00
RobertandGitHub 5b5945e427 Update configurator-pg to 0.2.0 (better error messages) (#1457)
* Don't indent first line of configuration parser error messages

configurator-pg returns multi-line errors now. Alternatively,
we could indent all the lines.
2020-03-12 10:04:44 -05:00
Robert VollmertandSteve Chavez dea57bd1be Raise lower bound for configurator-pg.
Building on GHC 8.8.3 requires the MonadFail instance that 0.1.0.6
provides.
2020-03-09 13:06:27 -05:00
Robert VollmertandSteve Chavez 3e81a38438 Relax upper bounds for GHC 8.8.3 / stackage lts-15.3.
Specifically, allow base 4.13 which ships with GHC 8.8, as well
as several dependency updates including hasql-transaction 1.0,
swagger 2.5 and warp 3.3.
2020-03-09 13:06:27 -05:00
Robert VollmertandSteve Chavez dfdf3d30b3 Fix test typo. 2020-03-07 13:04:17 -05:00
Robert VollmertandSteve Chavez 60b64d3e81 Upgrade hspec-wai dependency to 0.10. 2020-03-07 13:04:17 -05:00
Robert VollmertandSteve Chavez 962fba4d16 Update stack.yaml.lock for HSYaml extra-deps. 2020-03-07 13:04:17 -05:00
steve-chavez de218e900b Fix for sylish-haskell 2020-02-21 13:30:30 -05:00
steve-chavez b75e7cef90 Change server-proxy-uri 2 openapi-server-proxy-uri
The server-proxy-uri config option mislead into thinking it was a
web server global setting when it's only related to OpenAPI.
2020-02-02 15:23:32 -05:00
steve-chavez 9b1224827a Allow overriding headers Location,Content-Type,etc 2020-01-21 12:28:05 -05:00
steve-chavez c7f78fa7fc Add response.headers on GET/POST/PATCH/PUT/DELETE 2020-01-21 12:28:05 -05:00
steve-chavez 7dade7f466 Add request method and path GUCs 2020-01-21 12:28:05 -05:00
steve-chavez b20e1150a5 Add GUC for accessing the Authorization header 2020-01-21 12:28:05 -05:00
steve-chavez aa0d6a6831 Update BACKERS.md 2020-01-21 12:28:05 -05:00
Steve ChavezandGitHub 663faa1f82 Allow specifying the constraint name to disambiguate an embedding (#1430)
Makes previous duck typing regex unnecessary since the FK can be renamed
to a singular name or to any other format.

* Remove embedding with duck typed column names
* Allow embedding by foreign key name
* Add junction disambiguation tests
2020-01-06 09:42:33 -05:00
YUKISHITA YohsukeandSteve Chavez 99b13fa25f Upsert with "Prefer: resolution=merge-duplicates" on specified columns (#1327) (#1432)
Allow query param 'on_conflict=key1,key2,...' to upsert with explicit columns with unique
constraint.
2019-12-29 11:52:58 -05:00
Dmitry WaginandSteve Chavez e12c1319b6 Change default server-unix-socket-mode 755 -> 660 (#1423) 2019-12-16 08:37:00 -05:00
Dmitry WaginandSteve Chavez 7f365bf60b Enforce singular behavior despite Prefer: return (#1417) 2019-12-08 01:49:20 -05:00
Dan AmorosoandSteve Chavez 2e6c78d723 add config option server-unix-socket-mode (#1415)
* added config option server-unix-socket-mode to enable custom socket permissions

* added server-unix-socket-mode input validation
2019-11-23 10:52:33 -05:00
steve-chavez f9c64d9f65 refactor: improve disambiguation error message
* reverse backwards relationships
* remove redundancy from getJoinSelects
* properly name Cardinality constructors
2019-11-17 13:28:21 -05:00
steve-chavez 4ef6926791 Update authorship 2019-11-17 13:28:21 -05:00
Brian WignallandSteve Chávez 9645f1011c Fix double-and typo 2019-11-12 10:00:23 -05:00
steve-chavez 9847e60dca Err embedding when multiple relationships found
When having one-to-many relationships like:

person        -< message[sender]
person        -< message[recipient]
person_detail -< message[sender]
person_detail -< message[recipient]

Where person_detail is a view of person.

This request:

GET "/message?select=*,sender(*)"

Is ambiguous. Both person or person_detail could be embedded.

Until now we have returned the first detected relationship but
now we return a 300 Multiple Choices error with a
descriptive error message asking the user to disambiguate.

This is more helpful for the user and also aids in cases of more
complex relationships.
2019-11-05 12:55:48 -05:00
steve-chavez cb3d9ab625 refactor: rename RelationType to Cardinality
Remove Root type constructor
2019-11-05 12:55:48 -05:00
steve-chavez db41fb454e refactor: Rename synonyms to source columns 2019-11-05 12:55:48 -05:00
steve-chavez 3b133d5554 Fix #1397, correct compression to XZ for osx bin 2019-10-24 13:12:39 -05:00
Steve ChávezandGitHub 80f763448f Fix unique foreign key in view (#1395) 2019-10-16 12:45:38 -05:00
steve-chavez a3701f5de8 Update BACKERS.md 2019-10-14 14:31:29 -05:00
steve-chavez ed2bfc09a6 Allow PATCH/DELETE w/o Prefer when no SELECT privs
PATCH/DELETE can now be done without adding Prefer return=minimal when
the user doesn't have SELECT privileges.

* Also fix PATCH wrong HTTP status code
2019-10-08 12:41:39 -05:00
steve-chavez 337f821e00 refactor: remove parent embed workaround(in #647)
This workaround is no more necessary since the addition of #978.

* Also add a test for proving parent embeds offset is consistent
  with other types of embeds.
2019-10-08 12:41:39 -05:00
steve-chavez f2b126f147 refactor: Move hasql helpers to Common module 2019-10-08 12:41:39 -05:00
steve-chavez 1173bc277b refactor: rm configSchema ref in postgrest func
* make TargetDefaultSpec take a schema attribute

* remove schema param from addJoinConditions
2019-10-08 12:41:39 -05:00
steve-chavez 50f2cc16ab refactor: rm schema arg from QueryBuilder funcs
* Change TableName to QualifiedIdentifier in ReadQuery
  and MutateQuery.

* Move removeSourceCTESchema to DbRequestBuilder.
2019-10-08 12:41:39 -05:00
steve-chavez eebe319bfd Recommend doing make check pre-commit 2019-09-30 08:46:31 -05:00
steve-chavez 75a42b77ea Fix M2M resource embedding on RPC and mutations 2019-09-28 13:45:18 -05:00
steve-chavez d71d3450af Fix PATCH embed when not having the id in ?select 2019-09-28 13:45:18 -05:00
steve-chavez f080159268 refactor: add the returningCols function
* separate fieldNames from getting fkCols

* Put binaryField inside readSqlParts

* Move scalar proc logic to binaryField

* Move logic for the "SELECT *" default to DbRequestBuilder
2019-09-28 13:45:18 -05:00
steve-chavez 0183d32c7f refactor: Remove DbRequest type
This type only adds an extra constructor.
2019-09-28 13:45:18 -05:00
steve-chavez 94f5894d7f Fix self join resource embedding on PATCH 2019-09-28 13:45:18 -05:00
steve-chavez 81e5a62f25 refactor: remove fromJust in DbRequestBuilder
* Also comment and reorganize DbRequestBuilder
2019-09-28 13:45:18 -05:00
Steve ChávezandGitHub 186381bab2 Add support for Prefer: count=planned/estimated on GET /table (#1386) 2019-09-18 10:13:48 -05:00
steve-chavez e044488f73 Make costly bulk call query optional
Bulk Call should be used by specifying
the `Prefer: params=multiple-objects` header.
2019-09-11 12:01:41 -05:00
steve-chavez b077974ebc Add tests for call proc queries EXPLAIN costs
* circleci: add run query costs tests
2019-09-11 12:01:41 -05:00
steve-chavez 200540dfc3 refactor: put callProc core query to QueryBuilder
* Move set local queries to QueryBuilder

* Move unquoted to Middleware
2019-09-11 12:01:41 -05:00
steve-chavez 3c00f46e36 refactor: Move statements logic to own module
* Move gucHeader decoding to Statements
2019-09-11 12:01:41 -05:00
steve-chavez 620721dea7 Add support for HEAD request 2019-09-03 13:38:53 -05:00
steve-chavez 68cbe34c11 refactor: only run db reset on certain specs
This speeds up running the whole test suite
2019-09-03 13:38:53 -05:00
steve-chavez e21b010c6e refactor: reorganize modules
* Move range logic from App to RangeQuery

* Move decoder logic to Statement modules

* Move location headers logic to functions

* Add a comment for App.hs module
2019-09-03 13:38:53 -05:00
Robert VollmertandSteve Chávez 0846d4d7b2 stack.yaml: Document use of obscure ghc options. 2019-08-27 14:49:07 -05:00
Robert VollmertandSteve Chávez 2183a2a1ae stack.yaml: Remove no-warn-redundant-constraints.
The warning appears to not be part of -Wall anymore in recent
GHC versions.

Also move the warning-related options next to each other.
2019-08-27 14:49:07 -05:00
Robert VollmertandSteve Chávez e8475b18d3 Update stack.yaml.lock for lts-14.3 2019-08-27 09:35:27 -05:00
Robert VollmertandSteve Chávez cdc1177762 stylish-haskell: check Haskell source files only
The style check was failing due to a changed stack.yaml.lock file.

(That file still should be updated, but this is not where it should
cause a failure.)
2019-08-27 09:35:27 -05:00
Robert VollmertandSteve Chávez 97035e0b8b circleci: make sure hlint is in the $PATH 2019-08-27 09:35:27 -05:00
Robert VollmertandSteve Chávez aaf62c1c96 Build with -fno-spec-constr.
Compare https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304
2019-08-27 09:35:27 -05:00
Robert VollmertandSteve Chávez 4d0661fd9b Upgrade swagger to 2.4.
Two changes:
- tags are now an insert-ordered hashset
- type_ .~ -> type_ ?~ with some magic type inference

This also updates stack.yaml to the newest GHC 8.6 LTS release.
2019-08-27 09:35:27 -05:00
72 changed files with 3686 additions and 1986 deletions
+10 -6
View File
@@ -83,6 +83,10 @@ jobs:
stack setup stack setup
rm -rf $(stack path --dist-dir) $(stack path --local-install-root) rm -rf $(stack path --dist-dir) $(stack path --local-install-root)
stack install hlint stylish-haskell stack install hlint stylish-haskell
- run:
name: Add stack tools to $PATH
command: |
echo "export PATH=/home/circleci/.local/bin:$PATH" >> $BASH_ENV
- run: - run:
name: build src and tests dependencies name: build src and tests dependencies
command: | command: |
@@ -105,10 +109,10 @@ jobs:
test/io-tests.sh test/io-tests.sh
- run: - run:
name: run linter name: run linter
command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$@" command: make lint
- run: - run:
name: run styler name: run styler
command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- stylish-haskell -i && git diff-index --exit-code HEAD -- command: make style
build-test-9.6: build-test-9.6:
docker: docker:
@@ -140,7 +144,7 @@ jobs:
stack build --fast --test --no-run-tests stack build --fast --test --no-run-tests
- run: - run:
name: run tests 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: build-test-10:
docker: docker:
@@ -172,7 +176,7 @@ jobs:
stack build --fast --test --no-run-tests stack build --fast --test --no-run-tests
- run: - run:
name: run tests 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: build-test-11:
docker: docker:
@@ -204,7 +208,7 @@ jobs:
stack build --fast --test --no-run-tests stack build --fast --test --no-run-tests
- run: - run:
name: run tests 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: build-prof-test:
docker: docker:
@@ -266,7 +270,7 @@ jobs:
release: release:
docker: docker:
- image: circleci/golang:1.8 - image: circleci/golang:1.9
steps: steps:
- attach_workspace: - attach_workspace:
at: /tmp/workspace at: /tmp/workspace
+5 -6
View File
@@ -42,14 +42,13 @@ your contributions.
* All contributions must pass the tests before being merged. When * All contributions must pass the tests before being merged. When
you create a pull request your code will automatically be tested. you create a pull request your code will automatically be tested.
* All code must also pass [hlint](http://community.haskell.org/~ndm/hlint/) * 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 with no warnings. This helps enforce a uniform style for all committers. Continuous integration will check this as well on every
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
pull request. 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 * 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) source](https://postgrest.com/en/stable/install.html#build-from-source) docs section.
wiki page.
### Running Tests ### Running Tests
+1 -1
View File
@@ -65,6 +65,6 @@ script:
END='## \[' END='## \['
BODY=$(sed -n "1,/$START/d;/$END/q;p" CHANGELOG.md) BODY=$(sed -n "1,/$START/d;/$END/q;p" CHANGELOG.md)
strip postgrest 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 ghr -t $GITHUB_TOKEN -u $OWNER -r $REPO -b "$BODY"--replace $TRAVIS_TAG postgrest-$TRAVIS_TAG-osx.tar.xz
fi fi
+2
View File
@@ -41,6 +41,8 @@ PostgREST ongoing development is only possible thanks to our Sponsors and Backer
- Nicholas DiBiase - Nicholas DiBiase
- Christopher Reid - Christopher Reid
- Nathan Bouscal - Nathan Bouscal
- Daniel Rafaj
- David Fenko
## Former Backers ## Former Backers
+32
View File
@@ -9,6 +9,38 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed ### 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 ## [6.0.2] - 2019-08-22
### Fixed ### Fixed
+1
View File
@@ -1,4 +1,5 @@
Copyright (c) 2014 Joe Nelson Copyright (c) 2014 Joe Nelson
Copyright (c) 2019 Steve Chavez
Permission is hereby granted, free of charge, to any person obtaining Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the a copy of this software and associated documentation files (the
+4 -5
View File
@@ -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 check: lint style test
@@ -9,8 +11,7 @@ lint:
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$$@" git ls-files | grep '\.l\?hs$$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$$@"
style: 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 test: check-dburi
stack test stack test
@@ -22,14 +23,12 @@ coverage: check-dburi clean
stack build --coverage stack build --coverage
stack test --coverage stack test --coverage
circleci: prompt-long-process circleci: prompt-long-process
circleci local execute --job build-test-9.4 circleci local execute --job build-test-9.4
circleci-prof-test: prompt-long-process circleci-prof-test: prompt-long-process
circleci local execute --job build-prof-test circleci local execute --job build-prof-test
check-dburi: check-dburi:
test -n "$(POSTGREST_TEST_CONNECTION)" # Requires POSTGREST_TEST_CONNECTION environmental variable test -n "$(POSTGREST_TEST_CONNECTION)" # Requires POSTGREST_TEST_CONNECTION environmental variable
+1 -1
View File
@@ -10,7 +10,7 @@
}, },
"POSTGREST_VER": { "POSTGREST_VER": {
"description": "Version of PostgREST to deploy", "description": "Version of PostgREST to deploy",
"value": "6.0.2" "value": "7.0.0"
}, },
"DB_URI": { "DB_URI": {
"description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb", "description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
+5 -2
View File
@@ -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 ARG POSTGREST_VERSION
@@ -31,7 +34,7 @@ ENV PGRST_DB_URI= \
PGRST_DB_EXTRA_SEARCH_PATH=public \ PGRST_DB_EXTRA_SEARCH_PATH=public \
PGRST_SERVER_HOST=*4 \ PGRST_SERVER_HOST=*4 \
PGRST_SERVER_PORT=3000 \ PGRST_SERVER_PORT=3000 \
PGRST_SERVER_PROXY_URI= \ PGRST_OPENAPI_SERVER_PROXY_URI= \
PGRST_JWT_SECRET= \ PGRST_JWT_SECRET= \
PGRST_SECRET_IS_BASE64=false \ PGRST_SECRET_IS_BASE64=false \
PGRST_JWT_AUD= \ PGRST_JWT_AUD= \
+1 -1
View File
@@ -7,7 +7,7 @@ db-extra-search-path = "$(PGRST_DB_EXTRA_SEARCH_PATH)"
server-host = "$(PGRST_SERVER_HOST)" server-host = "$(PGRST_SERVER_HOST)"
server-port = "$(PGRST_SERVER_PORT)" 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)" jwt-secret = "$(PGRST_JWT_SECRET)"
secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)" secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)"
jwt-aud = "$(PGRST_JWT_AUD)" jwt-aud = "$(PGRST_JWT_AUD)"
+39 -46
View File
@@ -12,25 +12,17 @@ import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
import Control.Retry (RetryStatus, capDelay, import Control.Retry (RetryStatus, capDelay,
exponentialBackoff, retrying, exponentialBackoff, retrying,
rsPreviousDelay) rsPreviousDelay)
import Data.Either.Combinators (whenLeft)
import Data.IORef (IORef, atomicWriteIORef, newIORef, import Data.IORef (IORef, atomicWriteIORef, newIORef,
readIORef) readIORef)
import Data.String (IsString (..)) import Data.String (IsString (..))
import Data.Text (pack, replace, strip, stripPrefix, import Data.Text (pack, replace, strip, stripPrefix)
unpack)
import Data.Text.Encoding (decodeUtf8, encodeUtf8) import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.IO (hPutStrLn, readFile) import Data.Text.IO (hPutStrLn, readFile)
import Data.Time.Clock (getCurrentTime) 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, import Network.Wai.Handler.Warp (defaultSettings, runSettings,
runSettingsSocket, setHost, setPort, setHost, setPort, setServerName)
setServerName)
import System.Directory (removeFile)
import System.IO (BufferMode (..), hSetBuffering) import System.IO (BufferMode (..), hSetBuffering)
import System.IO.Error (isDoesNotExistError)
import PostgREST.App (postgrest) import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..), configPoolTimeout', import PostgREST.Config (AppConfig (..), configPoolTimeout',
@@ -42,13 +34,15 @@ import PostgREST.OpenAPI (isMalformedProxyUri)
import PostgREST.Types (ConnectionStatus (..), DbStructure, import PostgREST.Types (ConnectionStatus (..), DbStructure,
PgVersion (..), Schema, PgVersion (..), Schema,
minimumPgVersion) minimumPgVersion)
import Protolude hiding (hPutStrLn, replace) import Protolude hiding (hPutStrLn, head, replace)
#ifndef mingw32_HOST_OS #ifndef mingw32_HOST_OS
import System.Posix.Signals import System.Posix.Signals
import UnixSocket
#endif #endif
{-| {-|
The purpose of this worker is to fill the refDbStructure created in 'main' The purpose of this worker is to fill the refDbStructure created in 'main'
with the 'DbStructure' returned from calling 'getDbStructure'. This method with the 'DbStructure' returned from calling 'getDbStructure'. This method
@@ -70,11 +64,11 @@ import System.Posix.Signals
connectionWorker connectionWorker
:: ThreadId -- ^ This thread is killed if pg version is unsupported :: ThreadId -- ^ This thread is killed if pg version is unsupported
-> P.Pool -- ^ The PostgreSQL connection pool -> 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 (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
-> IORef Bool -- ^ Used as a binary Semaphore -> IORef Bool -- ^ Used as a binary Semaphore
-> IO () -> IO ()
connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do connectionWorker mainTid pool schemas refDbStructure refIsWorkerOn = do
isWorkerOn <- readIORef refIsWorkerOn isWorkerOn <- readIORef refIsWorkerOn
unless isWorkerOn $ do unless isWorkerOn $ do
atomicWriteIORef refIsWorkerOn True atomicWriteIORef refIsWorkerOn True
@@ -90,7 +84,7 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
NotConnected -> return () -- Unreachable NotConnected -> return () -- Unreachable
Connected actualPgVersion -> do -- Procede with initialization Connected actualPgVersion -> do -- Procede with initialization
result <- P.use pool $ do 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 liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
case result of case result of
Left e -> do Left e -> do
@@ -159,10 +153,12 @@ main = do
-- readOptions builds the 'AppConfig' from the config file specified on the -- readOptions builds the 'AppConfig' from the config file specified on the
-- command line -- command line
conf <- loadDbUriFile =<< loadSecretFile =<< readOptions conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
let host = configHost conf let schemas = toList $ configSchemas conf
host = configHost conf
port = configPort conf port = configPort conf
proxy = configProxyUri conf proxy = configOpenAPIProxyUri conf
maybeSocketAddr = configSocket conf maybeSocketAddr = configSocket conf
socketFileMode = configSocketMode conf
pgSettings = toS (configDatabase conf) -- is the db-uri pgSettings = toS (configDatabase conf) -- is the db-uri
roleClaimKey = configRoleClaimKey conf roleClaimKey = configRoleClaimKey conf
appSettings = appSettings =
@@ -171,16 +167,18 @@ main = do
. setServerName (toS $ "postgrest/" <> prettyVersion) $ . setServerName (toS $ "postgrest/" <> prettyVersion) $
defaultSettings defaultSettings
whenLeft socketFileMode panic
-- Checks that the provided proxy uri is formated correctly -- Checks that the provided proxy uri is formated correctly
when (isMalformedProxyUri $ toS <$> proxy) $ when (isMalformedProxyUri $ toS <$> proxy) $
panic panic
"Malformed proxy uri, a correct example: https://example.com:8443/basePath" "Malformed proxy uri, a correct example: https://example.com:8443/basePath"
-- Checks that the provided jspath is valid -- Checks that the provided jspath is valid
when (isLeft roleClaimKey) $ whenLeft roleClaimKey $
panic $ show roleClaimKey panic $ show roleClaimKey
--
-- create connection pool with the provided settings, returns either -- create connection pool with the provided settings, returns either
-- a 'Connection' or a 'ConnectionError'. Does not throw. -- a 'Connection' or a 'ConnectionError'. Does not throw.
pool <- P.acquire (configPool conf, configPoolTimeout' conf, pgSettings) pool <- P.acquire (configPool conf, configPoolTimeout' conf, pgSettings)
@@ -199,7 +197,7 @@ main = do
connectionWorker connectionWorker
mainTid mainTid
pool pool
(configSchema conf) schemas
refDbStructure refDbStructure
refIsWorkerOn refIsWorkerOn
-- --
@@ -221,7 +219,7 @@ main = do
Catch $ connectionWorker Catch $ connectionWorker
mainTid mainTid
pool pool
(configSchema conf) schemas
refDbStructure refDbStructure
refIsWorkerOn refIsWorkerOn
) Nothing ) Nothing
@@ -240,22 +238,20 @@ main = do
(connectionWorker (connectionWorker
mainTid mainTid
pool pool
(configSchema conf) schemas
refDbStructure refDbStructure
refIsWorkerOn) refIsWorkerOn)
in case maybeSocketAddr of
Nothing -> do -- run the postgrest application with user defined socket. Only for UNIX systems.
-- run the postgrest application #ifndef mingw32_HOST_OS
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf) whenJust maybeSocketAddr $
runSettings appSettings postgrestApplication runAppInSocket appSettings postgrestApplication socketFileMode
Just socketAddr -> do #endif
-- run postgrest application with user defined socket
sock <- createAndBindSocket (unpack socketAddr) -- run the postgrest application
listen sock maxListenQueue whenNothing maybeSocketAddr $ do
putStrLn $ ("Listening on unix socket " :: Text) <> show socketAddr putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
runSettingsSocket appSettings sock postgrestApplication runSettings appSettings postgrestApplication
-- clean socket up when done
close sock
{-| {-|
The purpose of this function is to load the JWT secret from a file if 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) Just filename -> strip <$> readFile (toS filename)
setDbUri dbUri = conf {configDatabase = dbUri} setDbUri dbUri = conf {configDatabase = dbUri}
createAndBindSocket :: FilePath -> IO Socket -- Utilitarian functions.
createAndBindSocket filePath = do whenJust :: Applicative f => Maybe a -> (a -> f ()) -> f ()
deleteSocketFileIfExist filePath whenJust (Just x) f = f x
sock <- socket AF_UNIX Stream defaultProtocol whenJust Nothing _ = pass
bind sock $ SockAddrUnix filePath
return sock whenNothing :: Applicative f => Maybe a -> f () -> f ()
where whenNothing Nothing f = f
deleteSocketFileIfExist path = removeFile path `catch` handleDoesNotExist whenNothing _ _ = pass
handleDoesNotExist e
| isDoesNotExistError e = return ()
| otherwise = throwIO e
+40
View File
@@ -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
+73 -30
View File
@@ -1,13 +1,13 @@
name: postgrest name: postgrest
version: 6.0.2 version: 7.0.0
synopsis: REST API for any Postgres database synopsis: REST API for any Postgres database
description: Reads the schema of a PostgreSQL database and creates RESTful routes description: Reads the schema of a PostgreSQL database and creates RESTful routes
for the tables and views, supporting all HTTP verbs that security for the tables and views, supporting all HTTP verbs that security
permits. permits.
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
author: Joe Nelson, Adam Baker author: Joe Nelson, Adam Baker, Steve Chavez
maintainer: Steve Chávez <stevechavezast@gmail.com> maintainer: Steve Chavez <stevechavezast@gmail.com>
category: Executable, PostgreSQL, Network APIs category: Executable, PostgreSQL, Network APIs
homepage: https://postgrest.org homepage: https://postgrest.org
bug-reports: https://github.com/PostgREST/postgrest/issues bug-reports: https://github.com/PostgREST/postgrest/issues
@@ -36,15 +36,14 @@ library
PostgREST.OpenAPI PostgREST.OpenAPI
PostgREST.Parsers PostgREST.Parsers
PostgREST.QueryBuilder PostgREST.QueryBuilder
PostgREST.Statements
PostgREST.RangeQuery PostgREST.RangeQuery
PostgREST.Types PostgREST.Types
other-modules: Paths_postgrest other-modules: Paths_postgrest
PostgREST.QueryBuilder.Private PostgREST.Private.Common
PostgREST.QueryBuilder.Procedure PostgREST.Private.QueryFragment
PostgREST.QueryBuilder.ReadStatement
PostgREST.QueryBuilder.WriteStatement
hs-source-dirs: src hs-source-dirs: src
build-depends: base >= 4.9 && < 4.13 build-depends: base >= 4.9 && < 4.14
, HTTP >= 4000.3.7 && < 4000.4 , HTTP >= 4000.3.7 && < 4000.4
, Ranged-sets >= 0.3 && < 0.5 , Ranged-sets >= 0.3 && < 0.5
, aeson >= 0.11.3 && < 1.5 , aeson >= 0.11.3 && < 1.5
@@ -53,7 +52,7 @@ library
, bytestring >= 0.10.8 && < 0.11 , bytestring >= 0.10.8 && < 0.11
, case-insensitive >= 1.2 && < 1.3 , case-insensitive >= 1.2 && < 1.3
, cassava >= 0.4.5 && < 0.6 , cassava >= 0.4.5 && < 0.6
, configurator-pg >= 0.1 && < 0.2 , configurator-pg >= 0.2 && < 0.3
, containers >= 0.5.7 && < 0.7 , containers >= 0.5.7 && < 0.7
, contravariant >= 1.4 && < 1.6 , contravariant >= 1.4 && < 1.6
, contravariant-extras >= 0.3.3 && < 0.4 , contravariant-extras >= 0.3.3 && < 0.4
@@ -62,23 +61,23 @@ library
, gitrev >= 1.2 && < 1.4 , gitrev >= 1.2 && < 1.4
, hasql >= 1.4 && < 1.5 , hasql >= 1.4 && < 1.5
, hasql-pool >= 0.5 && < 0.6 , 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 , heredoc >= 0.2 && < 0.3
, http-types >= 0.12.2 && < 0.13 , 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 , interpolatedstring-perl6 >= 1 && < 1.1
, jose >= 0.8.1 && < 0.9 , jose >= 0.8.1 && < 0.9
, lens >= 4.14 && < 4.18 , lens >= 4.14 && < 4.19
, lens-aeson >= 1.0.1 && < 1.1 , lens-aeson >= 1.0.1 && < 1.2
, network-uri >= 2.6.1 && < 2.7 , 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 , parsec >= 3.1.11 && < 3.2
, protolude >= 0.2.2 && < 0.3 , 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 , scientific >= 0.3.4 && < 0.4
, swagger2 >= 2.1.4 && < 2.4 , swagger2 >= 2.4 && < 2.6
, text >= 1.2.2 && < 1.3 , text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.9 , time >= 1.6 && < 1.10
, unordered-containers >= 0.2.8 && < 0.3 , unordered-containers >= 0.2.8 && < 0.3
, vector >= 0.11 && < 0.13 , vector >= 0.11 && < 0.13
, wai >= 3.2.1 && < 3.3 , wai >= 3.2.1 && < 3.3
@@ -93,21 +92,23 @@ library
executable postgrest executable postgrest
main-is: Main.hs main-is: Main.hs
hs-source-dirs: main 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 , auto-update >= 0.1.4 && < 0.2
, base64-bytestring >= 1 && < 1.1 , base64-bytestring >= 1 && < 1.1
, bytestring >= 0.10.8 && < 0.11 , bytestring >= 0.10.8 && < 0.11
, directory >= 1.2.6 && < 1.4 , directory >= 1.2.6 && < 1.4
, either >= 4.4.1 && < 5.1
, hasql >= 1.4 && < 1.5 , hasql >= 1.4 && < 1.5
, hasql-pool >= 0.5 && < 0.6 , hasql-pool >= 0.5 && < 0.6
, hasql-transaction >= 0.7.2 && < 0.8 , hasql-transaction >= 0.7.2 && < 1.1
, network < 2.9 , network < 3.2
, postgrest , postgrest
, protolude >= 0.2.2 && < 0.3 , protolude >= 0.2.2 && < 0.3
, retry >= 0.7.4 && < 0.9 , retry >= 0.7.4 && < 0.9
, text >= 1.2.2 && < 1.3 , text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.9 , time >= 1.6 && < 1.10
, warp >= 3.2.12 && < 3.3 , wai >= 3.2.1 && < 3.3
, warp >= 3.2.12 && < 3.4
default-language: Haskell2010 default-language: Haskell2010
default-extensions: OverloadedStrings default-extensions: OverloadedStrings
QuasiQuotes QuasiQuotes
@@ -116,6 +117,7 @@ executable postgrest
if !os(windows) if !os(windows)
build-depends: unix build-depends: unix
other-modules: UnixSocket
test-suite spec test-suite spec
type: exitcode-stdio-1.0 type: exitcode-stdio-1.0
@@ -128,6 +130,7 @@ test-suite spec
Feature.ConcurrentSpec Feature.ConcurrentSpec
Feature.CorsSpec Feature.CorsSpec
Feature.DeleteSpec Feature.DeleteSpec
Feature.EmbedDisambiguationSpec
Feature.ExtraSearchPathSpec Feature.ExtraSearchPathSpec
Feature.InsertSpec Feature.InsertSpec
Feature.JsonOperatorSpec Feature.JsonOperatorSpec
@@ -147,10 +150,11 @@ test-suite spec
Feature.UpsertSpec Feature.UpsertSpec
Feature.RawOutputTypesSpec Feature.RawOutputTypesSpec
Feature.HtmlRawOutputSpec Feature.HtmlRawOutputSpec
Feature.MultipleSchemaSpec
SpecHelper SpecHelper
TestTypes TestTypes
hs-source-dirs: test 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 >= 0.11.3 && < 1.5
, aeson-qq >= 0.8.1 && < 0.9 , aeson-qq >= 0.8.1 && < 0.9
, async >= 2.1.1 && < 2.3 , async >= 2.1.1 && < 2.3
@@ -163,21 +167,21 @@ test-suite spec
, contravariant >= 1.4 && < 1.6 , contravariant >= 1.4 && < 1.6
, hasql >= 1.4 && < 1.5 , hasql >= 1.4 && < 1.5
, hasql-pool >= 0.5 && < 0.6 , 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 , heredoc >= 0.2 && < 0.3
, hspec >= 2.3 && < 2.8 , hspec >= 2.3 && < 2.8
, hspec-wai >= 0.7 && < 0.10 , hspec-wai >= 0.10 && < 0.11
, hspec-wai-json >= 0.7 && < 0.10 , hspec-wai-json >= 0.10 && < 0.11
, http-types >= 0.12.3 && < 0.13 , http-types >= 0.12.3 && < 0.13
, lens >= 4.14 && < 4.18 , lens >= 4.14 && < 4.19
, lens-aeson >= 1.0.1 && < 1.1 , lens-aeson >= 1.0.1 && < 1.2
, monad-control >= 1.0.1 && < 1.1 , monad-control >= 1.0.1 && < 1.1
, postgrest , postgrest
, process >= 1.4.2 && < 1.7 , process >= 1.4.2 && < 1.7
, protolude >= 0.2.2 && < 0.3 , 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 , text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.9 , time >= 1.6 && < 1.10
, transformers-base >= 0.4.4 && < 0.5 , transformers-base >= 0.4.4 && < 0.5
, wai >= 3.2.1 && < 3.3 , wai >= 3.2.1 && < 3.3
, wai-extra >= 3.0.19 && < 3.1 , wai-extra >= 3.0.19 && < 3.1
@@ -186,3 +190,42 @@ test-suite spec
QuasiQuotes QuasiQuotes
NoImplicitPrelude NoImplicitPrelude
ghc-options: -threaded -rtsopts -with-rtsopts=-N 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
+109 -76
View File
@@ -3,13 +3,14 @@ Module : PostgREST.ApiRequest
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest. Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
-} -}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
module PostgREST.ApiRequest ( module PostgREST.ApiRequest (
ApiRequest(..) ApiRequest(..)
, InvokeMethod(..)
, ContentType(..) , ContentType(..)
, Action(..) , Action(..)
, Target(..) , Target(..)
, PreferRepresentation (..)
, mutuallyAgreeable , mutuallyAgreeable
, userApiRequest , userApiRequest
) where ) where
@@ -27,7 +28,8 @@ import qualified Data.Vector as V
import Control.Arrow ((***)) import Control.Arrow ((***))
import Data.Aeson.Types (emptyArray, emptyObject) 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.Maybe (fromJust)
import Data.Ranged.Ranges (Range (..), emptyRange, import Data.Ranged.Ranges (Range (..), emptyRange,
rangeIntersection) rangeIntersection)
@@ -47,24 +49,23 @@ import PostgREST.RangeQuery (NonnegRange, allRange, rangeGeq,
rangeLimit, rangeOffset, rangeRequested, rangeLimit, rangeOffset, rangeRequested,
restrictRange) restrictRange)
import PostgREST.Types import PostgREST.Types
import Protolude import Protolude hiding (head)
type RequestBody = BL.ByteString type RequestBody = BL.ByteString
data InvokeMethod = InvHead | InvGet | InvPost deriving Eq
-- | Types of things a user wants to do to tables/views/procs -- | Types of things a user wants to do to tables/views/procs
data Action = ActionCreate | ActionRead data Action = ActionCreate | ActionRead{isHead :: Bool}
| ActionUpdate | ActionDelete | ActionUpdate | ActionDelete
| ActionInfo | ActionInvoke{isReadOnly :: Bool} | ActionSingleUpsert | ActionInvoke InvokeMethod
| ActionInspect | ActionSingleUpsert | ActionInfo | ActionInspect{isHead :: Bool}
deriving Eq deriving Eq
-- | The target db object of a user action -- | The target db object of a user action
data Target = TargetIdent QualifiedIdentifier data Target = TargetIdent QualifiedIdentifier
| TargetProc{tpQi :: QualifiedIdentifier, tpIsRootSpec :: Bool} | TargetProc{tpQi :: QualifiedIdentifier, tpIsRootSpec :: Bool}
| TargetDefaultSpec -- The default spec offered at root "/" | TargetDefaultSpec{tdsSchema :: Schema} -- The default spec offered at root "/"
| TargetUnknown [Text] | TargetUnknown [Text]
deriving Eq 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 Describes what the user wants to do. This data type is a
@@ -74,65 +75,61 @@ data PreferRepresentation = Full | HeadersOnly | None deriving Eq
if it is an action we are able to perform. if it is an action we are able to perform.
-} -}
data ApiRequest = ApiRequest { data ApiRequest = ApiRequest {
-- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST iAction :: Action -- ^ Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
iAction :: Action , iRange :: M.HashMap ByteString NonnegRange -- ^ Requested range of rows within response
-- | Requested range of rows within response , iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level
, iRange :: M.HashMap ByteString NonnegRange , iTarget :: Target -- ^ The target, be it calling a proc or accessing a table
-- | The target, be it calling a proc or accessing a table , iAccepts :: [ContentType] -- ^ Content types the client will accept, [CTAny] if no Accept header
, iTarget :: Target , iPayload :: Maybe PayloadJSON -- ^ Data sent by client and used for mutation actions
-- | Content types the client will accept, [CTAny] if no Accept header , iPreferRepresentation :: PreferRepresentation -- ^ If client wants created items echoed back
, iAccepts :: [ContentType] , iPreferParameters :: Maybe PreferParameters -- ^ How to pass parameters to a stored procedure
-- | Data sent by client and used for mutation actions , iPreferCount :: Maybe PreferCount -- ^ Whether the client wants a result count
, iPayload :: Maybe PayloadJSON , iPreferResolution :: Maybe PreferResolution -- ^ Whether the client wants to UPSERT or ignore records on PK conflict
-- | If client wants created items echoed back , iFilters :: [(Text, Text)] -- ^ Filters on the result ("id", "eq.10")
, iPreferRepresentation :: PreferRepresentation , iLogic :: [(Text, Text)] -- ^ &and and &or parameters used for complex boolean logic
-- | Pass all parameters as a single json object to a stored procedure , iSelect :: Maybe Text -- ^ &select parameter used to shape the response
, iPreferSingleObjectParameter :: Bool , iOnConflict :: Maybe Text -- ^ &on_conflict parameter used to upsert on specific unique keys
-- | Whether the client wants a result count (slower) , iColumns :: Maybe Text -- ^ &columns parameter used to shape the payload
, iPreferCount :: Bool , iOrder :: [(Text, Text)] -- ^ &order parameters for each level
-- | Whether the client wants to UPSERT or ignore records on PK conflict , iCanonicalQS :: ByteString -- ^ Alphabetized (canonical) request query string for response URLs
, iPreferResolution :: Maybe PreferResolution , iJWT :: Text -- ^ JSON Web Token
-- | Filters on the result ("id", "eq.10") , iHeaders :: [(Text, Text)] -- ^ HTTP request headers
, iFilters :: [(Text, Text)] , iCookies :: [(Text, Text)] -- ^ Request Cookies
-- | &and and &or parameters used for complex boolean logic , iPath :: ByteString -- ^ Raw request path
, iLogic :: [(Text, Text)] , iMethod :: ByteString -- ^ Raw request method
-- | &select parameter used to shape the response , 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/.
, iSelect :: Text , iSchema :: Schema -- ^ The request schema. Can vary depending on iProfile.
-- | &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)]
} }
-- | Examines HTTP request and translates it into user intent. -- | Examines HTTP request and translates it into user intent.
userApiRequest :: Schema -> Maybe QualifiedIdentifier -> Request -> RequestBody -> Either ApiRequestError ApiRequest userApiRequest :: NonEmpty Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest schema rootSpec req reqBody userApiRequest confSchemas rootSpec req reqBody
| isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate | isJust profile && fromJust profile `notElem` confSchemas = Left $ UnacceptableSchema $ toList confSchemas
| isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate
| topLevelRange == emptyRange = Left InvalidRange | topLevelRange == emptyRange = Left InvalidRange
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload | shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
| otherwise = Right ApiRequest { | otherwise = Right ApiRequest {
iAction = action iAction = action
, iTarget = target , iTarget = target
, iRange = ranges , iRange = ranges
, iTopLevelRange = topLevelRange
, iAccepts = maybe [CTAny] (map decodeContentType . parseHttpAccept) $ lookupHeader "accept" , iAccepts = maybe [CTAny] (map decodeContentType . parseHttpAccept) $ lookupHeader "accept"
, iPayload = relevantPayload , iPayload = relevantPayload
, iPreferRepresentation = representation , iPreferRepresentation = representation
, iPreferSingleObjectParameter = singleObject , iPreferParameters = if | hasPrefer (show SingleObject) -> Just SingleObject
, iPreferCount = hasPrefer "count=exact" | hasPrefer (show MultipleObjects) -> Just MultipleObjects
, iPreferResolution = if hasPrefer (show MergeDuplicates) then Just MergeDuplicates | otherwise -> Nothing
else if hasPrefer (show IgnoreDuplicates) then Just IgnoreDuplicates , iPreferCount = if | hasPrefer (show ExactCount) -> Just ExactCount
else Nothing | 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 , iFilters = filters
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ] , 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 , iColumns = columns
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ] , iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
, iCanonicalQS = toS $ urlEncodeVars , iCanonicalQS = toS $ urlEncodeVars
@@ -140,8 +137,12 @@ userApiRequest schema rootSpec req reqBody
. map (join (***) toS . second (fromMaybe BS.empty)) . map (join (***) toS . second (fromMaybe BS.empty))
$ qString $ qString
, iJWT = tokenStr , 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" , iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
, iPath = rawPathInfo req
, iMethod = method
, iProfile = profile
, iSchema = schema
} }
where where
-- queryString with '+' converted to ' '(space) -- queryString with '+' converted to ' '(space)
@@ -149,8 +150,10 @@ userApiRequest schema rootSpec req reqBody
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..) -- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
(filters, rpcQParams) = (filters, rpcQParams) =
case action of case action of
ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts ActionInvoke InvGet -> partitionFlts
_ -> (flts, []) ActionInvoke InvHead -> partitionFlts
_ -> (flts, [])
partitionFlts = partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
flts = flts =
[ (toS k, toS $ fromJust v) | [ (toS k, toS $ fromJust v) |
(k,v) <- qParams, isJust v, (k,v) <- qParams, isJust v,
@@ -163,13 +166,17 @@ userApiRequest schema rootSpec req reqBody
isTargetingProc = case target of isTargetingProc = case target of
TargetProc _ _ -> True TargetProc _ _ -> True
_ -> False _ -> False
isTargetingDefaultSpec = case target of
TargetDefaultSpec _ -> True
_ -> False
contentType = decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" contentType = decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type"
columns | action `elem` [ActionCreate, ActionUpdate, ActionInvoke{isReadOnly=False}] = toS <$> join (lookup "columns" qParams) columns
| otherwise = Nothing | action `elem` [ActionCreate, ActionUpdate, ActionInvoke InvPost] = toS <$> join (lookup "columns" qParams)
| otherwise = Nothing
payload = payload =
case (contentType, action) of case (contentType, action) of
(_, ActionInvoke{isReadOnly=True}) -> (_, ActionInvoke InvGet) -> Right rpcPrmsToJson
Right $ ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) PJObject (S.fromList $ fst <$> rpcQParams) (_, ActionInvoke InvHead) -> Right rpcPrmsToJson
(CTApplicationJSON, _) -> (CTApplicationJSON, _) ->
if isJust columns if isJust columns
then Right $ RawJSON reqBody then Right $ RawJSON reqBody
@@ -186,30 +193,55 @@ userApiRequest schema rootSpec req reqBody
Right $ ProcessedJSON (JSON.encode json) PJObject keys Right $ ProcessedJSON (JSON.encode json) PJObject keys
(ct, _) -> (ct, _) ->
Left $ toS $ "Content-Type not acceptable: " <> toMime 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 = action =
case method of case method of
"GET" | target == TargetDefaultSpec -> ActionInspect -- The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response
| isTargetingProc -> ActionInvoke{isReadOnly=True} -- From https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
| otherwise -> ActionRead "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 "POST" -> if isTargetingProc
then ActionInvoke{isReadOnly=False} then ActionInvoke InvPost
else ActionCreate else ActionCreate
"PATCH" -> ActionUpdate "PATCH" -> ActionUpdate
"PUT" -> ActionSingleUpsert "PUT" -> ActionSingleUpsert
"DELETE" -> ActionDelete "DELETE" -> ActionDelete
"OPTIONS" -> ActionInfo "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 target = case path of
[] -> case rootSpec of [] -> case rootSpec of
Just rsQi -> TargetProc rsQi True Just pName -> TargetProc (QualifiedIdentifier schema pName) True
Nothing -> TargetDefaultSpec Nothing -> TargetDefaultSpec schema
[table] -> TargetIdent $ QualifiedIdentifier schema table [table] -> TargetIdent $ QualifiedIdentifier schema table
["rpc", proc] -> TargetProc (QualifiedIdentifier schema proc) False ["rpc", proc] -> TargetProc (QualifiedIdentifier schema proc) False
other -> TargetUnknown other 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 relevantPayload | shouldParsePayload = rightToMaybe payload
| otherwise = Nothing | otherwise = Nothing
path = pathInfo req path = pathInfo req
@@ -222,11 +254,12 @@ userApiRequest schema rootSpec req reqBody
where where
split :: BS.ByteString -> [Text] split :: BS.ByteString -> [Text]
split = map T.strip . T.split (==',') . toS split = map T.strip . T.split (==',') . toS
singleObject = hasPrefer "params=single-object"
representation representation
| hasPrefer "return=representation" = Full | hasPrefer (show Full) = Full
| hasPrefer "return=minimal" = None | hasPrefer (show None) = None
| otherwise = HeadersOnly | otherwise = if action == ActionCreate
then HeadersOnly -- Assume the user wants the Location header(for POST) by default
else None
auth = fromMaybe "" $ lookupHeader hAuthorization auth = fromMaybe "" $ lookupHeader hAuthorization
tokenStr = case T.split (== ' ') (toS auth) of tokenStr = case T.split (== ' ') (toS auth) of
("Bearer" : t : _) -> t ("Bearer" : t : _) -> t
+228 -202
View File
@@ -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 FlexibleContexts #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
@@ -15,7 +27,6 @@ import qualified Hasql.Transaction as H
import qualified Hasql.Transaction as HT import qualified Hasql.Transaction as HT
import qualified Hasql.Transaction.Sessions as HT import qualified Hasql.Transaction.Sessions as HT
import Data.Aeson as JSON
import Data.Function (id) import Data.Function (id)
import Data.IORef (IORef, readIORef) import Data.IORef (IORef, readIORef)
import Data.Time.Clock (UTCTime) import Data.Time.Clock (UTCTime)
@@ -30,26 +41,28 @@ import Network.Wai
import PostgREST.ApiRequest (Action (..), ApiRequest (..), import PostgREST.ApiRequest (Action (..), ApiRequest (..),
ContentType (..), ContentType (..),
PreferRepresentation (..), InvokeMethod (..), Target (..),
Target (..), mutuallyAgreeable, mutuallyAgreeable, userApiRequest)
userApiRequest)
import PostgREST.Auth (containsRole, jwtClaims, import PostgREST.Auth (containsRole, jwtClaims,
parseSecret) parseSecret)
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.DbRequestBuilder (fieldNames, mutateRequest, import PostgREST.DbRequestBuilder (mutateRequest, readRequest)
readRequest)
import PostgREST.DbStructure import PostgREST.DbStructure
import PostgREST.Error (PgError (..), SimpleError (..), import PostgREST.Error (PgError (..), SimpleError (..),
errorResponseFor, singularityError) errorResponseFor, singularityError)
import PostgREST.Middleware import PostgREST.Middleware
import PostgREST.OpenAPI import PostgREST.OpenAPI
import PostgREST.Parsers (pRequestColumns) 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, createReadStatement,
createWriteStatement, createWriteStatement)
requestToCountQuery,
requestToQuery)
import PostgREST.RangeQuery (allRange, rangeOffset)
import PostgREST.Types import PostgREST.Types
import Protolude hiding (Proxy, intercalate) import Protolude hiding (Proxy, intercalate)
@@ -65,8 +78,9 @@ postgrest conf refDbStructure pool getTime worker =
Nothing -> respond . errorResponseFor $ ConnectionLostError Nothing -> respond . errorResponseFor $ ConnectionLostError
Just dbStructure -> do Just dbStructure -> do
response <- do response <- do
-- Need to parse ?columns early because findProc needs it to solve overloaded functions -- Need to parse ?columns early because findProc needs it to solve overloaded functions.
let apiReq = userApiRequest (configSchema conf) (configRootSpec conf) req body -- TODO: move this logic to the app function
let apiReq = userApiRequest (configSchemas conf) (configRootSpec conf) req body
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq) apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
case apiReqCols of case apiReqCols of
Left err -> return . errorResponseFor $ err Left err -> return . errorResponseFor $ err
@@ -78,7 +92,7 @@ postgrest conf refDbStructure pool getTime worker =
(Just RawJSON{}, Just cls) -> cls (Just RawJSON{}, Just cls) -> cls
_ -> S.empty _ -> S.empty
proc = case iTarget apiRequest of 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 _ -> Nothing
handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest
txMode = transactionMode proc (iAction apiRequest) txMode = transactionMode proc (iAction apiRequest)
@@ -90,46 +104,57 @@ postgrest conf refDbStructure pool getTime worker =
transactionMode :: Maybe ProcDescription -> Action -> HT.Mode transactionMode :: Maybe ProcDescription -> Action -> HT.Mode
transactionMode proc action = transactionMode proc action =
case action of case action of
ActionRead -> HT.Read ActionRead _ -> HT.Read
ActionInfo -> HT.Read ActionInfo -> HT.Read
ActionInspect -> HT.Read ActionInspect _ -> HT.Read
ActionInvoke{isReadOnly=False} -> ActionInvoke InvGet -> HT.Read
ActionInvoke InvHead -> HT.Read
ActionInvoke InvPost ->
let v = maybe Volatile pdVolatility proc in let v = maybe Volatile pdVolatility proc in
if v == Stable || v == Immutable if v == Stable || v == Immutable
then HT.Read then HT.Read
else HT.Write else HT.Write
ActionInvoke{isReadOnly=True} -> HT.Read
_ -> HT.Write _ -> HT.Write
app :: DbStructure -> Maybe ProcDescription -> S.Set FieldName -> AppConfig -> ApiRequest -> H.Transaction Response app :: DbStructure -> Maybe ProcDescription -> S.Set FieldName -> AppConfig -> ApiRequest -> H.Transaction Response
app dbStructure proc cols conf apiRequest = app dbStructure proc cols conf apiRequest =
let rawContentTypes = (decodeContentType <$> configRawMediaTypes conf) `L.union` [ CTOctetStream, CTTextPlain ] in
case responseContentTypeOrError (iAccepts apiRequest) rawContentTypes (iAction apiRequest) (iTarget apiRequest) of case responseContentTypeOrError (iAccepts apiRequest) rawContentTypes (iAction apiRequest) (iTarget apiRequest) of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right contentType -> Right contentType ->
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
(ActionRead, TargetIdent qi, Nothing) -> (ActionRead headersOnly, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
let partsField = (,) <$> readSqlParts case readSqlParts tSchema tName of
<*> (binaryField contentType rawContentTypes =<< fldNames) in
case partsField of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right ((q, cq), bField) -> do Right (q, cq, bField) -> do
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount let cQuery = if estimatedCount
(contentType == CTTextCSV) bField 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 row <- H.statement () stm
let (tableTotal, queryTotal, _ , body) = row let (tableTotal, queryTotal, _ , body, gucHeaders) = row
(status, contentRange) = rangeHeader queryTotal tableTotal case gucHeaders of
canonical = iCanonicalQS apiRequest Left _ -> return . errorResponseFor $ GucHeadersError
return $ Right ghdrs -> do
if contentType == CTSingularJSON && queryTotal /= 1 total <- if | plannedCount -> H.statement () explStm
then errorResponseFor . singularityError $ queryTotal | estimatedCount -> if tableTotal > (fromIntegral <$> maxRows)
else responseLBS status then do estTotal <- H.statement () explStm
[toHeader contentType, contentRange, pure $ if estTotal > tableTotal then estTotal else tableTotal
("Content-Location", else pure tableTotal
"/" <> toS (qiName qi) <> | otherwise -> pure tableTotal
if BS.null canonical then "" else "?" <> toS canonical let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal total
) headers = addHeadersIfNotIncluded (catMaybes [
] (toS body) 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) -> (ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
case mutateSqlParts tSchema tName of case mutateSqlParts tSchema tName of
@@ -138,32 +163,31 @@ app dbStructure proc cols conf apiRequest =
let pkCols = tablePKCols dbStructure tSchema tName let pkCols = tablePKCols dbStructure tSchema tName
stm = createWriteStatement sq mq stm = createWriteStatement sq mq
(contentType == CTSingularJSON) True (contentType == CTSingularJSON) True
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols (contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols pgVer
row <- H.statement (toS $ pjRaw pJson) stm row <- H.statement (toS $ pjRaw pJson) stm
let (_, queryTotal, fs, body) = extractQueryResult row let (_, queryTotal, fields, body, gucHeaders) = row
headers = catMaybes [ case gucHeaders of
if null fs Left _ -> return . errorResponseFor $ GucHeadersError
then Nothing Right ghdrs -> do
else Just (hLocation, "/" <> toS tName <> renderLocationFields fs) let
, if iPreferRepresentation apiRequest == Full (ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
then Just $ toHeader contentType then ([Just $ toHeader contentType, profileH], toS body)
else Nothing else ([], mempty)
, Just $ contentRangeH 1 0 $ headers = addHeadersIfNotIncluded (catMaybes ([
if shouldCount then Just queryTotal else Nothing if null fields
, if null pkCols then Nothing
then Nothing else Just $ locationH tName fields
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest , Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
] , if null pkCols && isNothing (iOnConflict apiRequest)
if contentType == CTSingularJSON then Nothing
&& queryTotal /= 1 else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
&& iPreferRepresentation apiRequest == Full ] ++ ctHeaders)) (unwrapGucHeader <$> ghdrs)
then do if contentType == CTSingularJSON && queryTotal /= 1
HT.condemn then do
return . errorResponseFor . singularityError $ queryTotal HT.condemn
else return . errorResponseFor . singularityError $ queryTotal
return . responseLBS status201 headers $ else
if iPreferRepresentation apiRequest == Full return $ responseLBS status201 headers rBody
then toS body else ""
(ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) -> (ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
case mutateSqlParts tSchema tName of case mutateSqlParts tSchema tName of
@@ -171,31 +195,28 @@ app dbStructure proc cols conf apiRequest =
Right (sq, mq) -> do Right (sq, mq) -> do
let stm = createWriteStatement sq mq let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTTextCSV) (contentType == CTSingularJSON) False (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) [] (iPreferRepresentation apiRequest) [] pgVer
row <- H.statement (toS $ pjRaw pJson) stm row <- H.statement (toS $ pjRaw pJson) stm
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body, gucHeaders) = row
case gucHeaders of
updateIsNoOp = S.null cols Left _ -> return . errorResponseFor $ GucHeadersError
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ Right ghdrs -> do
if shouldCount then Just queryTotal else Nothing let
minimalHeaders = [contentRangeHeader] updateIsNoOp = S.null cols
fullHeaders = toHeader contentType : minimalHeaders status | queryTotal == 0 && not updateIsNoOp = status404
| iPreferRepresentation apiRequest == Full = status200
status | queryTotal == 0 && not updateIsNoOp = status404 | otherwise = status204
| iPreferRepresentation apiRequest == Full = status200 contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing
| otherwise = status204 (ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
then ([Just $ toHeader contentType, profileH], toS body)
case (contentType, iPreferRepresentation apiRequest) of else ([], mempty)
(CTSingularJSON, Full) headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
| queryTotal == 1 -> return $ responseLBS status fullHeaders (toS body) if contentType == CTSingularJSON && queryTotal /= 1
| otherwise -> HT.condemn >> (return . errorResponseFor . singularityError) queryTotal then do
HT.condemn
(_, Full) -> return . errorResponseFor . singularityError $ queryTotal
return $ responseLBS status fullHeaders (toS body) else
return $ responseLBS status headers rBody
(_, _) ->
return $ responseLBS status minimalHeaders mempty
(ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just ProcessedJSON{pjRaw, pjType, pjKeys}) -> (ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just ProcessedJSON{pjRaw, pjType, pjKeys}) ->
case mutateSqlParts tSchema tName of case mutateSqlParts tSchema tName of
@@ -214,19 +235,22 @@ app dbStructure proc cols conf apiRequest =
else do else do
row <- H.statement (toS pjRaw) $ row <- H.statement (toS pjRaw) $
createWriteStatement sq mq (contentType == CTSingularJSON) False createWriteStatement sq mq (contentType == CTSingularJSON) False
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) [] (contentType == CTTextCSV) (iPreferRepresentation apiRequest) [] pgVer
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body, gucHeaders) = row
-- Makes sure the querystring pk matches the payload pk case gucHeaders of
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected Left _ -> return . errorResponseFor $ GucHeadersError
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done Right ghdrs -> do
if queryTotal /= 1 let headers = addHeadersIfNotIncluded (catMaybes [Just $ toHeader contentType, profileH]) (unwrapGucHeader <$> ghdrs)
then do (status, rBody) = if iPreferRepresentation apiRequest == Full then (status200, toS body) else (status204, mempty)
HT.condemn -- Makes sure the querystring pk matches the payload pk
return . errorResponseFor $ PutMatchingPkError -- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
else -- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
return $ if iPreferRepresentation apiRequest == Full if queryTotal /= 1
then responseLBS status200 [toHeader contentType] (toS body) then do
else responseLBS status204 [] "" HT.condemn
return . errorResponseFor $ PutMatchingPkError
else
return $ responseLBS status headers rBody
(ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) -> (ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
case mutateSqlParts tSchema tName of case mutateSqlParts tSchema tName of
@@ -235,65 +259,68 @@ app dbStructure proc cols conf apiRequest =
let stm = createWriteStatement sq mq let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTSingularJSON) False
(contentType == CTTextCSV) (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) [] (iPreferRepresentation apiRequest) [] pgVer
row <- H.statement mempty stm row <- H.statement mempty stm
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body, gucHeaders) = row
r = contentRangeH 1 0 $ case gucHeaders of
if shouldCount then Just queryTotal else Nothing Left _ -> return . errorResponseFor $ GucHeadersError
if contentType == CTSingularJSON Right ghdrs -> do
&& queryTotal /= 1 let
&& iPreferRepresentation apiRequest == Full status = if iPreferRepresentation apiRequest == Full then status200 else status204
then do contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
HT.condemn (ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
return . errorResponseFor . singularityError $ queryTotal then ([Just $ toHeader contentType, profileH], toS body)
else else ([], mempty)
return $ if iPreferRepresentation apiRequest == Full headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
then responseLBS status200 [toHeader contentType, r] (toS body) if contentType == CTSingularJSON
else responseLBS status204 [r] "" && queryTotal /= 1
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return $ responseLBS status headers rBody
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) -> (ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->
let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in
case mTable of case mTable of
Nothing -> return notFound Nothing -> return notFound
Just table -> Just table ->
let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in let allowH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET")
return $ responseLBS status200 [allOrigins, acceptH] "" allOrigins = ("Access-Control-Allow-Origin", "*") :: Header in
return $ responseLBS status200 [allOrigins, allowH] mempty
(ActionInvoke _, TargetProc qi _, Just pJson) -> (ActionInvoke invMethod, TargetProc qi@(QualifiedIdentifier tSchema pName) _, Just pJson) ->
let returnsScalar = case proc of let tName = fromMaybe pName $ procTableName =<< proc in
Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True case readSqlParts tSchema tName of
_ -> False
rpcBinaryField = if returnsScalar
then Right Nothing
else binaryField contentType rawContentTypes =<< fldNames
parts = (,) <$> readSqlParts <*> rpcBinaryField in
case parts of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right ((q, cq), bField) -> do Right (q, cq, bField) -> do
let singular = contentType == CTSingularJSON let
row <- H.statement (toS $ pjRaw pJson) $ preferParams = iPreferParameters apiRequest
callProc qi (specifiedProcArgs cols proc) returnsScalar q cq shouldCount pq = requestToCallProcQuery qi (specifiedProcArgs cols proc) returnsScalar preferParams
singular (iPreferSingleObjectParameter apiRequest) stm = callProcStatement returnsScalar pq q cq shouldCount (contentType == CTSingularJSON)
(contentType == CTTextCSV) (contentType == CTTextCSV) (contentType `elem` rawContentTypes) (preferParams == Just MultipleObjects)
(contentType `elem` rawContentTypes) bField bField pgVer
(pgVersion dbStructure) row <- H.statement (toS $ pjRaw pJson) stm
let (tableTotal, queryTotal, body, jsonHeaders) = let (tableTotal, queryTotal, body, gucHeaders) = row
fromMaybe (Just 0, 0, "[]", "[]") row case gucHeaders of
(status, contentRange) = rangeHeader queryTotal tableTotal
decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]
case decodedHeaders of
Left _ -> return . errorResponseFor $ GucHeadersError Left _ -> return . errorResponseFor $ GucHeadersError
Right hs -> Right ghdrs -> do
if singular && queryTotal /= 1 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 then do
HT.condemn HT.condemn
return . errorResponseFor . singularityError $ queryTotal 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 let host = configHost conf
port = toInteger $ configPort conf port = toInteger $ configPort conf
proxy = pickProxy $ toS <$> configProxyUri conf proxy = pickProxy $ toS <$> configOpenAPIProxyUri conf
uri Nothing = ("http", host, port, "/") uri Nothing = ("http", host, port, "/")
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b) uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
uri' = uri proxy uri' = uri proxy
@@ -301,43 +328,49 @@ 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)) 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 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 body <- encodeApi <$>
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body 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 _ -> return notFound
where where
notFound = responseLBS status404 [] "" notFound = responseLBS status404 [] ""
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header maxRows = configMaxRows conf
shouldCount = iPreferCount apiRequest exactCount = iPreferCount apiRequest == Just ExactCount
schema = toS $ configSchema conf estimatedCount = iPreferCount apiRequest == Just EstimatedCount
topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest plannedCount = iPreferCount apiRequest == Just PlannedCount
rangeHeader queryTotal tableTotal = shouldCount = exactCount || estimatedCount
let lower = rangeOffset topLevelRange topLevelRange = iTopLevelRange apiRequest
upper = lower + toInteger queryTotal - 1 returnsScalar = maybe False procReturnsScalar proc
contentRange = contentRangeH lower upper (toInteger <$> tableTotal) pgVer = pgVersion dbStructure
status = rangeStatus lower upper (toInteger <$> tableTotal) profileH = contentProfileH <$> iProfile apiRequest
in (status, contentRange)
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) proc apiRequest readSqlParts s t =
fldNames = fieldNames <$> readReq let
readDbRequest = DbRead <$> readReq readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
selectQuery = requestToQuery schema False <$> readDbRequest in
countQuery = requestToCountQuery schema <$> readDbRequest (,,) <$>
readSqlParts = (,) <$> selectQuery <*> countQuery (readRequestToQuery <$> readReq) <*>
mutationDbRequest s t = mutateRequest apiRequest t cols (tablePKCols dbStructure s t) =<< fldNames (readRequestToCountQuery <$> readReq) <*>
mutateSqlParts s t = (binaryField contentType rawContentTypes returnsScalar =<< readReq)
(,) <$> selectQuery
<*> (requestToQuery schema False . DbMutate <$> mutationDbRequest s t) mutateSqlParts s t =
rawContentTypes = let
(decodeContentType <$> configRawMediaTypes conf) `L.union` readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
[ CTOctetStream, CTTextPlain ] 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 :: [ContentType] -> [ContentType] -> Action -> Target -> Either Response ContentType
responseContentTypeOrError accepts rawContentTypes action target = serves contentTypesForRequest accepts responseContentTypeOrError accepts rawContentTypes action target = serves contentTypesForRequest accepts
where where
contentTypesForRequest = case action of contentTypesForRequest = case action of
ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionRead _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
++ rawContentTypes ++ rawContentTypes
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
@@ -345,7 +378,7 @@ responseContentTypeOrError accepts rawContentTypes action target = serves conten
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
++ rawContentTypes ++ rawContentTypes
++ [CTOpenAPI | tpIsRootSpec target] ++ [CTOpenAPI | tpIsRootSpec target]
ActionInspect -> [CTOpenAPI, CTApplicationJSON] ActionInspect _ -> [CTOpenAPI, CTApplicationJSON]
ActionInfo -> [CTTextCSV] ActionInfo -> [CTTextCSV]
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
serves sProduces cAccepts = serves sProduces cAccepts =
@@ -357,41 +390,34 @@ responseContentTypeOrError accepts rawContentTypes action target = serves conten
| If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that | If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that
| `?select=...` contains only one field other than `*` | `?select=...` contains only one field other than `*`
-} -}
binaryField :: ContentType -> [ContentType]-> [FieldName] -> Either Response (Maybe FieldName) binaryField :: ContentType -> [ContentType] -> Bool -> ReadRequest -> Either Response (Maybe FieldName)
binaryField ct rawContentTypes fldNames binaryField ct rawContentTypes isScalarProc readReq
| isScalarProc = Right Nothing
| ct `elem` rawContentTypes = | ct `elem` rawContentTypes =
let fieldName = headMay fldNames in let fieldName = headMay fldNames in
if length fldNames == 1 && fieldName /= Just "*" if length fldNames == 1 && fieldName /= Just "*"
then Right fieldName then Right fieldName
else Left . errorResponseFor $ BinaryFieldError ct else Left . errorResponseFor $ BinaryFieldError ct
| otherwise = Right Nothing | otherwise = Right Nothing
where
fldNames = fstFieldNames readReq
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString) locationH :: TableName -> [BS.ByteString] -> Header
splitKeyValue kv = (k, BS.tail v) locationH tName fields =
where (k, v) = BS.break (== '=') kv 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 contentLocationH :: TableName -> ByteString -> Header
renderLocationFields fields = contentLocationH tName qString =
renderSimpleQuery True $ map splitKeyValue fields ("Content-Location", "/" <> toS tName <> if BS.null qString then mempty else "?" <> toS qString)
rangeStatus :: Integer -> Integer -> Maybe Integer -> Status contentProfileH :: Schema -> Header
rangeStatus _ _ Nothing = status200 contentProfileH schema =
rangeStatus lower upper (Just total) ("Content-Profile", toS schema)
| 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, [], "")
+36 -13
View File
@@ -37,6 +37,7 @@ import Control.Lens (preview)
import Control.Monad (fail) import Control.Monad (fail)
import Crypto.JWT (StringOrURI, stringOrUri) import Crypto.JWT (StringOrURI, stringOrUri)
import Data.List (lookup) import Data.List (lookup)
import Data.List.NonEmpty (NonEmpty, fromList)
import Data.Scientific (floatingOrInteger) import Data.Scientific (floatingOrInteger)
import Data.Text (dropEnd, dropWhileEnd, import Data.Text (dropEnd, dropWhileEnd,
intercalate, lines, splitOn, intercalate, lines, splitOn,
@@ -46,8 +47,10 @@ import Data.Text.IO (hPutStrLn)
import Data.Version (versionBranch) import Data.Version (versionBranch)
import Development.GitRev (gitHash) import Development.GitRev (gitHash)
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..)) import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
import Numeric (readOct)
import Paths_postgrest (version) import Paths_postgrest (version)
import System.IO.Error (IOError) import System.IO.Error (IOError)
import System.Posix.Types (FileMode)
import Control.Applicative import Control.Applicative
import Data.Monoid import Data.Monoid
@@ -58,21 +61,22 @@ import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))
import PostgREST.Error (ApiRequestError (..)) import PostgREST.Error (ApiRequestError (..))
import PostgREST.Parsers (pRoleClaimKey) import PostgREST.Parsers (pRoleClaimKey)
import PostgREST.Types (JSPath, JSPathExp (..), import PostgREST.Types (JSPath, JSPathExp (..))
QualifiedIdentifier (..))
import Protolude hiding (concat, hPutStrLn, intercalate, null, import Protolude hiding (concat, hPutStrLn, intercalate, null,
take, (<>)) take, (<>))
-- | Config file settings for the server -- | Config file settings for the server
data AppConfig = AppConfig { data AppConfig = AppConfig {
configDatabase :: Text configDatabase :: Text
, configAnonRole :: Text , configAnonRole :: Text
, configProxyUri :: Maybe Text , configOpenAPIProxyUri :: Maybe Text
, configSchema :: Text , configSchemas :: NonEmpty Text
, configHost :: Text , configHost :: Text
, configPort :: Int , configPort :: Int
, configSocket :: Maybe Text , configSocket :: Maybe FilePath
, configSocketMode :: Either Text FileMode
, configJwtSecret :: Maybe B.ByteString , configJwtSecret :: Maybe B.ByteString
, configJwtSecretIsBase64 :: Bool , configJwtSecretIsBase64 :: Bool
@@ -87,7 +91,7 @@ data AppConfig = AppConfig {
, configRoleClaimKey :: Either ApiRequestError JSPath , configRoleClaimKey :: Either ApiRequestError JSPath
, configExtraSearchPath :: [Text] , configExtraSearchPath :: [Text]
, configRootSpec :: Maybe QualifiedIdentifier , configRootSpec :: Maybe Text
, configRawMediaTypes :: [B.ByteString] , configRawMediaTypes :: [B.ByteString]
} }
@@ -137,7 +141,7 @@ readOptions = do
-- Now read the actual config file -- Now read the actual config file
conf <- catches (C.load cfgPath) conf <- catches (C.load cfgPath)
[ Handler (\(ex :: IOError) -> exitErr $ "Cannot open config file:\n\t" <> show ex) [ 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 case C.runParser parseConfig conf of
@@ -147,16 +151,16 @@ readOptions = do
return appConf return appConf
where where
dbSchema = reqString "db-schema"
parseConfig = parseConfig =
AppConfig AppConfig
<$> reqString "db-uri" <$> reqString "db-uri"
<*> reqString "db-anon-role" <*> reqString "db-anon-role"
<*> optString "server-proxy-uri" <*> optString "server-proxy-uri"
<*> dbSchema <*> (fromList . splitOnCommas <$> reqValue "db-schema")
<*> (fromMaybe "!4" <$> optString "server-host") <*> (fromMaybe "!4" <$> optString "server-host")
<*> (fromMaybe 3000 <$> optInt "server-port") <*> (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") <*> (fmap encodeUtf8 <$> optString "jwt-secret")
<*> (fromMaybe False <$> optBool "secret-is-base64") <*> (fromMaybe False <$> optBool "secret-is-base64")
<*> parseJwtAudience "jwt-aud" <*> parseJwtAudience "jwt-aud"
@@ -168,9 +172,22 @@ readOptions = do
<*> (fmap (fmap coerceText) <$> C.subassocs "app.settings" C.value) <*> (fmap (fmap coerceText) <$> C.subassocs "app.settings" C.value)
<*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> optValue "role-claim-key") <*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> optValue "role-claim-key")
<*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path") <*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path")
<*> ((\x y -> QualifiedIdentifier x <$> y) <$> dbSchema <*> optString "root-spec") <*> optString "root-spec"
<*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types") <*> (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 :: C.Key -> C.Parser C.Config (Maybe StringOrURI)
parseJwtAudience k = parseJwtAudience k =
C.optional k C.string >>= \case C.optional k C.string >>= \case
@@ -183,6 +200,9 @@ readOptions = do
reqString :: C.Key -> C.Parser C.Config Text reqString :: C.Key -> C.Parser C.Config Text
reqString k = C.required k C.string 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 :: C.Key -> C.Parser C.Config (Maybe Text)
optString k = mfilter (/= "") <$> C.optional k C.string optString k = mfilter (/= "") <$> C.optional k C.string
@@ -250,13 +270,16 @@ readOptions = do
|## unix socket location |## unix socket location
|## if specified it takes precedence over server-port |## if specified it takes precedence over server-port
|# server-unix-socket = "/tmp/pgrst.sock" |# 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 |## base url for swagger output
|# server-proxy-uri = "" |# openapi-server-proxy-uri = ""
| |
|## choose a secret, JSON Web Key (or set) to enable JWT auth |## choose a secret, JSON Web Key (or set) to enable JWT auth
|## (use "@filename" to load from separate file) |## (use "@filename" to load from separate file)
|# jwt-secret = "foo" |# jwt-secret = "secret_with_at_least_32_characters"
|# secret-is-base64 = false |# secret-is-base64 = false
|# jwt-aud = "your_audience_claim" |# jwt-aud = "your_audience_claim"
| |
+185 -195
View File
@@ -14,248 +14,217 @@ A query tree is built in case of resource embedding. By inferring the relationsh
module PostgREST.DbRequestBuilder ( module PostgREST.DbRequestBuilder (
readRequest readRequest
, mutateRequest , mutateRequest
, fieldNames
) where ) where
import qualified Data.ByteString.Char8 as BS import qualified Data.HashMap.Strict as M
import qualified Data.HashMap.Strict as M import qualified Data.Set as S
import qualified Data.Set as S
import Control.Arrow ((***)) import Control.Arrow ((***))
import Control.Lens.Getter (view)
import Control.Lens.Tuple (_1)
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft)
import Data.Foldable (foldr1) import Data.Foldable (foldr1)
import Data.List (delete) import Data.List (delete)
import Data.Maybe (fromJust)
import Data.Text (isInfixOf) import Data.Text (isInfixOf)
import Text.Regex.TDFA ((=~))
import Unsafe (unsafeHead)
import Control.Applicative import Control.Applicative
import Data.Tree import Data.Tree
import Network.Wai import Network.Wai
import PostgREST.ApiRequest (Action (..), ApiRequest (..), import PostgREST.ApiRequest (Action (..), ApiRequest (..))
PreferRepresentation (..),
PreferRepresentation (..), Target (..))
import PostgREST.Error (ApiRequestError (..), errorResponseFor) import PostgREST.Error (ApiRequestError (..), errorResponseFor)
import PostgREST.Parsers import PostgREST.Parsers
import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange) import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange)
import PostgREST.Types import PostgREST.Types
import Protolude hiding (from) import Protolude hiding (from)
readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest readRequest :: Schema -> TableName -> Maybe Integer -> [Relation] -> ApiRequest -> Either Response ReadRequest
readRequest maxRows allRels proc apiRequest = readRequest schema rootTableName maxRows allRels apiRequest =
mapLeft errorResponseFor $ mapLeft errorResponseFor $
treeRestrictRange maxRows =<< treeRestrictRange maxRows =<<
augumentRequestWithJoin schema relations =<< augumentRequestWithJoin schema rootRels =<<
addFiltersOrdersRanges apiRequest <*> addFiltersOrdersRanges apiRequest <*>
(buildReadRequest <$> pRequestSelect (iSelect apiRequest)) (initReadRequest rootName <$> pRequestSelect sel)
where where
action = iAction apiRequest sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param
(schema, rootTableName) = fromJust $ -- Make it safe (rootName, rootRels) = rootWithRels schema rootTableName allRels (iAction apiRequest)
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
_ -> 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 -- 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 issue #987. -- an alias like "table_depth", this is related to http://github.com/PostgREST/postgrest/issues/987.
buildReadRequest :: [Tree SelectItem] -> ReadRequest initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadRequest
buildReadRequest fieldTree = initReadRequest rootQi =
let rootDepth = 0 foldr (treeEntry rootDepth) initial
rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in where
foldr (treeEntry rootDepth) (Node (Select [] rootNodeName Nothing [] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree rootDepth = 0
where rootSchema = qiSchema rootQi
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest rootName = qiName rootQi
treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) = initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, rootDepth)) []
let nxtDepth = succ depth in treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
case fldForest of treeEntry depth (Node fld@((fn, _),_,alias, embedHint) fldForest) (Node (q, i) rForest) =
[] -> Node (q {select=fld:select q}, i) rForest let nxtDepth = succ depth in
_ -> Node (q, i) $ case fldForest of
foldr (treeEntry nxtDepth) (Node (Select [] fn Nothing [] [] [] [] allRange, (fn, Nothing, alias, relationDetail, nxtDepth)) []) fldForest:rForest [] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) $
relations :: [Relation] foldr (treeEntry nxtDepth)
relations = case action of (Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,
ActionCreate -> fakeSourceRelations ++ allRels (fn, Nothing, alias, embedHint, nxtDepth)) [])
ActionUpdate -> fakeSourceRelations ++ allRels fldForest:rForest
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
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request treeRestrictRange maxRows request = pure $ nodeRestrictRange maxRows <$> request
where where
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i) 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 = augumentRequestWithJoin schema allRels request =
addRelations schema allRels Nothing request addRels schema allRels Nothing request
>>= addJoinConditions schema Nothing >>= addJoinConditions Nothing
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest addRels :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
addRelations schema allRelations parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, relationDetail, depth)) forest) = addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, depth)) forest) =
case parentNode of case parentNode of
Just (Node (Select{from=parentNodeTable}, _) _) -> Just (Node (Select{from=parentNodeQi}, _) _) ->
let newFrom r = if tbl == nodeName then tableName (relTable r) else tbl 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 newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
rel :: Either ApiRequestError Relation rel = findRel schema allRels (qiName parentNodeQi) nodeName hint
rel = note (NoRelationBetween parentNodeTable nodeName) $ in
findRelation schema allRelations nodeName parentNodeTable relationDetail in
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest) Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
_ -> _ ->
let rn = (query, (nodeName, Just r, alias, Nothing, depth)) let rn = (query, (nodeName, Nothing, alias, Nothing, depth)) in
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
Node rn <$> updateForest (Just $ Node rn forest) Node rn <$> updateForest (Just $ Node rn forest)
where where
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest] 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 -- Finds a relationship between an origin and a target in the request: /origin?select=target(*)
findRelation schema allRelations nodeTableName parentNodeTableName relationDetail = -- If more than one relationship is found then the request is ambiguous and we return an error.
find (\Relation{relTable, relColumns, relFTable, relFColumns, relType, relLinkTable} -> -- In that case the request can be disambiguated by adding precision to the target or by using a hint: /origin?select=target!hint(*)
-- Both relation ends need to be on the exposed schema -- The elements will be matched according to these rules:
schema == tableSchema relTable && schema == tableSchema relFTable && -- origin = table / view
case relationDetail of -- target = table / view / constraint / column-from-origin
Nothing -> -- 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)
-- (request) => projects { ..., clients{...} } findRel :: Schema -> [Relation] -> NodeName -> NodeName -> Maybe EmbedHint -> Either ApiRequestError Relation
-- will match findRel schema allRels origin target hint =
-- (relation type) => parent case rel of
-- (entity) => clients {id} [] -> Left $ NoRelBetween origin target
-- (foriegn entity) => projects {client_id} [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 -- /projects?select=clients(*)
parentNodeTableName == tableName relFTable -- match relation foreign table name origin == tableName relTable && -- projects
) || target == tableName relFTable || -- clients
-- (request) => projects { ..., client_id{...} } -- /projects?select=projects_client_id_fkey(*)
-- will match (
-- (relation type) => parent origin == tableName relTable && -- projects
-- (entity) => clients {id} Just target == relConstraint -- projects_client_id_fkey
-- (foriegn entity) => projects {client_id} ) ||
( -- /projects?select=client_id(*)
parentNodeTableName == tableName relFTable && (
length relFColumns == 1 && origin == tableName relTable && -- projects
-- match common foreign key names(table_name_id, table_name_fk) to table_name matchFKSingleCol (Just target) relColumns -- client_id
(toS ("^" <> colName (unsafeHead relFColumns) <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS nodeTableName :: BS.ByteString) )
) && (
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
)
) )
) allRels
-- (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
-- previousAlias is only used for the case of self joins -- previousAlias is only used for the case of self joins
addJoinConditions :: Schema -> Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
addJoinConditions schema previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, relation, _, _, depth)) forest) = addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, depth)) forest) =
case relation of case rel of
Just Relation{relType=Root} -> Node node <$> updatedForest -- this is the root node Just r@Relation{relType=O2M} -> Node (augmentQuery r, nodeProps) <$> updatedForest
Just rel@Relation{relType=Parent} -> Node (augmentQuery rel, nodeProps) <$> updatedForest Just r@Relation{relType=M2O} -> Node (augmentQuery r, nodeProps) <$> updatedForest
Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest Just r@Relation{relType=M2M, relJunction=junction} ->
Just rel@Relation{relType=Many, relLinkTable=(Just linkTable)} -> case junction of
let rq = augmentQuery rel in Just Junction{junTable} ->
Node (rq{implicitJoins=tableName linkTable:implicitJoins rq}, nodeProps) <$> updatedForest let rq = augmentQuery r in
_ -> Left UnknownRelation Node (rq{implicitJoins=tableQi junTable:implicitJoins rq}, nodeProps) <$> updatedForest
Nothing ->
Left UnknownRelation
Nothing -> Node node <$> updatedForest
where where
newAlias = case isSelfJoin <$> relation of newAlias = case isSelfReference <$> rel of
Just True 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 | otherwise -> Nothing
_ -> Nothing _ -> Nothing
augmentQuery rel = augmentQuery r =
foldr foldr
(\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs}) (\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})
query{fromAlias=newAlias} query{fromAlias=newAlias}
(getJoinConditions previousAlias newAlias rel) (getJoinConditions previousAlias newAlias r)
updatedForest = mapM (addJoinConditions schema newAlias) forest updatedForest = mapM (addJoinConditions newAlias) forest
-- previousAlias and newAlias are used in the case of self joins -- previousAlias and newAlias are used in the case of self joins
getJoinConditions :: Maybe Alias -> Maybe Alias -> Relation -> [JoinCondition] 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 case typ of
Child -> O2M ->
zipWith (toJoinCondition tN ftN) cols fCols zipWith (toJoinCondition tN ftN) cols fCols
Parent -> M2O ->
zipWith (toJoinCondition tN ftN) cols fCols zipWith (toJoinCondition tN ftN) cols fCols
Many -> M2M -> case jun of
let ltN = maybe "" tableName lt in Just (Junction jt _ jc1 _ jc2) ->
zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2) let jtn = tableName jt in
Root -> witness zipWith (toJoinCondition tN jtn) cols jc1 ++ zipWith (toJoinCondition ftN jtn) fCols jc2
Nothing -> []
where where
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
toJoinCondition tb ftb c fc = toJoinCondition tb ftb c fc =
let qi1 = QualifiedIdentifier tSchema tb let qi1 = removeSourceCTESchema tSchema tb
qi2 = QualifiedIdentifier tSchema ftb in qi2 = removeSourceCTESchema tSchema ftb in
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) newAlias, colName c) JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, colName c)
(maybe qi2 (QualifiedIdentifier mempty) previousAlias, colName fc) (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 -> Either ApiRequestError (ReadRequest -> ReadRequest)
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [ 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 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 where
filters :: Either ApiRequestError [(EmbedPath, Filter)] filters :: Either ApiRequestError [(EmbedPath, Filter)]
@@ -278,7 +247,7 @@ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
(flts, logFrst) = (flts, logFrst) =
case action of case action of
ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest) ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)
ActionRead -> (iFilters apiRequest, iLogic apiRequest) ActionRead _ -> (iFilters apiRequest, iLogic apiRequest)
_ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest) _ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])] orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
orders = mapM pRequestOrder $ iOrder apiRequest orders = mapM pRequestOrder $ iOrder apiRequest
@@ -318,11 +287,15 @@ addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
where where
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
mutateRequest :: ApiRequest -> TableName -> S.Set FieldName -> [FieldName] -> [FieldName] -> Either Response MutateRequest mutateRequest :: Schema -> TableName -> ApiRequest -> S.Set FieldName -> [FieldName] -> ReadRequest -> Either Response MutateRequest
mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $ mutateRequest schema tName apiRequest cols pkCols readReq = mapLeft errorResponseFor $
case action of case action of
ActionCreate -> Right $ Insert tName cols ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings ActionCreate -> do
ActionUpdate -> Update tName cols <$> combinedLogic <*> pure returnings 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 -> ActionSingleUpsert ->
(\flts -> (\flts ->
if null (iLogic apiRequest) && if null (iLogic apiRequest) &&
@@ -331,14 +304,18 @@ mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
all (\case all (\case
Filter _ (OpExpr False (Op "eq" _)) -> True Filter _ (OpExpr False (Op "eq" _)) -> True
_ -> False) flts _ -> False) flts
then Insert tName cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings then Insert qi cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
else else
Left InvalidFilters) =<< filters Left InvalidFilters) =<< filters
ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings ActionDelete -> Delete qi <$> combinedLogic <*> pure returnings
_ -> Left UnsupportedVerb _ -> Left UnsupportedVerb
where where
qi = QualifiedIdentifier schema tName
action = iAction apiRequest 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 filters = map snd <$> mapM pRequestFilter mutateFilters
logic = map snd <$> mapM pRequestLogicTree logicFilters logic = map snd <$> mapM pRequestLogicTree logicFilters
combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters 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) (mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst) onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
fieldNames :: ReadRequest -> [FieldName] returningCols :: ReadRequest -> [FieldName]
fieldNames (Node (sel, _) forest) = returningCols rr@(Node _ forest) = returnings
map (fst . view _1) (select sel) ++ map colName fks
where where
fks = concatMap (fromMaybe [] . f) forest fldNames = fstFieldNames rr
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _, _)) _) = Just cols -- Without fkCols, when a mutateRequest to /projects?select=name,clients(name) occurs, the RETURNING SQL part would be
f _ = Nothing -- `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 -- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
-- they are later concatenated with AND in the QueryBuilder -- they are later concatenated with AND in the QueryBuilder
+131 -137
View File
@@ -40,34 +40,23 @@ import Unsafe (unsafeHead)
import Control.Applicative import Control.Applicative
import PostgREST.Private.Common
import PostgREST.Types import PostgREST.Types
import Protolude import Protolude
column :: HD.Value a -> HD.Row a getDbStructure :: [Schema] -> PgVersion -> HT.Transaction DbStructure
column = HD.column . HD.nonNullable 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) let rels = addM2MRels . addO2MRels $ addViewM2ORels srcCols m2oRels
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
cols' = addForeignKeys rels cols cols' = addForeignKeys rels cols
keys' = addViewPrimaryKeys syns keys keys' = addViewPrimaryKeys srcCols keys
return DbStructure { return DbStructure {
dbTables = tabs dbTables = tabs
@@ -102,13 +91,14 @@ decodeColumns tables =
<*> nullableColumn HD.text <*> nullableColumn HD.text
<*> nullableColumn HD.text <*> nullableColumn HD.text
decodeRelations :: [Table] -> [Column] -> HD.Result [Relation] decodeRels :: [Table] -> [Column] -> HD.Result [Relation]
decodeRelations tables cols = decodeRels tables cols =
mapMaybe (relationFromRow tables cols) <$> HD.rowList relRow mapMaybe (relFromRow tables cols) <$> HD.rowList relRow
where where
relRow = (,,,,,) relRow = (,,,,,,)
<$> column HD.text <$> column HD.text
<*> column HD.text <*> column HD.text
<*> column HD.text
<*> column (HD.array (HD.dimension replicateM (element HD.text))) <*> column (HD.array (HD.dimension replicateM (element HD.text)))
<*> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text
@@ -120,22 +110,30 @@ decodePks tables =
where where
pkRow = (,,) <$> column HD.text <*> column HD.text <*> column HD.text pkRow = (,,) <$> column HD.text <*> column HD.text <*> column HD.text
decodeSynonyms :: [Column] -> HD.Result [Synonym] decodeSourceColumns :: [Column] -> HD.Result [SourceColumn]
decodeSynonyms cols = decodeSourceColumns cols =
mapMaybe (synonymFromRow cols) <$> HD.rowList synRow mapMaybe (sourceColumnFromRow cols) <$> HD.rowList srcColRow
where where
synRow = (,,,,,) srcColRow = (,,,,,)
<$> column HD.text <*> column HD.text <$> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text <*> 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 = decodeProcs =
-- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance -- 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 where
tblRow = ProcDescription procRow = ProcDescription
<$> column HD.text <$> column HD.text
<*> column HD.text
<*> nullableColumn HD.text <*> nullableColumn HD.text
<*> (parseArgs <$> column HD.text) <*> (parseArgs <$> column HD.text)
<*> (parseRetType <*> (parseRetType
@@ -145,8 +143,8 @@ decodeProcs =
<*> column HD.char) <*> column HD.char)
<*> (parseVolatility <$> column HD.char) <*> (parseVolatility <$> column HD.char)
addName :: ProcDescription -> (Text, ProcDescription) addKey :: ProcDescription -> (QualifiedIdentifier, ProcDescription)
addName pd = (pdName pd, pd) addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd)
parseArgs :: Text -> [PgArg] parseArgs :: Text -> [PgArg]
parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',') parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')
@@ -179,31 +177,34 @@ decodeProcs =
| v == 's' = Stable | v == 's' = Stable
| otherwise = Volatile -- only 'v' can happen here | otherwise = Volatile -- only 'v' can happen here
allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription]) allProcs :: H.Statement [Schema] ProcsMap
allProcs = H.Statement (toS procsSqlQuery) (param HE.text) decodeProcs True 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 accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs True
where 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 :: SqlQuery
procsSqlQuery = [q| procsSqlQuery = [q|
SELECT p.proname as "proc_name", SELECT
d.description as "proc_description", pn.nspname as "proc_schema",
pg_get_function_arguments(p.oid) as "args", p.proname as "proc_name",
tn.nspname as "rettype_schema", d.description as "proc_description",
coalesce(comp.relname, t.typname) as "rettype_name", pg_get_function_arguments(p.oid) as "args",
p.proretset as "rettype_is_setof", tn.nspname as "rettype_schema",
t.typtype as "rettype_typ", coalesce(comp.relname, t.typname) as "rettype_name",
p.provolatile p.proretset as "rettype_is_setof",
t.typtype as "rettype_typ",
p.provolatile
FROM pg_proc p FROM pg_proc p
JOIN pg_namespace pn ON pn.oid = p.pronamespace JOIN pg_namespace pn ON pn.oid = p.pronamespace
JOIN pg_type t ON t.oid = p.prorettype JOIN pg_type t ON t.oid = p.prorettype
JOIN pg_namespace tn ON tn.oid = t.typnamespace JOIN pg_namespace tn ON tn.oid = t.typnamespace
LEFT JOIN pg_class comp ON comp.oid = t.typrelid LEFT JOIN pg_class comp ON comp.oid = t.typrelid
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
WHERE pn.nspname = $1
|] |]
schemaDescription :: H.Statement Schema (Maybe Text) schemaDescription :: H.Statement Schema (Maybe Text)
@@ -254,20 +255,20 @@ addForeignKeys rels = map addFk
addFk col = col { colFK = fk col } addFk col = col { colFK = fk col }
fk col = find (lookupFn col) rels >>= relToFk col fk col = find (lookupFn col) rels >>= relToFk col
lookupFn :: Column -> Relation -> Bool 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 relToFk col Relation{relColumns=cols, relFColumns=colsF} = do
pos <- L.elemIndex col cols pos <- L.elemIndex col cols
colF <- atMay colsF pos colF <- atMay colsF pos
return $ ForeignKey colF 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 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 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 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 t1.c1----t2.c2 t1.c1----------t2.c2
-> \________ -> \________
\ \
t2_view.c2 t2_view.c1 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.c1----t2.c2 t1.c1----------t2.c2
-> \________/ -> \________/
/ \ / \
t1_view.c1 t2_view.c2 t1_view.c1-------t2_view.c1 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] addViewM2ORels :: [SourceColumn] -> [Relation] -> [Relation]
addViewChildRelations allSyns = concatMap (\rel -> addViewM2ORels allSrcCols = concatMap (\rel ->
rel : case rel of rel : case rel of
Relation{relType=Child, relTable, relColumns, relFTable, relFColumns} -> Relation{relType=M2O, relTable, relColumns, relConstraint, relFTable, relFColumns} ->
let colSynsGroupedByView :: [Column] -> [[Synonym]] let srcColsGroupedByView :: [Column] -> [[SourceColumn]]
colSynsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $ srcColsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $
filter (\(c, _) -> c `elem` relCols) allSyns filter (\(c, _) -> c `elem` relCols) allSrcCols
colsSyns = colSynsGroupedByView relColumns relSrcCols = srcColsGroupedByView relColumns
fColsSyns = colSynsGroupedByView relFColumns relFSrcCols = srcColsGroupedByView relFColumns
getView :: [Synonym] -> Table getView :: [SourceColumn] -> Table
getView = colTable . snd . unsafeHead 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. -- 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 -- So we need to change the order of the SourceColumns 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 -- TODO: 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 srcCols `sortAccordingTo` cols = sortOn (\(k, _) -> L.lookup k $ zip cols [0::Int ..]) srcCols
viewTableChild = viewTableM2O =
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns) [ Relation (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)
relFTable relFColumns relConstraint relFTable relFColumns
Child Nothing Nothing Nothing M2O Nothing
| syns <- colsSyns, syns `allSynsOf` relColumns ] | srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns ]
tableViewChild = tableViewM2O =
[ Relation relTable relColumns [ Relation relTable relColumns
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns) relConstraint
Child Nothing Nothing Nothing (getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relFColumns)
| fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ] M2O Nothing
| fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relFColumns ]
viewViewChild = viewViewM2O =
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns) [ Relation (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns) relConstraint
Child Nothing Nothing Nothing (getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relFColumns)
| syns <- colsSyns, syns `allSynsOf` relColumns M2O Nothing
, fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ] | srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns
, fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relFColumns ]
in viewTableChild ++ tableViewChild ++ viewViewChild in viewTableM2O ++ tableViewM2O ++ viewViewM2O
_ -> []) _ -> [])
addParentRelations :: [Relation] -> [Relation] addO2MRels :: [Relation] -> [Relation]
addParentRelations = concatMap (\rel@(Relation t c ft fc _ _ _ _) -> [rel, Relation ft fc t c Parent Nothing Nothing Nothing]) addO2MRels = concatMap (\rel@(Relation t c cn ft fc _ _) -> [rel, Relation ft fc cn t c O2M Nothing])
addManyToManyRelations :: [Relation] -> [Relation] addM2MRels :: [Relation] -> [Relation]
addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation links) addM2MRels rels = rels ++ addMirrorRel (mapMaybe junction2Rel junctions)
where 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 -> Text
groupFn Relation{relTable=Table{tableSchema=s, tableName=t}} = s <> "_" <> t groupFn Relation{relTable=Table{tableSchema=s, tableName=t}} = s <> "_" <> t
-- Reference : https://wiki.haskell.org/99_questions/Solutions/26 -- Reference : https://wiki.haskell.org/99_questions/Solutions/26
@@ -346,19 +349,20 @@ addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation
combinations 0 _ = [ [] ] combinations 0 _ = [ [] ]
combinations n xs = [ y:ys | y:xs' <- tails xs combinations n xs = [ y:ys | y:xs' <- tails xs
, ys <- combinations (n-1) 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]) junction2Rel [
link2Relation [ Relation{relTable=jt, relColumns=jc1, relConstraint=const1, relFTable=t, relFColumns=c},
Relation{relTable=lt, relColumns=lc1, relFTable=t, relFColumns=c}, Relation{ relColumns=jc2, relConstraint=const2, relFTable=ft, relFColumns=fc}
Relation{ relColumns=lc2, 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 | 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 :: [SourceColumn] -> [PrimaryKey] -> [PrimaryKey]
addViewPrimaryKeys syns = concatMap (\pk -> addViewPrimaryKeys srcCols = concatMap (\pk ->
let viewPks = (\(_, viewCol) -> PrimaryKey{pkTable=colTable viewCol, pkName=colName viewCol}) <$> 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) pk : viewPks)
allTables :: H.Statement () [Table] allTables :: H.Statement () [Table]
@@ -384,9 +388,9 @@ allTables =
GROUP BY table_schema, table_name, insertable GROUP BY table_schema, table_name, insertable
ORDER BY table_schema, table_name |] ORDER BY table_schema, table_name |]
allColumns :: [Table] -> H.Statement Schema [Column] allColumns :: [Table] -> H.Statement [Schema] [Column]
allColumns tabs = allColumns tabs =
H.Statement sql (param HE.text) (decodeColumns tabs) True H.Statement sql (arrayParam HE.text) (decodeColumns tabs) True
where where
sql = [q| sql = [q|
SELECT DISTINCT SELECT DISTINCT
@@ -404,7 +408,7 @@ allColumns tabs =
array_to_string(enum_info.vals, ',') AS enum array_to_string(enum_info.vals, ',') AS enum
FROM ( 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 ( WITH key_columns AS (
SELECT SELECT
@@ -420,11 +424,11 @@ allColumns tabs =
pg_catalog.pg_class c, pg_catalog.pg_class c,
pg_catalog.pg_namespace n pg_catalog.pg_namespace n
WHERE WHERE
r.contype IN ('f', 'p') r.contype IN ('f', 'p', 'u')
AND c.relkind IN ('r', 'v', 'f', 'm') AND c.relkind IN ('r', 'v', 'f', 'm')
AND r.conrelid = c.oid AND r.conrelid = c.oid
AND c.relnamespace = n.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 -- CTE based on information_schema.columns
@@ -526,7 +530,7 @@ allColumns tabs =
AND a.attnum > 0 AND a.attnum > 0
AND NOT a.attisdropped AND NOT a.attisdropped
AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char", 'm'::"char"])) 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))*/ /*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
) )
SELECT 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 Text -> [Text]
parseEnum = maybe [] (split (==',')) parseEnum = maybe [] (split (==','))
allChildRelations :: [Table] -> [Column] -> H.Statement () [Relation] allM2ORels :: [Table] -> [Column] -> H.Statement () [Relation]
allChildRelations tabs cols = allM2ORels tabs cols =
H.Statement sql HE.noParams (decodeRelations tabs cols) True H.Statement sql HE.noParams (decodeRels tabs cols) True
where where
sql = [q| sql = [q|
SELECT ns1.nspname AS table_schema, SELECT ns1.nspname AS table_schema,
tab.relname AS table_name, tab.relname AS table_name,
conname AS constraint_name,
column_info.cols AS columns, column_info.cols AS columns,
ns2.nspname AS foreign_table_schema, ns2.nspname AS foreign_table_schema,
other.relname AS foreign_table_name, other.relname AS foreign_table_name,
column_info.refs AS foreign_columns column_info.refs AS foreign_columns
FROM pg_constraint, FROM pg_constraint,
LATERAL (SELECT array_agg(cols.attname) AS cols, LATERAL (
array_agg(cols.attnum) AS nums, SELECT array_agg(cols.attname) AS cols,
array_agg(refs.attname) AS refs array_agg(cols.attnum) AS nums,
FROM ( SELECT unnest(conkey) AS col, unnest(confkey) AS ref) k, array_agg(refs.attname) AS refs
LATERAL (SELECT * FROM pg_attribute FROM ( SELECT unnest(conkey) AS col, unnest(confkey) AS ref) k,
WHERE attrelid = conrelid AND attnum = col) LATERAL (SELECT * FROM pg_attribute WHERE attrelid = conrelid AND attnum = col) AS cols,
AS cols, LATERAL (SELECT * FROM pg_attribute WHERE attrelid = confrelid AND attnum = ref) AS refs) AS column_info,
LATERAL (SELECT * FROM pg_attribute LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = connamespace) AS ns1,
WHERE attrelid = confrelid AND attnum = ref) LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = conrelid) AS tab,
AS refs) LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = confrelid) AS other,
AS column_info, LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = other.relnamespace) AS ns2
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 WHERE confrelid != 0
ORDER BY (conrelid, column_info.nums) |] ORDER BY (conrelid, column_info.nums) |]
relationFromRow :: [Table] -> [Column] -> (Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation relFromRow :: [Table] -> [Column] -> (Text, Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation
relationFromRow allTabs allCols (rs, rt, rcs, frs, frt, frcs) = relFromRow allTabs allCols (rs, rt, cn, rcs, frs, frt, frcs) =
Relation <$> table <*> cols <*> tableF <*> colsF <*> pure Child <*> pure Nothing <*> pure Nothing <*> pure Nothing Relation <$> table <*> cols <*> pure (Just cn) <*> tableF <*> colsF <*> pure M2O <*> pure Nothing
where where
findTable s t = find (\tbl -> tableSchema tbl == s && tableName tbl == t) allTabs 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 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 pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
allSynonyms :: [Column] -> PgVersion -> H.Statement Schema [Synonym] allSourceColumns :: [Column] -> PgVersion -> H.Statement [Schema] [SourceColumn]
allSynonyms cols pgVer = allSourceColumns cols pgVer =
H.Statement sql (param HE.text) (decodeSynonyms cols) True H.Statement sql (arrayParam HE.text) (decodeSourceColumns cols) True
-- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569 -- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
where where
subselectRegex :: Text subselectRegex :: Text
@@ -743,7 +744,7 @@ allSynonyms cols pgVer =
from pg_class c from pg_class c
join pg_namespace n on n.oid = c.relnamespace join pg_namespace n on n.oid = c.relnamespace
join pg_rewrite r on r.ev_class = c.oid 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( removed_subselects as(
select select
@@ -791,13 +792,6 @@ allSynonyms cols pgVer =
where resorigtbl <> '0' where resorigtbl <> '0'
order by view_schema, view_name, view_colum_name; |] 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.Session PgVersion
getPgVersion = H.statement () $ H.Statement sql HE.noParams versionRow False getPgVersion = H.statement () $ H.Statement sql HE.noParams versionRow False
where where
+36 -6
View File
@@ -16,12 +16,12 @@ module PostgREST.Error (
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.Text as T
import qualified Hasql.Pool as P import qualified Hasql.Pool as P
import qualified Hasql.Session as H import qualified Hasql.Session as H
import qualified Network.HTTP.Types.Status as HT import qualified Network.HTTP.Types.Status as HT
import Data.Aeson ((.=)) import Data.Aeson ((.=))
import Data.Text (unwords)
import Network.Wai (Response, responseLBS) import Network.Wai (Response, responseLBS)
import Text.Read (readMaybe) import Text.Read (readMaybe)
@@ -48,8 +48,10 @@ data ApiRequestError
| InvalidRange | InvalidRange
| InvalidBody ByteString | InvalidBody ByteString
| ParseRequestError Text Text | ParseRequestError Text Text
| NoRelationBetween Text Text | NoRelBetween Text Text
| AmbiguousRelBetween Text Text [Relation]
| InvalidFilters | InvalidFilters
| UnacceptableSchema [Text]
| UnknownRelation -- Unreachable? | UnknownRelation -- Unreachable?
| UnsupportedVerb -- Unreachable? | UnsupportedVerb -- Unreachable?
deriving (Show, Eq) deriving (Show, Eq)
@@ -62,7 +64,9 @@ instance PgrstError ApiRequestError where
status UnknownRelation = HT.status404 status UnknownRelation = HT.status404
status ActionInappropriate = HT.status405 status ActionInappropriate = HT.status405
status (ParseRequestError _ _) = HT.status400 status (ParseRequestError _ _) = HT.status400
status (NoRelationBetween _ _) = HT.status400 status (NoRelBetween _ _) = HT.status400
status AmbiguousRelBetween{} = HT.status300
status (UnacceptableSchema _) = HT.status406
headers _ = [toHeader CTApplicationJSON] headers _ = [toHeader CTApplicationJSON]
@@ -77,13 +81,39 @@ instance JSON.ToJSON ApiRequestError where
"message" .= ("HTTP Range error" :: Text)] "message" .= ("HTTP Range error" :: Text)]
toJSON UnknownRelation = JSON.object [ toJSON UnknownRelation = JSON.object [
"message" .= ("Unknown relation" :: Text)] "message" .= ("Unknown relation" :: Text)]
toJSON (NoRelationBetween parent child) = JSON.object [ toJSON (NoRelBetween parent child) = JSON.object [
"message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)] "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 [ toJSON UnsupportedVerb = JSON.object [
"message" .= ("Unsupported HTTP verb" :: Text)] "message" .= ("Unsupported HTTP verb" :: Text)]
toJSON InvalidFilters = JSON.object [ toJSON InvalidFilters = JSON.object [
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)] "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 data PgError = PgError Authenticated P.UsageError
type Authenticated = Bool type Authenticated = Bool
@@ -241,7 +271,7 @@ instance JSON.ToJSON SimpleError where
"message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)] "message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)]
toJSON (SingularityError n) = JSON.object [ toJSON (SingularityError n) = JSON.object [
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text), "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 [ toJSON JwtTokenMissing = JSON.object [
"message" .= ("Server lacks JWT secret" :: Text)] "message" .= ("Server lacks JWT secret" :: Text)]
+20 -10
View File
@@ -10,6 +10,8 @@ module PostgREST.Middleware where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import Data.Scientific (FPFormat (..), formatScientific,
isInteger)
import qualified Hasql.Transaction as H import qualified Hasql.Transaction as H
import Network.Wai (Application, Response) import Network.Wai (Application, Response)
@@ -24,9 +26,8 @@ import PostgREST.Auth (JWTAttempt (..))
import PostgREST.Config (AppConfig (..), corsPolicy) import PostgREST.Config (AppConfig (..), corsPolicy)
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing), import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
errorResponseFor) errorResponseFor)
import PostgREST.QueryBuilder (pgFmtSetLocal, pgFmtSetLocalSearchPath, import PostgREST.QueryBuilder (setLocalQuery, setLocalSearchPathQuery)
unquoted) import Protolude hiding (head)
import Protolude
runWithClaims :: AppConfig -> JWTAttempt -> runWithClaims :: AppConfig -> JWTAttempt ->
(ApiRequest -> H.Transaction Response) -> (ApiRequest -> H.Transaction Response) ->
@@ -37,17 +38,19 @@ runWithClaims conf eClaims app req =
JWTInvalid JWTExpired -> return . errorResponseFor . JwtTokenInvalid $ "JWT expired" JWTInvalid JWTExpired -> return . errorResponseFor . JwtTokenInvalid $ "JWT expired"
JWTInvalid e -> return . errorResponseFor . JwtTokenInvalid . show $ e JWTInvalid e -> return . errorResponseFor . JwtTokenInvalid . show $ e
JWTClaims claims -> do 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 mapM_ H.sql customReqCheck
app req app req
where where
headersSql = pgFmtSetLocal "request.header." <$> iHeaders req methodSql = setLocalQuery mempty ("request.method", toS $ iMethod req)
cookiesSql = pgFmtSetLocal "request.cookie." <$> iCookies req pathSql = setLocalQuery mempty ("request.path", toS $ iPath req)
claimsSql = pgFmtSetLocal "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole] headersSql = setLocalQuery "request.header." <$> iHeaders req
appSettingsSql = pgFmtSetLocal mempty <$> configSettings conf 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 -> setRoleSql = maybeToList $ (\x ->
pgFmtSetLocal mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole setLocalQuery mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
setSearchPathSql = pgFmtSetLocalSearchPath $ configSchema conf : configExtraSearchPath conf setSearchPathSql = setLocalSearchPathQuery (iSchema req : configExtraSearchPath conf)
-- role claim defaults to anon if not specified in jwt -- role claim defaults to anon if not specified in jwt
claimsWithRole = M.union claims (M.singleton "role" anon) claimsWithRole = M.union claims (M.singleton "role" anon)
anon = JSON.String . toS $ configAnonRole conf anon = JSON.String . toS $ configAnonRole conf
@@ -58,3 +61,10 @@ defaultMiddle =
gzip def gzip def
. cors corsPolicy . cors corsPolicy
. staticPolicy (only [("favicon.ico", "static/favicon.ico")]) . 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
+21 -14
View File
@@ -10,7 +10,7 @@ module PostgREST.OpenAPI (
, pickProxy , pickProxy
) where ) where
import qualified Data.Set as Set import qualified Data.HashSet.InsOrd as Set
import Control.Arrow ((&&&)) import Control.Arrow ((&&&))
import Data.Aeson (decode, encode) import Data.Aeson (decode, encode)
@@ -55,7 +55,7 @@ makeTableDef pks (t, cs, _) =
let tn = tableName t in let tn = tableName t in
(tn, (mempty :: Schema) (tn, (mempty :: Schema)
& description .~ tableDescription t & description .~ tableDescription t
& type_ .~ SwaggerObject & type_ ?~ SwaggerObject
& properties .~ fromList (map (makeProperty pks) cs) & properties .~ fromList (map (makeProperty pks) cs)
& required .~ map colName (filter (not . colNullable) cs)) & required .~ map colName (filter (not . colNullable) cs))
@@ -84,13 +84,13 @@ makeProperty pks c = (colName c, Inline s)
& enum_ .~ e & enum_ .~ e
& format ?~ colType c & format ?~ colType c
& maxLength .~ (fromIntegral <$> colMaxLen c) & maxLength .~ (fromIntegral <$> colMaxLen c)
& type_ .~ toSwaggerType (colType c) & type_ ?~ toSwaggerType (colType c)
makeProcSchema :: ProcDescription -> Schema makeProcSchema :: ProcDescription -> Schema
makeProcSchema pd = makeProcSchema pd =
(mempty :: Schema) (mempty :: Schema)
& description .~ pdDescription pd & description .~ pdDescription pd
& type_ .~ SwaggerObject & type_ ?~ SwaggerObject
& properties .~ fromList (map makeProcProperty (pdArgs pd)) & properties .~ fromList (map makeProcProperty (pdArgs pd))
& required .~ map pgaName (filter pgaReq (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) makeProcProperty (PgArg n t _) = (n, Inline s)
where where
s = (mempty :: Schema) s = (mempty :: Schema)
& type_ .~ toSwaggerType t & type_ ?~ toSwaggerType t
& format ?~ t & format ?~ t
makePreferParam :: [Text] -> Param makePreferParam :: [Text] -> Param
@@ -109,7 +109,7 @@ makePreferParam ts =
& required ?~ False & required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamHeader & in_ .~ ParamHeader
& type_ .~ SwaggerString & type_ ?~ SwaggerString
& enum_ .~ decode (encode ts)) & enum_ .~ decode (encode ts))
makeProcParam :: ProcDescription -> [Referenced Param] makeProcParam :: ProcDescription -> [Referenced Param]
@@ -132,28 +132,35 @@ makeParamDefs ti =
& required ?~ False & required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery & 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) , ("order", (mempty :: Param)
& name .~ "order" & name .~ "order"
& description ?~ "Ordering" & description ?~ "Ordering"
& required ?~ False & required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery & in_ .~ ParamQuery
& type_ .~ SwaggerString)) & type_ ?~ SwaggerString))
, ("range", (mempty :: Param) , ("range", (mempty :: Param)
& name .~ "Range" & name .~ "Range"
& description ?~ "Limiting and Pagination" & description ?~ "Limiting and Pagination"
& required ?~ False & required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamHeader & in_ .~ ParamHeader
& type_ .~ SwaggerString)) & type_ ?~ SwaggerString))
, ("rangeUnit", (mempty :: Param) , ("rangeUnit", (mempty :: Param)
& name .~ "Range-Unit" & name .~ "Range-Unit"
& description ?~ "Limiting and Pagination" & description ?~ "Limiting and Pagination"
& required ?~ False & required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamHeader & in_ .~ ParamHeader
& type_ .~ SwaggerString & type_ ?~ SwaggerString
& default_ .~ decode "\"items\"")) & default_ .~ decode "\"items\""))
, ("offset", (mempty :: Param) , ("offset", (mempty :: Param)
& name .~ "offset" & name .~ "offset"
@@ -161,14 +168,14 @@ makeParamDefs ti =
& required ?~ False & required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery & in_ .~ ParamQuery
& type_ .~ SwaggerString)) & type_ ?~ SwaggerString))
, ("limit", (mempty :: Param) , ("limit", (mempty :: Param)
& name .~ "limit" & name .~ "limit"
& description ?~ "Limiting and Pagination" & description ?~ "Limiting and Pagination"
& required ?~ False & required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery & in_ .~ ParamQuery
& type_ .~ SwaggerString)) & type_ ?~ SwaggerString))
] ]
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) cs <> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) cs
| (t, cs, _) <- ti | (t, cs, _) <- ti
@@ -190,7 +197,7 @@ makeRowFilter tn c =
& required ?~ False & required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery & in_ .~ ParamQuery
& type_ .~ SwaggerString & type_ ?~ SwaggerString
& format ?~ colType c)) & format ?~ colType c))
makeRowFilters :: Text -> [Column] -> [(Text, Param)] makeRowFilters :: Text -> [Column] -> [(Text, Param)]
@@ -213,7 +220,7 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
& at 200 ?~ Inline ((mempty :: Response) & at 200 ?~ Inline ((mempty :: Response)
& description .~ "OK" & description .~ "OK"
& schema ?~ Inline (mempty & schema ?~ Inline (mempty
& type_ .~ SwaggerArray & type_ ?~ SwaggerArray
& items ?~ (SwaggerItemsObject $ Ref $ Reference $ tableName t) & items ?~ (SwaggerItemsObject $ Ref $ Reference $ tableName t)
) )
) )
+9 -5
View File
@@ -30,6 +30,10 @@ pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
pRequestSelect selStr = pRequestSelect selStr =
mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS 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 :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper) pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
where where
@@ -130,12 +134,12 @@ pRelationSelect :: Parser SelectItem
pRelationSelect = lexeme $ try ( do pRelationSelect = lexeme $ try ( do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField fld <- pField
relationDetail <- optionMaybe ( hint <- optionMaybe (
try ( char '!' *> pFieldName ) <|> try ( char '!' *> pFieldName) <|>
try ( char '.' *> pFieldName ) -- TODO deprecated, remove in next major version -- deprecated, remove in next major version
try ( char '.' *> pFieldName)
) )
return (fld, Nothing, alias, hint)
return (fld, Nothing, alias, relationDetail)
) )
pFieldSelect :: Parser SelectItem pFieldSelect :: Parser SelectItem
+25
View File
@@ -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
@@ -1,11 +1,12 @@
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-| {-|
Module : PostgREST.QueryBuilder.Private Module : PostgREST.Private.QueryFragment
Description : Helper functions for PostgREST.QueryBuilder. Description : Helper functions for PostgREST.QueryBuilder.
-}
module PostgREST.QueryBuilder.Private where
import qualified Data.ByteString.Char8 as BS Any function that outputs a SqlFragment should be in this module.
-}
module PostgREST.Private.QueryFragment where
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import Data.Maybe import Data.Maybe
import Data.Text (intercalate, import Data.Text (intercalate,
@@ -13,63 +14,20 @@ import Data.Text (intercalate,
toLower, unwords) toLower, unwords)
import qualified Data.Text as T (map, null, import qualified Data.Text as T (map, null,
takeWhile) 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 PostgREST.Types import PostgREST.Types
import Protolude hiding (cast, import Protolude hiding (cast,
intercalate, replace) intercalate, replace)
import Text.InterpolatedString.Perl6 (qc) import Text.InterpolatedString.Perl6 (qc)
column :: HD.Value a -> HD.Row a noLocationF :: SqlFragment
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[]" 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
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query -- 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`. -- 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. -- This happens because `unknown` relies on the context to determine the value type.
-- The error also happens on raw libpq used with C. -- The error also happens on raw libpq used with C.
ignoredBody :: SqlFragment ignoredBody :: SqlFragment
ignoredBody = "ignored_body AS (SELECT $1::text) " 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 -- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads
@@ -139,9 +97,6 @@ fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
n = qiName t n = qiName t
s = qiSchema t s = qiSchema t
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
unicodeStatement = H.Statement . T.encodeUtf8
emptyOnFalse :: Text -> Bool -> Text emptyOnFalse :: Text -> Bool -> Text
emptyOnFalse val cond = if cond then "" else val emptyOnFalse val cond = if cond then "" else val
@@ -195,9 +150,8 @@ pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper
(find ((==) . toLower $ v) ["null","true","false"]) (find ((==) . toLower $ v) ["null","true","false"])
pgFmtJoinCondition :: JoinCondition -> SqlFragment pgFmtJoinCondition :: JoinCondition -> SqlFragment
pgFmtJoinCondition (JoinCondition (qi, col1) (QualifiedIdentifier schema fTable, col2)) = pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) =
pgFmtColumn qi col1 <> " = " <> pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 col2
pgFmtColumn (removeSourceCTESchema schema fTable) col2
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")" pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"
@@ -225,13 +179,27 @@ pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
Nothing -> "" Nothing -> ""
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
pgFmtSetLocal :: Text -> (Text, Text) -> SqlFragment
pgFmtSetLocal prefix (k, v) =
"SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
pgFmtSetLocalSearchPath :: [Text] -> SqlFragment
pgFmtSetLocalSearchPath vals =
"SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
trimNullChars :: Text -> Text trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0') 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
+123 -95
View File
@@ -4,58 +4,38 @@
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
{-| {-|
Module : PostgREST.QueryBuilder Module : PostgREST.QueryBuilder
Description : PostgREST SQL generating functions. Description : PostgREST SQL queries generating functions.
This module provides functions to consume data types that This module provides functions to consume data types that
represent database objects (e.g. Relation, Schema, SqlQuery) represent database queries (e.g. ReadRequest, MutateRequest) and SqlFragment
and produces SQL Statements. to produce SqlQuery type outputs.
Any function that outputs a SQL fragment should be in this module.
-} -}
module PostgREST.QueryBuilder ( module PostgREST.QueryBuilder (
callProc readRequestToQuery
, createReadStatement , mutateRequestToQuery
, createWriteStatement , readRequestToCountQuery
, requestToQuery , requestToCallProcQuery
, requestToCountQuery , limitedQuery
, unquoted , setLocalQuery
, ResultsWithCount , setLocalSearchPathQuery
, pgFmtSetLocal
, pgFmtSetLocalSearchPath
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Set as S
import qualified Data.Set as S
import Data.Scientific (FPFormat (..), formatScientific, isInteger) import Data.Text (intercalate, unwords)
import Data.Text (intercalate, unwords) import Data.Tree (Tree (..))
import Data.Tree (Tree (..))
import Data.Maybe import Data.Maybe
import PostgREST.QueryBuilder.Private import PostgREST.Private.QueryFragment
import PostgREST.QueryBuilder.Procedure import PostgREST.RangeQuery (allRange, rangeLimit,
import PostgREST.QueryBuilder.ReadStatement rangeOffset)
import PostgREST.QueryBuilder.WriteStatement
import PostgREST.RangeQuery (allRange, rangeLimit,
rangeOffset)
import PostgREST.Types import PostgREST.Types
import Protolude hiding (cast, import Protolude hiding (cast, intercalate,
intercalate, replace) replace)
requestToCountQuery :: Schema -> DbRequest -> SqlQuery readRequestToQuery :: ReadRequest -> SqlQuery
requestToCountQuery _ (DbMutate _) = witness readRequestToQuery (Node (Select colSelects mainQi tblAlias implJoins logicForest joinConditions_ ordts range, _) forest) =
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)) =
unwords [ unwords [
"SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects), "SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
"FROM " <> intercalate ", " (tabl : implJs), "FROM " <> intercalate ", " (tabl : implJs),
@@ -63,51 +43,42 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbl tblAlias imp
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_)) ("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_))
`emptyOnFalse` (null logicForest && null joinConditions_), `emptyOnFalse` (null logicForest && null joinConditions_),
("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts, ("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 where
implJs = fromQi . QualifiedIdentifier schema <$> implJoins implJs = fromQi <$> implJoins
mainQi = removeSourceCTESchema schema tbl
tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias
qi = maybe mainQi (QualifiedIdentifier mempty) 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]) mutateRequestToQuery :: MutateRequest -> SqlQuery
getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s) mutateRequestToQuery (Insert mainQi iCols onConflct putConditions returnings) =
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)) =
unwords [ unwords [
"WITH " <> normalizedBody, "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 [ unwords [
"SELECT " <> cols <> " FROM", "SELECT " <> cols <> " FROM",
"json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ") _", "json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ") _",
-- Only used for PUT -- 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) -> ( maybe "" (\(oncDo, oncCols) -> (
"ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of "ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
IgnoreDuplicates -> IgnoreDuplicates ->
@@ -117,37 +88,94 @@ requestToQuery schema _ (DbMutate (Insert mainTbl iCols onConflct putConditions
then "DO NOTHING" then "DO NOTHING"
else "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols) else "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols)
) `emptyOnFalse` null oncCols) onConflct, ) `emptyOnFalse` null oncCols) onConflct,
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings] returningF mainQi returnings
]
where where
qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " $ pgFmtIdent <$> S.toList iCols cols = intercalate ", " $ pgFmtIdent <$> S.toList iCols
requestToQuery schema _ (DbMutate (Update mainTbl uCols logicForest returnings)) = mutateRequestToQuery (Update mainQi uCols logicForest returnings) =
if S.null uCols 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 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 else
unwords [ unwords [
"WITH " <> normalizedBody, "WITH " <> normalizedBody,
"UPDATE " <> fromQi qi <> " SET " <> cols, "UPDATE " <> fromQi mainQi <> " SET " <> cols,
"FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ")) _ ", "FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ")) _ ",
("WHERE " <> intercalate " AND " (pgFmtLogicTree qi <$> logicForest)) `emptyOnFalse` null logicForest, ("WHERE " <> intercalate " AND " (pgFmtLogicTree mainQi <$> logicForest)) `emptyOnFalse` null logicForest,
("RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)) `emptyOnFalse` null returnings returningF mainQi returnings
] ]
where where
qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols) cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)
requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) = mutateRequestToQuery (Delete mainQi logicForest returnings) =
unwords [ unwords [
"WITH " <> ignoredBody, "WITH " <> ignoredBody,
"DELETE FROM ", fromQi qi, "DELETE FROM ", fromQi mainQi,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest, ("WHERE " <> intercalate " AND " (map (pgFmtLogicTree mainQi) logicForest)) `emptyOnFalse` null logicForest,
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings returningF mainQi returnings
] ]
where
qi = QualifiedIdentifier schema mainTbl
unquoted :: JSON.Value -> Text requestToCallProcQuery :: QualifiedIdentifier -> [PgArg] -> Bool -> Maybe PreferParameters -> SqlQuery
unquoted (JSON.String t) = t requestToCallProcQuery qi pgArgs returnsScalar preferParams =
unquoted (JSON.Number n) = unwords [
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n "WITH",
unquoted (JSON.Bool b) = show b argsCTE,
unquoted v = toS $ JSON.encode v sourceBody ]
where
paramsAsSingleObject = preferParams == Just SingleObject
paramsAsMulitpleObjects = preferParams == Just MultipleObjects
(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)")
)
fmtArgs :: (PgArg -> SqlFragment) -> SqlFragment
fmtArgs argFrag = intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> argFrag a) <$> pgArgs)
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
callIt :: SqlFragment
callIt = fromQi qi <> "(" <> args <> ")"
-- | 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
]
limitedQuery :: SqlQuery -> Maybe Integer -> SqlQuery
limitedQuery query maxRows = query <> maybe mempty (\x -> " LIMIT " <> show x) maxRows
setLocalQuery :: Text -> (Text, Text) -> SqlQuery
setLocalQuery prefix (k, v) =
"SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
setLocalSearchPathQuery :: [Text] -> SqlQuery
setLocalSearchPathQuery vals =
"SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
-85
View File
@@ -1,85 +0,0 @@
module PostgREST.QueryBuilder.Procedure where
import Data.Maybe
import Data.Text (intercalate, unwords)
import qualified Hasql.Decoders as HD
import qualified Hasql.Encoders as HE
import qualified Hasql.Statement as H
import PostgREST.QueryBuilder.Private
import PostgREST.Types
import Protolude hiding (cast,
intercalate, replace)
import Text.InterpolatedString.Perl6 (qc)
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
@@ -1,32 +0,0 @@
module PostgREST.QueryBuilder.ReadStatement where
import Data.Maybe
import Data.Text (intercalate)
import qualified Hasql.Encoders as HE
import qualified Hasql.Statement as H
import PostgREST.QueryBuilder.Private
import PostgREST.Types
import Protolude hiding (cast,
intercalate, replace)
import Text.InterpolatedString.Perl6 (qc)
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
@@ -1,53 +0,0 @@
module PostgREST.QueryBuilder.WriteStatement where
import Data.Maybe
import Data.Text (intercalate, unwords)
import qualified Hasql.Encoders as HE
import qualified Hasql.Statement as H
import PostgREST.ApiRequest (PreferRepresentation (..))
import PostgREST.QueryBuilder.Private
import PostgREST.Types
import Protolude hiding (cast,
intercalate, replace)
import Text.InterpolatedString.Perl6 (qc)
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
+31 -1
View File
@@ -1,6 +1,6 @@
{-| {-|
Module : PostgREST.RangeQuery 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 ( module PostgREST.RangeQuery (
rangeParse rangeParse
@@ -11,6 +11,8 @@ module PostgREST.RangeQuery (
, rangeGeq , rangeGeq
, allRange , allRange
, NonnegRange , NonnegRange
, rangeStatusHeader
, contentRangeH
) where ) where
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
@@ -22,6 +24,7 @@ import Control.Applicative
import Data.Ranged.Boundaries import Data.Ranged.Boundaries
import Data.Ranged.Ranges import Data.Ranged.Ranges
import Network.HTTP.Types.Header import Network.HTTP.Types.Header
import Network.HTTP.Types.Status
import Protolude import Protolude
@@ -70,3 +73,30 @@ allRange = rangeGeq 0
rangeLeq :: Integer -> NonnegRange rangeLeq :: Integer -> NonnegRange
rangeLeq n = rangeLeq n =
Range BoundaryBelowAll (BoundaryAbove 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
+179
View File
@@ -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
+134 -48
View File
@@ -2,10 +2,14 @@
Module : PostgREST.Types Module : PostgREST.Types
Description : PostgREST common types and functions used by the rest of the modules Description : PostgREST common types and functions used by the rest of the modules
-} -}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.Types where module PostgREST.Types where
import Control.Lens.Getter (view)
import Control.Lens.Tuple (_1)
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BS (c2w) import qualified Data.ByteString.Internal as BS (c2w)
@@ -56,18 +60,53 @@ decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of
"*/*" -> CTAny "*/*" -> CTAny
ct' -> CTOther ct' ct' -> CTOther ct'
-- | 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 data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
instance Show PreferResolution where instance Show PreferResolution where
show MergeDuplicates = "resolution=merge-duplicates" show MergeDuplicates = "resolution=merge-duplicates"
show IgnoreDuplicates = "resolution=ignore-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 { data DbStructure = DbStructure {
dbTables :: [Table] dbTables :: [Table]
, dbColumns :: [Column] , dbColumns :: [Column]
, dbRelations :: [Relation] , dbRelations :: [Relation]
, dbPrimaryKeys :: [PrimaryKey] , dbPrimaryKeys :: [PrimaryKey]
-- ProcDescription is a list because a function can be overloaded , dbProcs :: ProcsMap
, dbProcs :: M.HashMap Text [ProcDescription]
, pgVersion :: PgVersion , pgVersion :: PgVersion
} deriving (Show, Eq) } deriving (Show, Eq)
@@ -93,7 +132,8 @@ data ProcVolatility = Volatile | Stable | Immutable
deriving (Eq, Show, Ord) deriving (Eq, Show, Ord)
data ProcDescription = ProcDescription { data ProcDescription = ProcDescription {
pdName :: Text pdSchema :: Schema
, pdName :: Text
, pdDescription :: Maybe Text , pdDescription :: Maybe Text
, pdArgs :: [PgArg] , pdArgs :: [PgArg]
, pdReturnType :: RetType , pdReturnType :: RetType
@@ -102,18 +142,23 @@ data ProcDescription = ProcDescription {
-- Order by least number of args in the case of overloaded functions -- Order by least number of args in the case of overloaded functions
instance Ord ProcDescription where instance Ord ProcDescription where
ProcDescription name1 des1 args1 rt1 vol1 `compare` ProcDescription name2 des2 args2 rt2 vol2 ProcDescription schema1 name1 des1 args1 rt1 vol1 `compare` ProcDescription schema2 name2 des2 args2 rt2 vol2
| name1 == name2 && length args1 < length args2 = LT | schema1 == schema2 && name1 == name2 && length args1 < length args2 = LT
| name1 == name2 && length args1 > length args2 = GT | schema2 == schema2 && name1 == name2 && length args1 > length args2 = GT
| otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2) | 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. 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. 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 = findProc qi payloadKeys paramsAsSingleObject allProcs =
case M.lookup (qiName qi) allProcs of case M.lookup qi allProcs of
Nothing -> Nothing Nothing -> Nothing
Just [proc] -> Just proc -- if it's not an overloaded function then immediately get the ProcDescription 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 Just procs -> find matches procs -- Handle overloaded functions case
@@ -135,10 +180,19 @@ specifiedProcArgs keys proc =
in in
(\k -> fromMaybe (PgArg k "text" True) (find ((==) k . pgaName) args)) <$> S.toList keys (\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 Schema = Text
type TableName = Text type TableName = Text
type SqlQuery = Text
type SqlFragment = Text
data Table = Table { data Table = Table {
tableSchema :: Schema tableSchema :: Schema
@@ -150,6 +204,9 @@ data Table = Table {
instance Eq Table where instance Eq Table where
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2 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) newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
data Column = data Column =
@@ -171,8 +228,8 @@ data Column =
instance Eq Column where instance Eq Column where
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2 Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
-- | A view column that refers to a table column -- | The source table column a view column refers to
type Synonym = (Column, ViewColumn) type SourceColumn = (Column, ViewColumn)
type ViewColumn = Column type ViewColumn = Column
data PrimaryKey = PrimaryKey { data PrimaryKey = PrimaryKey {
@@ -203,33 +260,48 @@ data OrderTerm = OrderTerm {
data QualifiedIdentifier = QualifiedIdentifier { data QualifiedIdentifier = QualifiedIdentifier {
qiSchema :: Schema qiSchema :: Schema
, qiName :: TableName , 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 "Relation"ship between two tables.
"What is the relation between the current node and the parent node". The order of the relColumns and relFColumns should be maintained to get the join conditions right.
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.
TODO merge relColumns and relFColumns to a tuple or Data.Bimap TODO merge relColumns and relFColumns to a tuple or Data.Bimap
-} -}
data Relation = Relation { data Relation = Relation {
relTable :: Table relTable :: Table
, relColumns :: [Column] , relColumns :: [Column]
, relFTable :: Table , relConstraint :: Maybe ConstraintName -- ^ Just on O2M/M2O, Nothing on M2M
, relFColumns :: [Column] , relFTable :: Table
, relType :: RelationType , relFColumns :: [Column]
-- The Link attrs are used when RelationType == Many , relType :: Cardinality
, relLinkTable :: Maybe Table , relJunction :: Maybe Junction -- ^ Junction for M2M Cardinality
, relLinkCols1 :: Maybe [Column]
, relLinkCols2 :: Maybe [Column]
} deriving (Show, Eq) } deriving (Show, Eq)
isSelfJoin :: Relation -> Bool -- | Junction table on an M2M relationship
isSelfJoin r = relType r /= Root && relTable r == relFTable r 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 = data PayloadJSON =
-- | Cached attributes of a JSON payload -- | Cached attributes of a JSON payload
@@ -333,24 +405,33 @@ type RpcQParam = (Text, Text)
Custom guc header, it's obtained by parsing the json in a: Custom guc header, it's obtained by parsing the json in a:
`SET LOCAL "response.headers" = '[{"Set-Cookie": ".."}]' `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 instance JSON.FromJSON GucHeader where
parseJSON (JSON.Object o) = case headMay (M.toList o) of 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 | otherwise -> mzero
_ -> mzero _ -> mzero
parseJSON _ = mzero parseJSON _ = mzero
toHeaders :: [GucHeader] -> [Header] unwrapGucHeader :: GucHeader -> Header
toHeaders = map $ \(GucHeader (k, v)) -> (CI.mk $ toS k, toS v) 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. 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. 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 EmbedHint)
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe RelationDetail) -- | 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"] -- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]
type EmbedPath = [Text] type EmbedPath = [Text]
data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq) data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)
@@ -359,11 +440,11 @@ data JoinCondition = JoinCondition (QualifiedIdentifier, FieldName)
data ReadQuery = Select { data ReadQuery = Select {
select :: [SelectItem] select :: [SelectItem]
, from :: TableName , from :: QualifiedIdentifier
-- | A table alias is used in case of self joins -- | A table alias is used in case of self joins
, fromAlias :: Maybe Alias , fromAlias :: Maybe Alias
-- | Only used for Many to Many joins. Parent and Child joins use explicit joins. -- | Only used for Many to Many joins. Parent and Child joins use explicit joins.
, implicitJoins :: [TableName] , implicitJoins :: [QualifiedIdentifier]
, where_ :: [LogicTree] , where_ :: [LogicTree]
, joinConditions :: [JoinCondition] , joinConditions :: [JoinCondition]
, order :: [OrderTerm] , order :: [OrderTerm]
@@ -372,31 +453,35 @@ data ReadQuery = Select {
data MutateQuery = data MutateQuery =
Insert { Insert {
in_ :: TableName in_ :: QualifiedIdentifier
, insCols :: S.Set FieldName , insCols :: S.Set FieldName
, onConflict :: Maybe (PreferResolution, [FieldName]) , onConflict :: Maybe (PreferResolution, [FieldName])
, where_ :: [LogicTree] , where_ :: [LogicTree]
, returning :: [FieldName] , returning :: [FieldName]
}| }|
Update { Update {
in_ :: TableName in_ :: QualifiedIdentifier
, updCols :: S.Set FieldName , updCols :: S.Set FieldName
, where_ :: [LogicTree] , where_ :: [LogicTree]
, returning :: [FieldName] , returning :: [FieldName]
}| }|
Delete { Delete {
in_ :: TableName in_ :: QualifiedIdentifier
, where_ :: [LogicTree] , where_ :: [LogicTree]
, returning :: [FieldName] , returning :: [FieldName]
} deriving (Show, Eq) } deriving (Show, Eq)
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
type ReadRequest = Tree ReadNode 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 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 { data PgVersion = PgVersion {
pgvNum :: Int32 pgvNum :: Int32
, pgvName :: Text , pgvName :: Text
@@ -433,6 +518,9 @@ pgVersion112 = PgVersion 110002 "11.2"
pgVersion114 :: PgVersion pgVersion114 :: PgVersion
pgVersion114 = PgVersion 110004 "11.4" pgVersion114 = PgVersion 110004 "11.4"
pgVersion121 :: PgVersion
pgVersion121 = PgVersion 120001 "12.1"
sourceCTEName :: SqlFragment sourceCTEName :: SqlFragment
sourceCTEName = "pg_source" sourceCTEName = "pg_source"
@@ -441,8 +529,6 @@ type JSPath = [JSPathExp]
-- | jspath expression, e.g. .property, .property[0] or ."property-dash" -- | jspath expression, e.g. .property, .property[0] or ."property-dash"
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show) data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
-- | Current database connection status data ConnectionStatus -- | Current database connection status data ConnectionStatus
data ConnectionStatus data ConnectionStatus
= NotConnected = NotConnected
+17 -18
View File
@@ -1,21 +1,20 @@
# stack.yaml is used for circle-ci tests. Profiling build fails on circleci # stack is used for circleci and appveyor CI builds
# with GHC 8.6, so we build with 8.4 for now. resolver: lts-14.3
resolver: lts-13.29
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
- jose-0.8.1.0
- text-printer-0.5.0.1
- network-2.7.0.1
ghc-options: ghc-options:
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints -optP-Wno-nonportable-include-path # -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: nix:
packages: [pcre, pkgconfig, postgresql, zlib] 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
+24 -73
View File
@@ -5,92 +5,43 @@
packages: packages:
- completed: - completed:
hackage: Ranged-sets-0.4.0@sha256:04bb4ce482fbdc052c9ee3346ba210986b33002b8c3440b714d62750144f86b6,1373 hackage: HsYAML-0.2.1.0@sha256:e4677daeba57f7a1e9a709a1f3022fe937336c91513e893166bd1f023f530d68,5311
pantry-tree: pantry-tree:
size: 566 size: 1340
sha256: ae6809a20be4da39729ac3c6e10b5c311b80628c767184da95903ecff6efc095 sha256: 21f61bf9cad31674126b106071dd9b852e408796aeffc90eec1792f784107eff
original: original:
hackage: Ranged-sets-0.4.0 hackage: HsYAML-0.2.1.0@sha256:e4677daeba57f7a1e9a709a1f3022fe937336c91513e893166bd1f023f530d68,5311
- completed: - completed:
hackage: configurator-pg-0.1.0.3@sha256:ddccf34fef0a5c4f1364ec4c6fda459f089a592e18662b0263ed85ea943c9c70,2885 hackage: HsYAML-aeson-0.2.0.0@sha256:04796abfc01cffded83f37a10e6edba4f0c0a15d45bef44fc5bb4313d9c87757,1791
pantry-tree: pantry-tree:
size: 1008 size: 234
sha256: 5eb16e5536e8bfd921286fb4c97eaaf0c1cb9bdbdf9630a7ffdf1cc64942b79b sha256: 67cc9ba17c79e71d3abdb465a3ee2825477856fff3b8b7d543cbbbefdae9a9d9
original: original:
hackage: configurator-pg-0.1.0.3 hackage: HsYAML-aeson-0.2.0.0@sha256:04796abfc01cffded83f37a10e6edba4f0c0a15d45bef44fc5bb4313d9c87757,1791
- completed: - completed:
hackage: http-types-0.12.3@sha256:f35229edb1bc7b3ae27f961b2407dadb5bfa69d43a8f5337ab46cdc79ca4afe9,2035 hackage: configurator-pg-0.2.0@sha256:08dcfadbe31e9e505d0bed1ab034105e1735141499734ad52d1c5ac980cde4a6,2939
pantry-tree: pantry-tree:
size: 833 size: 1748
sha256: c9b77e1ba204fffbe4e1be80412bc48e47440a07e4b7db4cdc77d573a3e21b9a sha256: 760eb12ee3d81b95b68ee10d5d85171b117826f44242b2749d48791efad6c891
original: original:
hackage: http-types-0.12.3 hackage: configurator-pg-0.2.0@sha256:08dcfadbe31e9e505d0bed1ab034105e1735141499734ad52d1c5ac980cde4a6,2939
- completed: - completed:
hackage: hasql-1.4@sha256:fcb1b0046c1e888b6c4cad53c972d23318dbc6ababa9ceb7d9cfd3b546732bcd,6515 hackage: hspec-wai-0.10.1@sha256:56dd9ec1d56f47ef1946f71f7cbf070e4c285f718cac1b158400ae5e7172ef47,2290
pantry-tree: pantry-tree:
size: 2567 size: 809
sha256: 2e253e7f3052ae4f838d26191355eb0c0b9f344e265b7dec50e13b799ef32450 sha256: 17af1c2e709cd84bfda066b9ebb04cdde7f92660c51a1f7401a1e9f766524e93
original: original:
hackage: hasql-1.4 hackage: hspec-wai-0.10.1@sha256:56dd9ec1d56f47ef1946f71f7cbf070e4c285f718cac1b158400ae5e7172ef47,2290
- completed: - completed:
hackage: hasql-pool-0.5.1@sha256:a98f2fc38f60eb037a8ac6c5e17591b090089e305f367219c4879812592aaafe,2436 hackage: hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
pantry-tree: pantry-tree:
size: 412 size: 349
sha256: 22e4cea8c23ea0eaa871388236c1d3e10349d993fd1bbc602f40aeb4909d6100 sha256: fb9e89b79cde3276baa484c860c6b9eeebdbc1a5c43301293351a25bc4c08e87
original: original:
hackage: hasql-pool-0.5.1 hackage: hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
- completed:
hackage: hasql-transaction-0.7.2@sha256:d6d8ceb0b32be75686fe31c4b5bc15c569a71023fc60394893508ea733e8714b,2835
pantry-tree:
size: 1028
sha256: 9bd8c7bf3e30d033192ff53c97e5f1f5e9c08ebfe9a1b3364ab0f9e8f2c2872e
original:
hackage: hasql-transaction-0.7.2
- completed:
hackage: text-builder-0.6.5.1@sha256:547f292707c7488c0fbee415adb5fa107d725b720f8697e966a3e3e00cac02cd,4210
pantry-tree:
size: 542
sha256: 5b2be8c9530d3460cadfe142429e0f82bb4b5c238737de473b6f9a25a33e232f
original:
hackage: text-builder-0.6.5.1
- completed:
hackage: deferred-folds-0.9.10.1@sha256:eb2634488e2a836da7d5aed9afd15ad2bead38817249f60bb68e459cc32fb0b0,2928
pantry-tree:
size: 958
sha256: b9132db4ffe78f11254871ed88071c5abe1ff41eeffcbec30ce484f24db81c19
original:
hackage: deferred-folds-0.9.10.1
- completed:
hackage: primitive-0.6.4.0@sha256:5b6a2c3cc70a35aabd4565fcb9bb1dd78fe2814a36e62428a9a1aae8c32441a1,2079
pantry-tree:
size: 1517
sha256: 5d5e591311664886e88ade3da6880c32adf0d1fe80c55f40a3c93bb91df8fdeb
original:
hackage: primitive-0.6.4.0
- completed:
hackage: jose-0.8.1.0@sha256:904e64203f0e074c4601529be2b57c94eec9fd588b19e16165391f8f9e84a6a0,3353
pantry-tree:
size: 1935
sha256: 8d5f80f184b61e89fedbf61d4d7b54ce85326fe67d8ec888117387522c06504d
original:
hackage: jose-0.8.1.0
- completed:
hackage: text-printer-0.5.0.1@sha256:9171204826a67c97bc3578968d8b3dcfbd81be08dfd18fef2af71eaf7b2737b1,1502
pantry-tree:
size: 461
sha256: 656053744f42551bc9cffd76746bfae77a249f54ca69497f6835e35312c7b5bb
original:
hackage: text-printer-0.5.0.1
- completed:
hackage: network-2.7.0.1@sha256:e8ab30822597c44f0520875699e005c8b284d19000bdacfddbc980c7dfa00bec,2823
pantry-tree:
size: 2313
sha256: 5729e7f6993505243e13fde01833e768024303052becb72f915d8cff1c20e177
original:
hackage: network-2.7.0.1
snapshots: snapshots:
- completed: - completed:
size: 500539 size: 523878
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/13/29.yaml url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/14/3.yaml
sha256: 006398c5e92d1d64737b7e98ae4d63987c36808814504d1451f56ebd98093f75 sha256: 470c46c27746a48c7c50f829efc0cf00112787a7804ee4ac7a27754658f6d92c
original: lts-13.29 original: lts-14.3
+1 -1
View File
@@ -4,7 +4,7 @@ ENV PATH /root/.local/bin:$PATH
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y wget libpq-dev pkg-config libpcre3 libpcre3-dev \ && 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/* \ && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \ && echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \
&& locale-gen \ && locale-gen \
+1 -1
View File
@@ -11,7 +11,7 @@ import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: PgVersion -> SpecWith Application spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = spec actualPgVersion =
describe "and/or params used for complex boolean logic" $ do describe "and/or params used for complex boolean logic" $ do
context "used with GET" $ do context "used with GET" $ do
+1 -1
View File
@@ -11,7 +11,7 @@ import Protolude
import SpecHelper import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = describe "server started with asymmetric JWK" $ spec = describe "server started with asymmetric JWK" $
-- this test will stop working 9999999999s after the UNIX EPOCH -- this test will stop working 9999999999s after the UNIX EPOCH
+1 -1
View File
@@ -11,7 +11,7 @@ import Protolude hiding (get)
import SpecHelper import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = describe "test handling of aud claims in JWT" $ do spec = describe "test handling of aud claims in JWT" $ do
-- this test will stop working 9999999999s after the UNIX EPOCH -- this test will stop working 9999999999s after the UNIX EPOCH
+1 -1
View File
@@ -12,7 +12,7 @@ import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: PgVersion -> SpecWith Application spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = describe "authorization" $ do spec actualPgVersion = describe "authorization" $ do
let single = ("Accept","application/vnd.pgrst.object+json") let single = ("Accept","application/vnd.pgrst.object+json")
+1 -1
View File
@@ -11,7 +11,7 @@ import Protolude
import SpecHelper import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = describe "server started with binary JWT secret" $ spec = describe "server started with binary JWT secret" $
-- this test will stop working 9999999999s after the UNIX EPOCH -- this test will stop working 9999999999s after the UNIX EPOCH
+6 -6
View File
@@ -19,9 +19,9 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get) import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "Queryiny in parallel" $ describe "Querying in parallel" $
it "should not raise 'transaction in progress' error" $ it "should not raise 'transaction in progress' error" $
raceTest 10 $ raceTest 10 $
get "/fakefake" get "/fakefake"
@@ -35,13 +35,13 @@ spec =
, matchHeaders = [] , matchHeaders = []
} }
raceTest :: Int -> WaiExpectation -> WaiExpectation raceTest :: Int -> WaiExpectation st -> WaiExpectation st
raceTest times = liftBaseDiscard go raceTest times = liftBaseDiscard go
where where
go test = void $ mapConcurrently (const test) [1..times] go test = void $ mapConcurrently (const test) [1..times]
instance MonadBaseControl IO WaiSession where instance MonadBaseControl IO (WaiSession st) where
type StM WaiSession a = StM Session a type StM (WaiSession st) a = StM Session a
liftBaseWith f = WaiSession $ liftBaseWith f = WaiSession $
liftBaseWith $ \runInBase -> liftBaseWith $ \runInBase ->
f $ \k -> runInBase (unWaiSession k) f $ \k -> runInBase (unWaiSession k)
@@ -49,5 +49,5 @@ instance MonadBaseControl IO WaiSession where
{-# INLINE liftBaseWith #-} {-# INLINE liftBaseWith #-}
{-# INLINE restoreM #-} {-# INLINE restoreM #-}
instance MonadBase IO WaiSession where instance MonadBase IO (WaiSession st) where
liftBase = liftIO liftBase = liftIO
+1 -1
View File
@@ -14,7 +14,7 @@ import Protolude
import SpecHelper import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "CORS" $ do describe "CORS" $ do
let preflightHeaders = [ let preflightHeaders = [
+24 -2
View File
@@ -5,11 +5,12 @@ import Network.Wai (Application)
import Network.HTTP.Types import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc import Text.Heredoc
import Protolude hiding (get) import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "Deleting" $ do describe "Deleting" $ do
context "existing record" $ 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")] "" request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|] `shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
it "can embed (parent) entities" $ 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}}]|] `shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"] , matchHeaders = ["Content-Range" <:> "*/*"]
@@ -62,3 +63,24 @@ spec =
context "totally unknown route" $ context "totally unknown route" $
it "fails with 404" $ it "fails with 404" $
request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 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
+434
View File
@@ -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] }
+1 -1
View File
@@ -9,7 +9,7 @@ import Test.Hspec.Wai.JSON
import Protolude import Protolude
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = describe "extra search path" $ do spec = describe "extra search path" $ do
it "finds the ltree <@ operator on the public schema" $ it "finds the ltree <@ operator on the public schema" $
+1 -1
View File
@@ -10,7 +10,7 @@ import Text.Heredoc
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper (acceptHdrs) import SpecHelper (acceptHdrs)
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = describe "When raw-media-types is set to \"text/html\"" $ spec = describe "When raw-media-types is set to \"text/html\"" $
it "can get raw output with Accept: text/html" $ it "can get raw output with Accept: text/html" $
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") "" request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
+128 -41
View File
@@ -20,7 +20,7 @@ import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: PgVersion -> SpecWith Application spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = do spec actualPgVersion = do
describe "Posting new record" $ do describe "Posting new record" $ do
context "disparate json types" $ do context "disparate json types" $ do
@@ -148,15 +148,6 @@ spec actualPgVersion = do
simpleBody p `shouldBe` [json| [] |] simpleBody p `shouldBe` [json| [] |]
simpleStatus p `shouldBe` created201 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 it "can post nulls" $ do
p <- request methodPost "/no_pk" p <- request methodPost "/no_pk"
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
@@ -260,36 +251,6 @@ spec actualPgVersion = do
, matchHeaders = [] , 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 context "POST with ?columns parameter" $ do
it "ignores json keys not included in ?columns" $ do it "ignores json keys not included in ?columns" $ do
request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")] request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")]
@@ -537,7 +498,7 @@ spec actualPgVersion = do
matchHeaders = ["Content-Range" <:> "*/*"] 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| {} |] request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
`shouldRespondWith` "[]" `shouldRespondWith` "[]"
{ {
@@ -588,3 +549,129 @@ spec actualPgVersion = do
liftIO $ do liftIO $ do
simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|] simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|]
simpleStatus p2 `shouldBe` created201 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
+8 -3
View File
@@ -7,11 +7,11 @@ import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import PostgREST.Types (PgVersion, pgVersion112) import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: PgVersion -> SpecWith Application spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = describe "json and jsonb operators" $ do spec actualPgVersion = describe "json and jsonb operators" $ do
context "Shaping response with select parameter" $ do context "Shaping response with select parameter" $ do
it "obtains a json subfield one level with casting" $ 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)" $ it "fails on bad casting (data of the wrong format)" $
get "/complex_items?select=settings->foo->>bar::integer" 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 = [] } { matchStatus = 400 , matchHeaders = [] }
it "obtains a json subfield two levels (string)" $ it "obtains a json subfield two levels (string)" $
+324
View File
@@ -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
}
+1 -1
View File
@@ -13,7 +13,7 @@ import Protolude
import SpecHelper import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = describe "server started without JWT secret" $ do spec = describe "server started without JWT secret" $ do
-- this test will stop working 9999999999s after the UNIX EPOCH -- this test will stop working 9999999999s after the UNIX EPOCH
+1 -1
View File
@@ -7,7 +7,7 @@ import Test.Hspec.Wai
import Protolude hiding (get) import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "Non existent api schema" $ do describe "Non existent api schema" $ do
it "succeeds when requesting root path" $ it "succeeds when requesting root path" $
+1 -1
View File
@@ -9,7 +9,7 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = describe "features supported on PostgreSQL 9.5" $ spec = describe "features supported on PostgreSQL 9.5" $
context "json array negative index" $ do context "json array negative index" $ do
it "can select with negative indexes" $ do it "can select with negative indexes" $ do
+97 -3
View File
@@ -1,6 +1,8 @@
module Feature.PgVersion96Spec where 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
import Test.Hspec.Wai import Test.Hspec.Wai
@@ -9,10 +11,10 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "features supported on PostgreSQL 9.6" $ do 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 it "succeeds setting the headers" $ do
get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id" get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"
`shouldRespondWith` [json|[{"id": 2}]|] `shouldRespondWith` [json|[{"id": 2}]|]
@@ -67,6 +69,98 @@ spec =
"Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/", "Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/",
"Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"]} "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 context "Use of the phraseto_tsquery function" $ do
it "finds matches" $ it "finds matches" $
get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith` get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`
+1 -1
View File
@@ -6,7 +6,7 @@ import Test.Hspec hiding (pendingWith)
import Protolude import Protolude
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "GET / with proxy" $ describe "GET / with proxy" $
it "returns a valid openapi spec with proxy" $ it "returns a valid openapi spec with proxy" $
+34 -5
View File
@@ -11,9 +11,9 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "Requesting many items with server limits enabled" $ do describe "Requesting many items with server limits(max-rows) enabled" $ do
it "restricts results" $ it "restricts results" $
get "/items" get "/items"
`shouldRespondWith` [json| [{"id":1},{"id":2}] |] `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
@@ -29,16 +29,45 @@ spec =
matchHeader "Content-Range" "0-0/*" matchHeader "Content-Range" "0-0/*"
simpleStatus r `shouldBe` ok200 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" 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}]}]|] `shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"] , matchHeaders = ["Content-Range" <:> "0-1/*"]
} }
it "limit is not applied to parent embeds" $ it "succeeds in getting parent embeds despite the limit, see #647" $
get "/tasks?select=id,project(id)&id=gt.5" get "/tasks?select=id,project:projects(id)&id=gt.5"
`shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|] `shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"] , 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"]
}
+33 -265
View File
@@ -4,17 +4,17 @@ import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleHeaders)) import Network.Wai.Test (SResponse (simpleHeaders))
import Network.HTTP.Types import Network.HTTP.Types
import Test.Hspec import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Text.Heredoc import Text.Heredoc
import PostgREST.Types (PgVersion, pgVersion112) import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: PgVersion -> SpecWith Application spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = do spec actualPgVersion = do
describe "Querying a table with a column called count" $ describe "Querying a table with a column called count" $
@@ -198,7 +198,7 @@ spec actualPgVersion = do
it "matches filtering nested items 2" $ it "matches filtering nested items 2" $
get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*" 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 { matchStatus = 400
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
@@ -265,19 +265,8 @@ spec actualPgVersion = do
[json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|] [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
{ matchHeaders = [matchContentTypeJson] } { 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" $ 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|[ [json|[
{"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}}, {"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},
{"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}}, {"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},
@@ -295,24 +284,11 @@ spec actualPgVersion = do
[json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|] [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]
{ matchHeaders = [matchContentTypeJson] } { 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" $ 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"}]}]|] [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] } { 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" $ it "requesting parents and filtering parent columns" $
get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith` get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|] [str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
@@ -351,21 +327,6 @@ spec actualPgVersion = do
[json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|] [json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
{ matchHeaders = [matchContentTypeJson] } { 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 describe "view embedding" $ do
it "can detect fk relations through views to tables in the public schema" $ it "can detect fk relations through views to tables in the public schema" $
get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200 get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200
@@ -373,8 +334,8 @@ spec actualPgVersion = do
it "can detect fk relations through materialized views to tables in the public schema" $ it "can detect fk relations through materialized views to tables in the public schema" $
get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200 get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200
it "can request parent without specifying primary key" $ it "can request two parents" $
get "/articleStars?select=createdAt,article(owner),user(name)&limit=1" `shouldRespondWith` 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"}}]|] [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
@@ -394,16 +355,16 @@ spec actualPgVersion = do
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "detects parent relations when having many views of a private table" $ do 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" } } ]|] [json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]
{ matchHeaders = [matchContentTypeJson] } { 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" } } ]|] [json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]
{ matchHeaders = [matchContentTypeJson] } { 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" } } ]|] [json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|]
{ matchHeaders = [matchContentTypeJson] } { 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" } } ]|] [json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
@@ -466,7 +427,7 @@ spec actualPgVersion = do
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "can embed a view that has group by" $ 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| [json|
[{"number_of_projects":1,"client":null}, [{"number_of_projects":1,"client":null},
{"number_of_projects":2,"client":{"name":"Microsoft"}}, {"number_of_projects":2,"client":{"name":"Microsoft"}},
@@ -478,49 +439,6 @@ spec actualPgVersion = do
[json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |] [json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
describe "path fixed" $ do
it "works when requesting children 2 levels" $
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] }
it "works with parent relation" $
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 "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!sender(name,sent),recipient:person_detail!recipient(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!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] }
-- TODO Remove in next major version(7.0)
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 describe "aliased embeds" $ do
it "works with child relation" $ 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` 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`
@@ -578,114 +496,6 @@ spec actualPgVersion = do
{ "id":4,"childs":[]} { "id":4,"childs":[]}
]|] { matchHeaders = [matchContentTypeJson] } ]|] { 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!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] }
describe "ordering response" $ do describe "ordering response" $ do
it "by a column asc" $ it "by a column asc" $
get "/items?id=lte.2&order=id.asc" get "/items?id=lte.2&order=id.asc"
@@ -781,10 +591,6 @@ spec actualPgVersion = do
get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith` 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"}}]|] [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 context "order syntax errors" $ do
it "gives meaningful error messages when asc/desc/nulls{first,last} are misspelled" $ do it "gives meaningful error messages when asc/desc/nulls{first,last} are misspelled" $ do
get "/items?order=id.ac" `shouldRespondWith` get "/items?order=id.ac" `shouldRespondWith`
@@ -914,11 +720,6 @@ spec actualPgVersion = do
[json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |] [json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
{ matchHeaders = [matchContentTypeJson] } { 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" $ 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` get "/Server%20Today?select=Just%20A%20Server%20Model&Just%20A%20Server%20Model=like.*91*" `shouldRespondWith`
[json|[ [json|[
@@ -967,58 +768,6 @@ spec actualPgVersion = do
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] , 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 describe "values with quotes in IN and NOT IN" $ do
it "succeeds when only quoted values are present" $ do it "succeeds when only quoted values are present" $ do
get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith` get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`
@@ -1084,8 +833,12 @@ spec actualPgVersion = do
it "only returns an empty result set if the in value is empty" $ it "only returns an empty result set if the in value is empty" $
get "/items_with_different_col_types?int_data=in.( ,3,4)" 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: \"\""} |] [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
)
{ matchStatus = 400 { matchStatus = 400
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
@@ -1112,3 +865,18 @@ spec actualPgVersion = do
it "cannot use ltree(in public schema) extension operators if no extra search path added" $ 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 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
+66 -2
View File
@@ -19,7 +19,7 @@ defaultRange = [json| { "min": 0, "max": 15 } |]
emptyRange :: BL.ByteString emptyRange :: BL.ByteString
emptyRange = [json| { "min": 2, "max": 2 } |] emptyRange = [json| { "min": 2, "max": 2 } |]
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = do spec = do
describe "POST /rpc/getitemrange" $ do describe "POST /rpc/getitemrange" $ do
context "without range headers" $ do context "without range headers" $ do
@@ -155,12 +155,17 @@ spec = do
, matchHeaders = ["Content-Range" <:> "0-0/*"] , 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" get "/items?select=id&order=id.asc&limit=3&offset=2"
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|] `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "2-4/*"] , 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" $ it "succeeds if offset equals 0 as a no-op" $
get "/items?select=id&offset=0" get "/items?select=id&offset=0"
@@ -192,6 +197,65 @@ spec = do
, matchHeaders = [matchContentTypeJson] , 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 "with range headers" $ do
context "of acceptable range" $ do context "of acceptable range" $ do
it "succeeds with partial content" $ do it "succeeds with partial content" $ do
+1 -1
View File
@@ -10,7 +10,7 @@ import Test.Hspec.Wai.JSON
import Protolude import Protolude
import SpecHelper (acceptHdrs) import SpecHelper (acceptHdrs)
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = describe "When raw-media-types config variable is missing or left empty" $ do 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" 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" chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
+1 -1
View File
@@ -11,7 +11,7 @@ import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "root spec function" $ do describe "root spec function" $ do
it "accepts application/openapi+json" $ it "accepts application/openapi+json" $
+139 -12
View File
@@ -17,7 +17,7 @@ import PostgREST.Types (PgVersion, pgVersion100, pgVersion109,
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: PgVersion -> SpecWith Application spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = spec actualPgVersion =
describe "remote procedure call" $ do describe "remote procedure call" $ do
context "a proc that returns a set" $ do context "a proc that returns a set" $ do
@@ -34,6 +34,12 @@ spec actualPgVersion =
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/*"] , 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 it "includes total count if requested" $ do
request methodPost "/rpc/getitemrange" request methodPost "/rpc/getitemrange"
@@ -49,6 +55,12 @@ spec actualPgVersion =
{ matchStatus = 206 { matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-0/2"] , 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 it "returns proper json" $ do
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith` post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
@@ -72,6 +84,12 @@ spec actualPgVersion =
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"] , 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 context "unknown function" $ do
it "returns 404" $ it "returns 404" $
@@ -140,10 +158,10 @@ spec actualPgVersion =
context "foreign entities embedding" $ do context "foreign entities embedding" $ do
it "can embed if related tables are in the exposed schema" $ 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}]}]|] [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
{ matchHeaders = [matchContentTypeJson] } { 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}]}]|] [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
@@ -161,6 +179,29 @@ spec actualPgVersion =
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|] `shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
{ matchHeaders = [matchContentTypeJson] } { 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" $ context "a proc that returns an empty rowset" $
it "returns empty json array" $ do it "returns empty json array" $ do
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith` post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
@@ -415,24 +456,34 @@ spec actualPgVersion =
it "ignores json keys not included in ?columns" $ it "ignores json keys not included in ?columns" $
post "/rpc/sayhello?columns=name" 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"|] [json|"Hello, John"|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
context "bulk RPC" $ do it "only takes the first object in case of array of objects payload" $
it "works with a scalar function an returns a json array" $
post "/rpc/add_them" post "/rpc/add_them"
[json|[ [json|[
{"a": 1, "b": 2}, {"a": 1, "b": 2},
{"a": 4, "b": 6}, {"a": 4, "b": 6},
{"a": 100, "b": 200} {"a": 100, "b": 200} ]|]
]|] `shouldRespondWith` `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| [json|
[3, 10, 300] [3, 10, 300]
|] { matchHeaders = [matchContentTypeJson] } |] { matchHeaders = [matchContentTypeJson] }
it "works with a scalar function an returns a json array when posting CSV" $ 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" "a,b\n1,2\n4,6\n100,200"
`shouldRespondWith` `shouldRespondWith`
[json| [json|
@@ -443,11 +494,11 @@ spec actualPgVersion =
} }
it "works with a non-scalar result" $ 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|[ [json|[
{"id": 1}, {"id": 1},
{"id": 5} {"id": 5} ]|]
]|] `shouldRespondWith` `shouldRespondWith`
[json| [json|
[{"id":1,"name":"Windows 7"}, [{"id":1,"name":"Windows 7"},
{"id":2,"name":"Windows 10"}, {"id":2,"name":"Windows 10"},
@@ -455,6 +506,82 @@ spec actualPgVersion =
{"id":4,"name":"OSX"}] {"id":4,"name":"OSX"}]
|] { matchHeaders = [matchContentTypeJson] } |] { 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 "binary output" $ do
context "Proc that returns scalar" $ do context "Proc that returns scalar" $ do
it "can query without selecting column" $ it "can query without selecting column" $
+85 -18
View File
@@ -13,7 +13,7 @@ import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "Requesting singular json object" $ do describe "Requesting singular json object" $ do
let pgrstObj = "application/vnd.pgrst.object+json" let pgrstObj = "application/vnd.pgrst.object+json"
@@ -56,7 +56,7 @@ spec =
_ <- post "/addresses" [json| { id: 98, address: "xxx" } |] _ <- post "/addresses" [json| { id: 98, address: "xxx" } |]
_ <- post "/addresses" [json| { id: 99, address: "yyy" } |] _ <- post "/addresses" [json| { id: 99, address: "yyy" } |]
p <- request methodPatch "/addresses?id=gt.0" p <- request methodPatch "/addresses?id=gt.0"
[("Prefer", "return=representation"), singular] [singular]
[json| { address: "zzz" } |] [json| { address: "zzz" } |]
liftIO $ do liftIO $ do
simpleStatus p `shouldBe` notAcceptable406 simpleStatus p `shouldBe` notAcceptable406
@@ -65,7 +65,29 @@ spec =
-- the rows should not be updated, either -- the rows should not be updated, either
get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|] 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" $ 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" request methodPatch "/items?id=gt.0&id=lt.0"
[("Prefer", "return=representation"), singular] [json|{"id":1}|] [("Prefer", "return=representation"), singular] [json|{"id":1}|]
`shouldRespondWith` `shouldRespondWith`
@@ -79,20 +101,20 @@ spec =
p <- request methodPost p <- request methodPost
"/addresses" "/addresses"
[("Prefer", "return=representation"), singular] [("Prefer", "return=representation"), singular]
[json| [ { id: 100, address: "xxx" } ] |] [json| [ { id: 102, address: "xxx" } ] |]
liftIO $ simpleBody p `shouldBe` [str|{"id":100,"address":"xxx"}|] liftIO $ simpleBody p `shouldBe` [str|{"id":102,"address":"xxx"}|]
it "works for one row even with return=minimal" $ do it "works for one row even with return=minimal" $ do
request methodPost "/addresses" request methodPost "/addresses"
[("Prefer", "return=minimal"), singular] [("Prefer", "return=minimal"), singular]
[json| [ { id: 101, address: "xxx" } ] |] [json| [ { id: 103, address: "xxx" } ] |]
`shouldRespondWith` "" `shouldRespondWith` ""
{ matchStatus = 201 { matchStatus = 201
, matchHeaders = ["Content-Range" <:> "*/*"] , matchHeaders = ["Content-Range" <:> "*/*"]
} }
-- and the element should exist -- and the element should exist
get "/addresses?id=eq.101" get "/addresses?id=eq.103"
`shouldRespondWith` [str|[{"id":101,"address":"xxx"}]|] `shouldRespondWith` [str|[{"id":103,"address":"xxx"}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = [] , matchHeaders = []
} }
@@ -100,23 +122,47 @@ spec =
it "raises an error when attempting to create multiple entities" $ do it "raises an error when attempting to create multiple entities" $ do
p <- request methodPost p <- request methodPost
"/addresses" "/addresses"
[("Prefer", "return=representation"), singular] [singular]
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |] [json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
liftIO $ simpleStatus p `shouldBe` notAcceptable406 liftIO $ simpleStatus p `shouldBe` notAcceptable406
-- the rows should not exist, either -- the rows should not exist, either
get "/addresses?id=eq.200" `shouldRespondWith` "[]" 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" request methodPost "/addresses"
[("Prefer", "return=minimal"), singular] [("Prefer", "return=minimal"), singular]
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |] [json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]
`shouldRespondWith` "" `shouldRespondWith`
{ matchStatus = 201 [str|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
, matchHeaders = ["Content-Range" <:> "*/*"] { 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" $ 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" request methodPost "/addresses"
[("Prefer", "return=representation"), singular] [("Prefer", "return=representation"), singular]
[json| [ ] |] [json| [ ] |]
@@ -134,18 +180,39 @@ spec =
liftIO $ simpleBody p `shouldBe` [str|{"id":11}|] liftIO $ simpleBody p `shouldBe` [str|{"id":11}|]
it "raises an error when attempting to delete multiple entities" $ do 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 request methodDelete firstItems
[("Prefer", "return=representation"), singular] "" [("Prefer", "return=representation"), singular] ""
`shouldRespondWith` 406 `shouldRespondWith` 406
get firstItems 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 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-9/*"] , matchHeaders = ["Content-Range" <:> "0-4/*"]
} }
it "raises an error when deleting zero entities" $ 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" request methodDelete "/items?id=lt.0"
[("Prefer", "return=representation"), singular] "" [("Prefer", "return=representation"), singular] ""
`shouldRespondWith` `shouldRespondWith`
+21 -2
View File
@@ -17,12 +17,14 @@ import PostgREST.Config (docsVersion)
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = do spec = do
describe "OpenAPI" $ 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")] 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" $ it "should respond to openapi request on none root path with 415" $
request methodGet "/items" request methodGet "/items"
@@ -229,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 describe "PostgreSQL to Swagger Type Mapping" $ do
it "character varying to string" $ do it "character varying to string" $ do
+1 -1
View File
@@ -10,7 +10,7 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "Reading and writing to unicode schema and table names" $ describe "Reading and writing to unicode schema and table names" $
it "Can read and write values" $ do it "Can read and write values" $ do
+52 -1
View File
@@ -11,7 +11,7 @@ import Text.Heredoc
import Protolude hiding (get, put) import Protolude hiding (get, put)
import SpecHelper import SpecHelper
spec :: SpecWith Application spec :: SpecWith ((), Application)
spec = spec =
describe "UPSERT" $ do describe "UPSERT" $ do
context "with POST" $ do context "with POST" $ do
@@ -49,6 +49,31 @@ spec =
[json|[]|] `shouldRespondWith` [json|[]|] `shouldRespondWith`
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] } [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 context "when Prefer: resolution=ignore-duplicates is specified" $ do
it "INSERTs and ignores rows on pk conflict" $ it "INSERTs and ignores rows on pk conflict" $
@@ -75,6 +100,32 @@ spec =
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson] , 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 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")] request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|] [json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]
+85 -53
View File
@@ -6,16 +6,17 @@ import qualified Hasql.Transaction.Sessions as HT
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate, import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
updateAction) updateAction)
import Data.Function (id) import Data.Function (id)
import Data.List.NonEmpty (toList)
import Data.Time.Clock (getCurrentTime) import Data.Time.Clock (getCurrentTime)
import Data.IORef import Data.IORef
import Test.Hspec import Test.Hspec
import PostgREST.App (postgrest) import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..))
import PostgREST.DbStructure (getDbStructure, getPgVersion) import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Types (DbStructure (..), pgVersion95, import PostgREST.Types (pgVersion95, pgVersion96)
pgVersion96) import Protolude hiding (toList)
import Protolude
import SpecHelper import SpecHelper
import qualified Feature.AndOrParamsSpec import qualified Feature.AndOrParamsSpec
@@ -26,10 +27,12 @@ import qualified Feature.BinaryJwtSecretSpec
import qualified Feature.ConcurrentSpec import qualified Feature.ConcurrentSpec
import qualified Feature.CorsSpec import qualified Feature.CorsSpec
import qualified Feature.DeleteSpec import qualified Feature.DeleteSpec
import qualified Feature.EmbedDisambiguationSpec
import qualified Feature.ExtraSearchPathSpec import qualified Feature.ExtraSearchPathSpec
import qualified Feature.HtmlRawOutputSpec import qualified Feature.HtmlRawOutputSpec
import qualified Feature.InsertSpec import qualified Feature.InsertSpec
import qualified Feature.JsonOperatorSpec import qualified Feature.JsonOperatorSpec
import qualified Feature.MultipleSchemaSpec
import qualified Feature.NoJwtSpec import qualified Feature.NoJwtSpec
import qualified Feature.NonexistentSchemaSpec import qualified Feature.NonexistentSchemaSpec
import qualified Feature.PgVersion95Spec import qualified Feature.PgVersion95Spec
@@ -49,108 +52,137 @@ import qualified Feature.UpsertSpec
main :: IO () main :: IO ()
main = do main = do
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test" testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"
setupDb testDbConn setupDb testDbConn
pool <- P.acquire (3, 10, toS testDbConn) pool <- P.acquire (3, 10, toS testDbConn)
result <- P.use pool $ do actualPgVersion <- either (panic.show) id <$> P.use pool getPgVersion
ver <- getPgVersion
HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver
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 () let withApp = app testCfg
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime $ pure () maxRowsApp = app testMaxRowsCfg
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime $ pure () proxyApp = app testProxyCfg
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime $ pure () noJwtApp = app testCfgNoJWT
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime $ pure () binaryJwtApp = app testCfgBinaryJWT
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime $ pure () audJwtApp = app testCfgAudienceJWT
audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool getTime $ pure () asymJwkApp = app testCfgAsymJWK
asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool getTime $ pure () asymJwkSetApp = app testCfgAsymJWKSet
asymJwkSetApp = return $ postgrest (testCfgAsymJWKSet testDbConn) refDbStructure pool getTime $ pure () extraSearchPathApp = app testCfgExtraSearchPath
nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool getTime $ pure () rootSpecApp = app testCfgRootSpec
extraSearchPathApp = return $ postgrest (testCfgExtraSearchPath testDbConn) refDbStructure pool getTime $ pure () htmlRawOutputApp = app testCfgHtmlRawOutput
rootSpecApp = return $ postgrest (testCfgRootSpec testDbConn) refDbStructure pool getTime $ pure () responseHeadersApp = app testCfgResponseHeaders
htmlRawOutputApp = return $ postgrest (testCfgHtmlRawOutput testDbConn) refDbStructure pool getTime $ pure ()
let reset :: IO () unicodeApp = appDbs testUnicodeCfg
nonexistentSchemaApp = appDbs testNonexistentSchemaCfg
multipleSchemaApp = appDbs testMultipleSchemaCfg
let reset, analyze :: IO ()
reset = resetDb testDbConn reset = resetDb testDbConn
analyze = do
analyzeTable testDbConn "items"
analyzeTable testDbConn "child_entities"
actualPgVersion = pgVersion dbStructure
extraSpecs = extraSpecs =
[("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++ [("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95] ++ [("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95]
[("Feature.PgVersion96Spec", Feature.PgVersion96Spec.spec) | actualPgVersion >= pgVersion96]
specs = uncurry describe <$> [ specs = uncurry describe <$> [
("Feature.AuthSpec" , Feature.AuthSpec.spec actualPgVersion) ("Feature.AuthSpec" , Feature.AuthSpec.spec actualPgVersion)
, ("Feature.RawOutputTypesSpec" , Feature.RawOutputTypesSpec.spec) , ("Feature.RawOutputTypesSpec" , Feature.RawOutputTypesSpec.spec)
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec) , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
, ("Feature.CorsSpec" , Feature.CorsSpec.spec) , ("Feature.CorsSpec" , Feature.CorsSpec.spec)
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec) , ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
, ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion) , ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion)
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion) , ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)
, ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion) , ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion)
, ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion) , ("Feature.StructureSpec" , Feature.StructureSpec.spec)
, ("Feature.RangeSpec" , Feature.RangeSpec.spec) , ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec actualPgVersion)
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec actualPgVersion)
] ++ extraSpecs ] ++ extraSpecs
mutSpecs = uncurry describe <$> [
("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
, ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
]
hspec $ do 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 -- this test runs with a raw-output-media-types set to text/html
beforeAll_ reset . before htmlRawOutputApp $ before htmlRawOutputApp $
describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec
-- this test runs with a different server flag -- this test runs with a different server flag
beforeAll_ reset . before ltdApp $ before maxRowsApp $
describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec
-- this test runs with a different schema -- this test runs with a different schema
beforeAll_ reset . before unicodeApp $ before unicodeApp $
describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec
-- this test runs with a proxy -- this test runs with a proxy
beforeAll_ reset . before proxyApp $ before proxyApp $
describe "Feature.ProxySpec" Feature.ProxySpec.spec describe "Feature.ProxySpec" Feature.ProxySpec.spec
-- this test runs without a JWT secret -- this test runs without a JWT secret
beforeAll_ reset . before noJwtApp $ before noJwtApp $
describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec
-- this test runs with a binary JWT secret -- this test runs with a binary JWT secret
beforeAll_ reset . before binaryJwtApp $ before binaryJwtApp $
describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec
-- this test runs with a binary JWT secret and an audience claim -- this test runs with a binary JWT secret and an audience claim
beforeAll_ reset . before audJwtApp $ before audJwtApp $
describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec
-- this test runs with asymmetric JWK -- this test runs with asymmetric JWK
beforeAll_ reset . before asymJwkApp $ before asymJwkApp $
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-- this test runs with asymmetric JWKSet -- this test runs with asymmetric JWKSet
beforeAll_ reset . before asymJwkSetApp $ before asymJwkSetApp $
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-- this test runs with a nonexistent db-schema -- this test runs with a nonexistent db-schema
beforeAll_ reset . before nonexistentSchemaApp $ before nonexistentSchemaApp $
describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
-- this test runs with an extra search path -- this test runs with an extra search path
beforeAll_ reset . before extraSearchPathApp $ before extraSearchPathApp $
describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec
-- this test runs with a root spec function override -- this test runs with a root spec function override
when (actualPgVersion >= pgVersion96) $ when (actualPgVersion >= pgVersion96) $ do
beforeAll_ reset . before rootSpecApp $ before rootSpecApp $
describe "Feature.RootSpec" Feature.RootSpec.spec 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)
+76
View File
@@ -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
+29 -10
View File
@@ -11,6 +11,7 @@ import Control.Monad (void)
import Data.Aeson (Value (..), decode, encode) import Data.Aeson (Value (..), decode, encode)
import Data.CaseInsensitive (CI (..)) import Data.CaseInsensitive (CI (..))
import Data.List (lookup) import Data.List (lookup)
import Data.List.NonEmpty (fromList)
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus)) import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import System.Environment (getEnv) import System.Environment (getEnv)
import System.Process (readProcess) import System.Process (readProcess)
@@ -23,7 +24,7 @@ import Test.Hspec.Wai
import Text.Heredoc import Text.Heredoc
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.Types (JSPathExp (..), QualifiedIdentifier (..)) import PostgREST.Types (JSPathExp (..))
import Protolude import Protolude
matchContentTypeJson :: MatchHeader matchContentTypeJson :: MatchHeader
@@ -32,7 +33,7 @@ matchContentTypeJson = "Content-Type" <:> "application/json; charset=utf-8"
matchContentTypeSingular :: MatchHeader matchContentTypeSingular :: MatchHeader
matchContentTypeSingular = "Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8" matchContentTypeSingular = "Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"
validateOpenApiResponse :: [Header] -> WaiSession () validateOpenApiResponse :: [Header] -> WaiSession () ()
validateOpenApiResponse headers = do validateOpenApiResponse headers = do
r <- request methodGet "/" headers "" r <- request methodGet "/" headers ""
liftIO $ liftIO $
@@ -63,9 +64,11 @@ getEnvVarWithDefault var def = toS <$>
_baseCfg :: AppConfig _baseCfg :: AppConfig
_baseCfg = -- Connection Settings _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 -- No user configured Unix Socket
Nothing Nothing
-- No user configured Unix Socket file mode (defaults to 660)
(Right 432)
-- Jwt settings -- Jwt settings
(Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False Nothing (Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False Nothing
-- Connection Modifiers -- Connection Modifiers
@@ -91,13 +94,13 @@ testCfgNoJWT :: Text -> AppConfig
testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing } testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }
testUnicodeCfg :: Text -> AppConfig testUnicodeCfg :: Text -> AppConfig
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchema = "تست" } testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["تست"] }
testLtdRowsCfg :: Text -> AppConfig testMaxRowsCfg :: Text -> AppConfig
testLtdRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 } testMaxRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
testProxyCfg :: Text -> AppConfig 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 :: Text -> AppConfig
testCfgBinaryJWT testDbConn = (testCfg testDbConn) { testCfgBinaryJWT testDbConn = (testCfg testDbConn) {
@@ -125,17 +128,23 @@ testCfgAsymJWKSet testDbConn = (testCfg testDbConn) {
} }
testNonexistentSchemaCfg :: Text -> AppConfig testNonexistentSchemaCfg :: Text -> AppConfig
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" } testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["nonexistent"] }
testCfgExtraSearchPath :: Text -> AppConfig testCfgExtraSearchPath :: Text -> AppConfig
testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] } testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }
testCfgRootSpec :: Text -> AppConfig testCfgRootSpec :: Text -> AppConfig
testCfgRootSpec testDbConn = (testCfg testDbConn) { configRootSpec = Just $ QualifiedIdentifier "test" "root"} testCfgRootSpec testDbConn = (testCfg testDbConn) { configRootSpec = Just "root"}
testCfgHtmlRawOutput :: Text -> AppConfig testCfgHtmlRawOutput :: Text -> AppConfig
testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = ["text/html"] } 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 :: Text -> IO ()
setupDb dbConn = do setupDb dbConn = do
loadFixture dbConn "database" loadFixture dbConn "database"
@@ -149,9 +158,13 @@ setupDb dbConn = do
resetDb :: Text -> IO () resetDb :: Text -> IO ()
resetDb dbConn = loadFixture dbConn "data" 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 :: Text -> FilePath -> IO()
loadFixture dbConn name = 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 :: ByteRange -> [Header]
rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)] rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
@@ -169,6 +182,12 @@ matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool
matchHeader name valRegex headers = matchHeader name valRegex headers =
maybe False (=~ valRegex) $ lookup name 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 :: BS.ByteString -> BS.ByteString -> Header
authHeaderBasic u p = authHeaderBasic u p =
(hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p)) (hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p))
+30
View File
@@ -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:
+82
View File
@@ -344,6 +344,12 @@ INSERT INTO employees VALUES
TRUNCATE TABLE tiobe_pls CASCADE; TRUNCATE TABLE tiobe_pls CASCADE;
INSERT INTO tiobe_pls VALUES ('Java', 1), ('C', 2), ('Python', 4); 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; TRUNCATE TABLE only_pk CASCADE;
INSERT INTO only_pk VALUES (1), (2); 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 2 | arrow-2 | parens-2 | dotted-2 | space-2
3 | arrow-3 | parens-3 | dotted-3 | space-3 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');
+1 -1
View File
@@ -1,3 +1,3 @@
set client_min_messages to warning; 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; DROP TYPE IF EXISTS jwt_token CASCADE;
+34
View File
@@ -6,6 +6,8 @@ GRANT USAGE ON SCHEMA
, public , public
, "تست" , "تست"
, extensions , extensions
, v1
, v2
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
-- Schema test objects -- Schema test objects
@@ -66,6 +68,8 @@ GRANT ALL ON TABLE
, perf_articles , perf_articles
, employees , employees
, tiobe_pls , tiobe_pls
, single_unique
, compound_unique
, only_pk , only_pk
, family_tree , family_tree
, managers , managers
@@ -101,6 +105,30 @@ GRANT ALL ON TABLE
, pgrst_reserved_chars , pgrst_reserved_chars
, authors_w_entities , authors_w_entities
, openapi_types , 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; TO postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
@@ -110,6 +138,8 @@ GRANT USAGE ON SEQUENCE
, items_id_seq , items_id_seq
, callcounter_count , callcounter_count
, leak_id_seq , leak_id_seq
, v1.childs_id_seq
, v2.childs_id_seq
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
-- Privileges for non anonymous users -- 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 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 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 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; GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author;
+317 -310
View File
@@ -18,6 +18,8 @@ CREATE SCHEMA private;
CREATE SCHEMA test; CREATE SCHEMA test;
CREATE SCHEMA تست; CREATE SCHEMA تست;
CREATE SCHEMA extensions; CREATE SCHEMA extensions;
CREATE SCHEMA v1;
CREATE SCHEMA v2;
-- --
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: - -- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
@@ -103,15 +105,10 @@ SET default_tablespace = '';
SET default_with_oids = false; SET default_with_oids = false;
--
-- Name: items; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE items ( CREATE TABLE items (
id bigint NOT NULL id bigserial primary key
); );
CREATE FUNCTION always_true(test.items) RETURNS boolean CREATE FUNCTION always_true(test.items) RETURNS boolean
LANGUAGE sql STABLE LANGUAGE sql STABLE
AS $$ SELECT true $$; AS $$ SELECT true $$;
@@ -364,59 +361,8 @@ CREATE TABLE auth (
pass character(60) NOT NULL 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; 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: - -- 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 ( CREATE TABLE clients (
id integer NOT NULL, id integer primary key,
name text NOT NULL 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: - -- Name: complex_items; Type: TABLE; Schema: test; Owner: -
-- --
@@ -574,26 +506,6 @@ CREATE VIEW insertable_view_with_join AS
FROM (has_fk FROM (has_fk
JOIN auto_incrementing_pk USING (id)); 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: - -- Name: json; Type: TABLE; Schema: test; Owner: -
-- --
@@ -663,11 +575,11 @@ CREATE TABLE insertonly (
-- --
CREATE TABLE projects ( CREATE TABLE projects (
id integer NOT NULL, id integer primary key,
name text NOT NULL, 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: - -- Name: projects_view; Type: VIEW; Schema: test; Owner: -
@@ -695,25 +607,23 @@ CREATE TABLE simple_pk (
extra character varying NOT NULL extra character varying NOT NULL
); );
-- CREATE TABLE users (
-- Name: users_projects; Type: TABLE; Schema: test; Owner: - id integer primary key,
-- name text NOT NULL
);
CREATE TABLE users_projects ( CREATE TABLE users_projects (
user_id integer NOT NULL, user_id integer NOT NULL REFERENCES users(id),
project_id integer NOT NULL project_id integer NOT NULL REFERENCES projects(id),
PRIMARY KEY (project_id, user_id)
); );
--
-- Name: tasks; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE tasks ( CREATE TABLE tasks (
id integer NOT NULL, id integer primary key,
name text NOT NULL, 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 CREATE OR REPLACE VIEW filtered_tasks AS
SELECT id AS "myId", name, project_id AS "projectID" 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 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: - -- Name: tsearch; Type: TABLE; Schema: test; Owner: -
@@ -734,28 +691,6 @@ CREATE TABLE tsearch (
text_search_vector tsvector 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;" ( CREATE TABLE "Escap3e;" (
"so6meIdColumn" integer primary key "so6meIdColumn" integer primary key
); );
@@ -773,7 +708,6 @@ CREATE TABLE clashing_column (
t text t text
); );
-- --
-- Name: id; Type: DEFAULT; Schema: test; Owner: - -- 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); 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; SET search_path = postgrest, pg_catalog;
-- --
@@ -805,24 +732,6 @@ ALTER TABLE ONLY auth
ADD CONSTRAINT auth_pkey PRIMARY KEY (id); 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; SET search_path = test, pg_catalog;
-- --
@@ -840,23 +749,6 @@ ALTER TABLE ONLY authors_only
ALTER TABLE ONLY auto_incrementing_pk ALTER TABLE ONLY auto_incrementing_pk
ADD CONSTRAINT auto_incrementing_pk_pkey PRIMARY KEY (id); 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: - -- Name: complex_items_pkey; Type: CONSTRAINT; Schema: test; Owner: -
-- --
@@ -888,15 +780,6 @@ ALTER TABLE ONLY simple_pk
ALTER TABLE ONLY has_fk ALTER TABLE ONLY has_fk
ADD CONSTRAINT has_fk_pkey PRIMARY KEY (id); 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: - -- Name: menagerie_pkey; Type: CONSTRAINT; Schema: test; Owner: -
-- --
@@ -905,46 +788,6 @@ ALTER TABLE ONLY menagerie
ADD CONSTRAINT menagerie_pkey PRIMARY KEY ("integer"); 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; 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(); 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; 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: - -- 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 ALTER TABLE ONLY has_fk
ADD CONSTRAINT has_fk_simple_fk_fkey FOREIGN KEY (simple_fk) REFERENCES simple_pk(k); 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 ( create table addresses (
id int not null unique, id int not null unique,
address text not null address text not null
@@ -1083,6 +842,8 @@ create table orders (
billing_address_id int references addresses(id), billing_address_id int references addresses(id),
shipping_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 CREATE FUNCTION getproject(id int) RETURNS SETOF projects
LANGUAGE sql LANGUAGE sql
@@ -1096,11 +857,17 @@ CREATE FUNCTION get_projects_below(id int) RETURNS SETOF projects
SELECT * FROM test.projects WHERE id < $1; 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 CREATE FUNCTION getallprojects() RETURNS SETOF projects
LANGUAGE sql LANGUAGE sql
AS $_$ AS $_$
SELECT * FROM test.projects; SELECT * FROM test.projects;
$_$; $_$ ROWS 2019;
CREATE FUNCTION setprojects(id_l int, id_h int, name text) RETURNS SETOF projects CREATE FUNCTION setprojects(id_l int, id_h int, name text) RETURNS SETOF projects
LANGUAGE sql LANGUAGE sql
@@ -1373,6 +1140,18 @@ create table test.tiobe_pls(
rank smallint 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 ( create table test.family_tree (
id text not null primary key, id text not null primary key,
name text not null, name text not null,
@@ -1388,12 +1167,11 @@ create table test.managers (
create table test.organizations ( create table test.organizations (
id integer primary key, id integer primary key,
name text, name text,
referee integer, referee integer references organizations(id),
auditor integer, auditor integer references organizations(id),
manager_id integer references managers(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 rename constraint organizations_manager_id_fkey to manager;
alter table only test.organizations add constraint pptr2 foreign key (auditor) references test.organizations(id);
create table private.authors( create table private.authors(
id integer primary key, id integer primary key,
@@ -1730,3 +1508,232 @@ select $$
</html> </html>
$$::text; $$::text;
$_$ language sql; $_$ 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;
+12 -12
View File
@@ -94,21 +94,21 @@ setUp
echo "Running memory usage tests.." echo "Running memory usage tests.."
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "12M" jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "13M"
jsonKeyTest "1M" "POST" "/leak?columns=blob" "12M" jsonKeyTest "1M" "POST" "/leak?columns=blob" "13M"
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "12M" jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "13M"
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "40M" jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "41M"
jsonKeyTest "10M" "POST" "/leak?columns=blob" "40M" jsonKeyTest "10M" "POST" "/leak?columns=blob" "41M"
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "40M" jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "41M"
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "170M" jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "171M"
jsonKeyTest "50M" "POST" "/leak?columns=blob" "170M" jsonKeyTest "50M" "POST" "/leak?columns=blob" "171M"
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "170M" jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "171M"
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "10M" postJsonArrayTest "1000" "/perf_articles?columns=id,body" "11M"
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "10M" postJsonArrayTest "10000" "/perf_articles?columns=id,body" "11M"
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "20M" postJsonArrayTest "100000" "/perf_articles?columns=id,body" "21M"
cleanUp cleanUp