Compare commits

...
95 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
Steve ChávezandGitHub 713b214c9a Bump to version v6.0.2
* Add missing CHANGELOG entry for #1368
2019-08-22 11:12:01 -05:00
steve-chavez 681388631b Add raw-media-types to Dockerfile 2019-08-21 10:34:28 -05:00
steve-chavez ae9e27a0c7 Fix #1348, go back to converting plus "+" to space
Not doing this conflicts with some http clients and proxies.

Use the alternative url-safe character '!' instead for
disambiguating resource embedding.
2019-08-21 10:34:28 -05:00
Steve ChávezandGitHub e83144ce7f Add documentation links 2019-08-16 12:41:24 -05:00
Joe NelsonandGitHub 1c54c7130a Another funding link 2019-08-14 14:05:12 -05:00
Steve ChávezandGitHub 1a8d5fed8a Update ISSUE_TEMPLATE.md 2019-08-13 08:15:11 -05:00
Nathan BouscalandSteve Chávez 57ebf43e85 Allow select in OpenAPI for POST requests (#1375)
If you use `return=representation`, `select` is useful in `POST` requests.
It already works correctly, but is missing from the OpenAPI spec.
2019-08-09 14:10:20 -05:00
steve-chavez b87734343e Update BACKERS.md 2019-08-08 18:50:34 -05:00
Dan AmorosoandSteve Chávez c80c9ef726 Reword error message for BinaryFieldError (#1372) 2019-08-08 14:20:06 -05:00
Dan AmorosoandSteve Chávez d5758523f3 Change raw-media-types to accept a string of comma separated MIME types (#1370) 2019-08-06 13:23:52 -05:00
Amédée d'AbovilleandSteve Chávez ee40e7e0d7 Fix long column descriptions being truncated at 63 characters in pg12 (#1368)
* Lengthen child_entities name comment to make tests check it does not get truncated at 63 chars.

* Don't cast column description to sql_identifier type (maxlen 63 chars) when introspecting schema in DbStructure.hs
2019-08-02 16:13:01 +00:00
steve-chavez 53b606e1c1 Remove raw-media-types config from Docker
Passing a list through an env var is not working
in configurator-pg
2019-07-31 12:06:19 -05:00
steve-chavez 47c0141c49 Bump to v6.0.1 2019-07-30 22:34:28 +00:00
steve-chavez 5b8a17e366 Correct appveyor conditional deploy 2019-07-30 22:34:28 +00:00
steve-chavez 64a86b899f Remove centos6 release
Centos 6 is missing ghc 8.4.4 support, see
https://github.com/commercialhaskell/stack/issues/4161

The static binary release for linux can be used instead.
2019-07-30 22:34:28 +00:00
steve-chavez 312e295a47 Add raw-media-types config option to Docker 2019-07-30 22:34:28 +00:00
steve-chavez e7544687d1 Fix/organize latest entries in CHANGELOG 2019-07-30 22:34:28 +00:00
Diogo BiazusandSteve Chávez 291de5bc1c LTS 13.29 (#1364)
* Update resolver to lts-13.29 and add lock file to repository

* Upgrade stack version

* Save cache after building dependencies only to have faster feedback loop when tests fail

* Move private functions from QueryBuilder to a separate Private module

* Move more functions over to private trying to make compilation consume less memory

* Split private in 4 modules

* Remove unused LambdaCase pragma

* Add profile to memory-tests.sh so it can find postgrest executable

* Move save dependencies before building and running tests for faster feedback loop
2019-07-29 17:14:06 +00:00
Steve ChávezandGitHub c37a9f5ec3 Fix windows build (#1354)
* Fix windows build by pinning network and text-printer versions
* Enable travis and appveyor builds on PRs
2019-07-23 22:27:35 +00:00
Dan AmorosoandSteve Chávez f5cef205f1 Introduced raw-media-types config option (#1349)
* extracted rawOutputTypes to config variable raw-output-media-types

* removed CTTextHtml from Types.hs
2019-07-22 23:37:53 +00:00
RobertandSteve Chávez afb7266f17 Update jose to 0.8.1 to fix Windows build (and adapt error message test) (#1351) 2019-07-15 15:14:22 +00:00
Eduardo JorgeandSteve Chávez e639c77aa2 Add websearch_to_tsquery support (#1339) 2019-07-08 13:25:58 -05:00
Robert VollmertandSteve Chávez ea97055449 appveyor: cache stack appdata directory
This is where stack installs GHC versions.
2019-07-07 11:40:41 -05:00
RobertandSteve Chávez 64dc6ab9ac Fix json test versioning for postgresql 10.9 bugfix (#1340)
This removes the test for buggy behaviour, it seems not useful
anymore.
2019-07-05 12:54:44 -05:00
Eduardo JorgeandSteve Chávez 25dedd1098 Add tests for overlap operator in array types (#1337)
* Add basic tests for some array operators
  - eq
  - neq
  - tl
  - gt
  - lte
  - gte
  - cs
  - cd
  - ov
2019-07-04 11:42:19 -05:00
RobertandSteve Chávez 617bf7b6a3 Update jose to 0.8 (and adapt error message test) (#1324)
The message changes between jose versions 0.7 and 0.8.
2019-07-04 10:56:45 -05:00
Robert VollmertandSteve Chávez e3a53de8a6 circleci: bump postgres to 11.4 to test json bugfix 2019-06-28 11:45:27 -05:00
Robert VollmertandSteve Chávez 4cc91fd5b1 Update json rpc argument tests for bugfix in PostgreSQL 11.4
See https://www.postgresql.org/docs/release/11.4/.
2019-06-28 11:45:27 -05:00
RobertandSteve Chávez 296a12e394 Work around macos case sensitivity issue (#1332)
See also https://github.com/haskell/cabal/issues/4739.

/Users/travis/build/PostgREST/postgrest/<built-in>:15:10: error:
     error: non-portable path to file '".stack-work/dist/x86_64-osx/Cabal-2.2.0.1/build/PostgREST/autogen/cabal_macros.h"'; specified path differs in case from file name on disk [-Werror,-Wnonportable-include-path]
         ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
         ".stack-work/dist/x86_64-osx/Cabal-2.2.0.1/build/PostgREST/autogen/cabal_macros.h"
2019-06-27 10:33:38 -05:00
steve-chavez da7aa1d72f Add root-spec to official Dockerfile
* Delete unused config file
2019-06-26 18:14:36 -05:00
steve-chavez 367ad8ea43 Update pgdg url for centos dockerfiles
* Also update ubuntu i386 image repo
2019-06-26 18:14:36 -05:00
steve-chavez 2c3bc2d75e AppVeyor workaround for TMP issue, also add timeout
See https://github.com/quasicomputational/hpack/commit/ce868d17e53289eab3703261a5a3dc8d48364533,
and https://github.com/commercialhaskell/stack/issues/3944
2019-06-26 18:14:36 -05:00
steve-chavez 3cce6ca02b Change docker image to postgres 11.3
PostgreSQL 11.4 patched an error for wich we had a test.
See https://www.postgresql.org/docs/release/11.4/ json_to_record.
2019-06-26 18:14:36 -05:00
78 changed files with 4190 additions and 2165 deletions
+40 -43
View File
@@ -74,8 +74,8 @@ jobs:
- run:
name: install stack & dependencies
command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update
sudo apt-get install -y libgmp-dev
sudo apt-get install -y --only-upgrade binutils
@@ -83,6 +83,20 @@ jobs:
stack setup
rm -rf $(stack path --dist-dir) $(stack path --local-install-root)
stack install hlint stylish-haskell
- run:
name: Add stack tools to $PATH
command: |
echo "export PATH=/home/circleci/.local/bin:$PATH" >> $BASH_ENV
- run:
name: build src and tests dependencies
command: |
stack build --fast -j1 --only-dependencies
stack build --fast --test --no-run-tests --only-dependencies
- save_cache:
paths:
- "~/.stack"
- ".stack-work"
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
- run:
name: build src and tests
command: |
@@ -95,15 +109,10 @@ jobs:
test/io-tests.sh
- run:
name: run linter
command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$@"
command: make lint
- run:
name: run styler
command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- stylish-haskell -i && git diff-index --exit-code HEAD --
- save_cache:
paths:
- "~/.stack"
- ".stack-work"
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
command: make style
build-test-9.6:
docker:
@@ -122,8 +131,8 @@ jobs:
- run:
name: install stack & dependencies
command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update
sudo apt-get install -y libgmp-dev
sudo apt-get install -y postgresql-client
@@ -135,7 +144,7 @@ jobs:
stack build --fast --test --no-run-tests
- run:
name: run tests
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
build-test-10:
docker:
@@ -154,8 +163,8 @@ jobs:
- run:
name: install stack & dependencies
command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update
sudo apt-get install -y libgmp-dev
sudo apt-get install -y postgresql-client
@@ -167,14 +176,14 @@ jobs:
stack build --fast --test --no-run-tests
- run:
name: run tests
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
build-test-11:
docker:
- image: circleci/buildpack-deps:trusty
environment:
- PGHOST=localhost
- image: circleci/postgres:11
- image: circleci/postgres:11.4
environment:
- POSTGRES_USER=circleci
- POSTGRES_DB=circleci
@@ -186,8 +195,8 @@ jobs:
- run:
name: install stack & dependencies
command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update
sudo apt-get install -y libgmp-dev
sudo apt-get install -y postgresql-client
@@ -199,7 +208,7 @@ jobs:
stack build --fast --test --no-run-tests
- run:
name: run tests
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
build-prof-test:
docker:
@@ -219,12 +228,21 @@ jobs:
- run:
name: install stack & dependencies
command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update
sudo apt-get install -y libgmp-dev
sudo apt-get install -y postgresql-client
stack setup
- run:
name: build dependencies with profiling enabled
command: |
stack build --profile -j1 --only-dependencies
- save_cache:
paths:
- "~/.stack"
- ".stack-work"
key: v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
- run:
name: build with profiling enabled
command: |
@@ -240,14 +258,6 @@ jobs:
psql "postgres:///postgrest_test" -f test/fixtures/jsonschema.sql
psql "postgres:///postgrest_test" -f test/fixtures/privileges.sql
test/memory-tests.sh
- save_cache:
paths:
- "~/.stack"
- ".stack-work"
key: v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
centos6:
<<: *build-distro-bin
centos7:
<<: *build-distro-bin
@@ -260,7 +270,7 @@ jobs:
release:
docker:
- image: circleci/golang:1.8
- image: circleci/golang:1.9
steps:
- attach_workspace:
at: /tmp/workspace
@@ -308,18 +318,6 @@ workflows:
filters:
tags:
only: /v[0-9]+(\.[0-9]+)*/
- centos6:
requires:
- build-test-9.4
- build-test-9.6
- build-test-10
- build-test-11
- build-prof-test
filters:
tags:
only: /v[0-9]+(\.[0-9]+)*/
branches:
ignore: /.*/
- centos7:
requires:
- build-test-9.4
@@ -358,7 +356,6 @@ workflows:
ignore: /.*/
- release:
requires:
- centos6
- centos7
- ubuntu
- ubuntui386
+5 -6
View File
@@ -42,14 +42,13 @@ your contributions.
* All contributions must pass the tests before being merged. When
you create a pull request your code will automatically be tested.
* All code must also pass [hlint](http://community.haskell.org/~ndm/hlint/)
with no warnings. This helps enforce a uniform style for all
committers. Continuous integration will check this as well on every
pull request.
* All code must also pass [hlint](http://community.haskell.org/~ndm/hlint/) and [stylish-haskell](https://github.com/jaspervdj/stylish-haskell)
with no warnings. This helps enforce a uniform style for all committers. Continuous integration will check this as well on every
pull request. There's a useful Makefile that helps with checking this locally. You can run `make commit-check` to do this manually but
we recommend adding it to `.git/hooks/pre-commit` to automatically check this before doing a commit.
* For help building the Haskell code on your computer check out the [building from
source](https://postgrest.com/en/stable/install.html#build-from-source)
wiki page.
source](https://postgrest.com/en/stable/install.html#build-from-source) docs section.
### Running Tests
+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
patreon: postgrest
+4
View File
@@ -1,3 +1,7 @@
<!--
Before reporting a bug:
If your database schema has changed while the PostgREST server is running, send the server a SIGUSR1 signal or restart it(http://postgrest.org/en/v5.2/admin.html#schema-reloading) to ensure the schema cache is not stale. This sometimes fixes apparent bugs.
-->
### Environment
* PostgreSQL version: (if using docker, specify the image)
+8 -2
View File
@@ -1,3 +1,5 @@
## Travis is only used for building an OSX binary ,
## no tests are run here.
language: generic
sudo: false
@@ -40,11 +42,15 @@ install:
fi
script:
## Building the whole project can take longer than 50 minutes. Since Travis has a global timeout of 50 minutes
## we compile for 30 minutes tops(`gtimeout 1800`) and quit compiling with no error.
## Since we CACHE the compile results we can continue compiling from where we left off
## on the next commit.
- gtimeout 1800 stack build --no-terminal --only-snapshot --install-ghc || true
- |
if test ! "$TRAVIS_TAG"
then
echo 'No tag pushed. Skipping build.'
echo 'No tag pushed. Skip building binary.'
else
stack build --no-terminal --copy-bins --local-bin-path .
fi
@@ -59,6 +65,6 @@ script:
END='## \['
BODY=$(sed -n "1,/$START/d;/$END/q;p" CHANGELOG.md)
strip postgrest
tar cjf postgrest-$TRAVIS_TAG-osx.tar.xz postgrest
tar cJf postgrest-$TRAVIS_TAG-osx.tar.xz postgrest
ghr -t $GITHUB_TOKEN -u $OWNER -r $REPO -b "$BODY"--replace $TRAVIS_TAG postgrest-$TRAVIS_TAG-osx.tar.xz
fi
+5 -1
View File
@@ -29,16 +29,20 @@ PostgREST ongoing development is only possible thanks to our Sponsors and Backer
## Lead Backers
- [Daniel Babiak](https://github.com/d-babiak)
- [Michel Pelletier](https://github.com/michelp/)
- Evans Fernandes
## Backers
- Tsingson Qin
- Michel Pelletier
- Jay Hannah
- Robert Stolarz
- Kofi Gumbs
- Nicholas DiBiase
- Christopher Reid
- Nathan Bouscal
- Daniel Rafaj
- David Fenko
## Former Backers
+58 -1
View File
@@ -9,11 +9,67 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
## [7.0.0] - 2020-04-03
### Added
- #1417, `Accept: application/vnd.pgrst.object+json` behavior is now enforced for POST/PATCH/DELETE regardless of `Prefer: return=representation/minimal` - @dwagin
- #1415, Add support for user defined socket permission via `server-unix-socket-mode` config option - @Dansvidania
- #1383, Add support for HEAD request - @steve-chavez
- #1378, Add support for `Prefer: count=planned` and `Prefer: count=estimated` on GET /table - @steve-chavez, @LorenzHenk
- #1327, Add support for optional query parameter `on_conflict` to upsert with specified keys for POST - @ykst
- #1430, Allow specifying the foreign key constraint name(`/source?select=fk_constraint(*)`) to disambiguate an embedding - @steve-chavez
- #1168, Allow access to the `Authorization` header through the `request.header.authorization` GUC - @steve-chavez
- #1435, Add `request.method` and `request.path` GUCs - @steve-chavez
- #1088, Allow adding headers to GET/POST/PATCH/PUT/DELETE responses through the `response.headers` GUC - @steve-chavez
- #1427, Allow overriding provided headers(Location, Content-Type, etc) through the `response.headers` GUC - @steve-chavez
- #1450, Allow multiple schemas to be exposed in one instance. The schema to use can be selected through the headers `Accept-Profile` for GET/HEAD and `Content-Profile` for POST/PATCH/PUT/DELETE - @steve-chavez, @mahmoudkassem
### Fixed
- #1301, Fix self join resource embedding on PATCH - @herulume, @steve-chavez
- #1389, Fix many to many resource embedding on RPC/PATCH - @steve-chavez
- #1355, Allow PATCH/DELETE without `return=minimal` on tables with no select privileges - @steve-chavez
- #1361, Fix embedding a VIEW when its source foreign key is UNIQUE - @bwbroersma
### Changed
- #1385, bulk RPC call now should be done by specifying a `Prefer: params=multiple-objects` header - @steve-chavez
- #1401, resource embedding now outputs an error when multiple relationships between two tables are found - @steve-chavez
- #1423, default Unix Socket file mode from 755 to 660 - @dwagin
- #1430, Remove embedding with duck typed column names `GET /projects?select=client(*)`- @steve-chavez
+ You can rename the foreign key to `client` to make this request work in the new version: `alter table projects rename constraint projects_client_id_fkey to client`
- #1413, Change `server-proxy-uri` config option to `openapi-server-proxy-uri` - @steve-chavez
## [6.0.2] - 2019-08-22
### Fixed
- #1369, Change `raw-media-types` to accept a string of comma separated MIME types - @Dansvidania
- #1368, Fix long column descriptions being truncated at 63 characters in PostgreSQL 12 - @amedeedaboville
- #1348, Go back to converting plus "+" to space " " in querystrings by default - @steve-chavez
### Deprecated
- #1348, Deprecate `.` symbol for disambiguating resource embedding(added in #918). The url-safe '!' should be used instead. We refrained from using `+` as part of our syntax because it conflicts with some http clients and proxies.
## [6.0.1] - 2019-07-30
### Added
- #1349, Add user defined raw output media types via `raw-media-types` config option - @Dansvidania
- #1243, Add websearch_to_tsquery support - @herulume
### Fixed
- #1336, Error when testing on Chrome/Firefox: text/html requested but a single column was not selected - @Dansvidania
- #1334, Unable to compile v6.0.0 on windows - @steve-chavez
## [6.0.0] - 2019-06-21
### Added
- #1186, Add support for user defined unix socket via `server-unix-socket` config option
- #1186, Add support for user defined unix socket via `server-unix-socket` config option - @Dansvidania
- #690, Add `?columns` query parameter for faster bulk inserts, also ignores unspecified json keys in a payload - @steve-chavez
- #1239, Add support for resource embedding on materialized views - @vitorbaptista
- #1264, Add support for bulk RPC call - @steve-chavez
@@ -22,6 +78,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #790, Allow override of OpenAPI spec through `root-spec` config option - @steve-chavez
- #1308, Accept `text/plain` and `text/html` for raw output - @steve-chavez
### Fixed
- #1223, Fix incorrect OpenAPI externalDocs url - @steve-chavez
+1
View File
@@ -1,4 +1,5 @@
Copyright (c) 2014 Joe Nelson
Copyright (c) 2019 Steve Chavez
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
+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
@@ -9,8 +11,7 @@ lint:
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$$@"
style:
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- stylish-haskell -i
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- stylish-haskell -i && git diff-index --exit-code HEAD -- '*.hs' '*.lhs'
test: check-dburi
stack test
@@ -22,14 +23,12 @@ coverage: check-dburi clean
stack build --coverage
stack test --coverage
circleci: prompt-long-process
circleci local execute --job build-test-9.4
circleci-prof-test: prompt-long-process
circleci local execute --job build-prof-test
check-dburi:
test -n "$(POSTGREST_TEST_CONNECTION)" # Requires POSTGREST_TEST_CONNECTION environmental variable
+5 -3
View File
@@ -50,13 +50,15 @@ Big thanks to our sponsors! You can join them by supporting PostgREST on [Patreo
```bash
postgrest --help
```
## [Documentation](http://postgrest.org)
Latest documentation is at [postgrest.org](http://postgrest.org). You can contribute to the docs in [PostgREST/postgrest-docs](https://github.com/PostgREST/postgrest-docs).
## Performance
TLDR; subsecond response times for up to 2000 requests/sec on Heroku
free tier. If you're used to servers written in interpreted languages
(or named after precious gems), prepare to be pleasantly surprised by
PostgREST performance.
free tier. If you're used to servers written in interpreted languages,
prepare to be pleasantly surprised by PostgREST performance.
Three factors contribute to the speed. First the server is written
in [Haskell](https://www.haskell.org/) using the
+1 -1
View File
@@ -10,7 +10,7 @@
},
"POSTGREST_VER": {
"description": "Version of PostgREST to deploy",
"value": "6.0.0"
"value": "7.0.0"
},
"DB_URI": {
"description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
+8 -11
View File
@@ -1,24 +1,19 @@
## AppVeyor is only used for building a Windows binary, no tests are run here.
platform: x64
cache:
- "c:\\sr"
- .stack-work
- "c:\\Users\\appveyor\\AppData\\Local\\Programs\\stack"
environment:
global:
STACK_ROOT: "c:\\sr"
GOPATH: c:\gopath
TMP: "c:\\tmp"
test: off
skip_non_tags: true
skip_branch_with_pr: true
branches:
only:
- master
install:
- set PATH=C:\Program Files\PostgreSQL\9.6\bin\;%PATH%
- curl -sS -ostack.zip -L --insecure http://www.stackage.org/stack/windows-x86_64
@@ -28,11 +23,13 @@ install:
build_script:
- stack setup --no-terminal > nul
- stack build -j1 --copy-bins --local-bin-path .
# Appveyor has a timeout of 60 mins, building can take longer, limit the time and make sure this succeeds,
# previous work will get cached and finish on next commit
- bash -lc "timeout 2700 'C:\projects\postgrest\stack.exe' build -j1 --copy-bins --local-bin-path . || true"
artifacts:
- path: postgrest.exe
deploy_script:
- 7z a -tzip postgrest-%APPVEYOR_REPO_TAG_NAME%-windows-x64.zip postgrest.exe
- bash -lc "exec 0</dev/null && cd $APPVEYOR_BUILD_FOLDER && ghr -t $GITHUB_TOKEN -u $APPVEYOR_ACCOUNT_NAME -r $APPVEYOR_PROJECT_NAME -b \"$(sed -n \"1,/$(echo $APPVEYOR_REPO_TAG_NAME | cut -c2-)/d;/## \[/q;p\" CHANGELOG.md)\" --replace $APPVEYOR_REPO_TAG_NAME postgrest-$APPVEYOR_REPO_TAG_NAME-windows-x64.zip"
- IF DEFINED APPVEYOR_REPO_TAG_NAME 7z a -tzip postgrest-%APPVEYOR_REPO_TAG_NAME%-windows-x64.zip postgrest.exe
- IF DEFINED APPVEYOR_REPO_TAG_NAME bash -lc " exec 0</dev/null && cd $APPVEYOR_BUILD_FOLDER && ghr -t $GITHUB_TOKEN -u $APPVEYOR_ACCOUNT_NAME -r $APPVEYOR_PROJECT_NAME -b \"$(sed -n \"1,/$(echo $APPVEYOR_REPO_TAG_NAME | cut -c2-)/d;/## \[/q;p\" CHANGELOG.md)\" --replace $APPVEYOR_REPO_TAG_NAME postgrest-$APPVEYOR_REPO_TAG_NAME-windows-x64.zip"
+8 -3
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
@@ -31,13 +34,15 @@ ENV PGRST_DB_URI= \
PGRST_DB_EXTRA_SEARCH_PATH=public \
PGRST_SERVER_HOST=*4 \
PGRST_SERVER_PORT=3000 \
PGRST_SERVER_PROXY_URI= \
PGRST_OPENAPI_SERVER_PROXY_URI= \
PGRST_JWT_SECRET= \
PGRST_SECRET_IS_BASE64=false \
PGRST_JWT_AUD= \
PGRST_MAX_ROWS= \
PGRST_PRE_REQUEST= \
PGRST_ROLE_CLAIM_KEY=".role"
PGRST_ROLE_CLAIM_KEY=".role" \
PGRST_ROOT_SPEC= \
PGRST_RAW_MEDIA_TYPES=
RUN groupadd -g 1000 postgrest && \
useradd -r -u 1000 -g postgrest postgrest && \
-3
View File
@@ -1,3 +0,0 @@
db-uri = "postgres://app_user:password@postgres:5432/app_db"
db-schema = "public"
db-anon-role = "app_user"
-18
View File
@@ -1,18 +0,0 @@
FROM centos:centos6
RUN yum -y update
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
RUN yum -y install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-6-x86_64/pgdg-centos93-9.3-2.noarch.rpm
RUN yum -y install postgresql93-devel
RUN yum clean all
RUN curl -sSL https://get.haskellstack.org/ | sh
ENV PATH $PATH:/usr/pgsql-9.3/bin
# To disable warning when building
ENV PATH $PATH:/root/.local/bin
RUN mkdir /source
WORKDIR /source
ENTRYPOINT ["stack"]
+3 -3
View File
@@ -2,12 +2,12 @@ FROM centos:centos7
RUN yum -y update
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
RUN yum -y install yum install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-7-x86_64/pgdg-centos93-9.3-2.noarch.rpm
RUN yum -y install postgresql93-devel
RUN yum -y install yum install https://download.postgresql.org/pub/repos/yum/10/redhat/rhel-7-x86_64/pgdg-centos10-10-2.noarch.rpm
RUN yum -y install postgresql10-devel
RUN yum clean all
RUN curl -sSL https://get.haskellstack.org/ | sh
ENV PATH $PATH:/usr/pgsql-9.3/bin
ENV PATH $PATH:/usr/pgsql-10/bin
# To disable warning when building
ENV PATH $PATH:/root/.local/bin
+2
View File
@@ -1,5 +1,7 @@
FROM ubuntu:16.04
## TODO pin the stack version
#
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
apt-get -qq update && \
apt-get -qqy --no-install-recommends install \
+3 -1
View File
@@ -1,4 +1,6 @@
FROM 32bit/ubuntu:16.04
FROM i386/ubuntu:16.04
## TODO pin the stack version
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
apt-get -qq update && \
+3 -1
View File
@@ -7,7 +7,7 @@ db-extra-search-path = "$(PGRST_DB_EXTRA_SEARCH_PATH)"
server-host = "$(PGRST_SERVER_HOST)"
server-port = "$(PGRST_SERVER_PORT)"
server-proxy-uri = "$(PGRST_SERVER_PROXY_URI)"
openapi-server-proxy-uri = "$(PGRST_OPENAPI_SERVER_PROXY_URI)"
jwt-secret = "$(PGRST_JWT_SECRET)"
secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)"
jwt-aud = "$(PGRST_JWT_AUD)"
@@ -15,3 +15,5 @@ role-claim-key = "$(PGRST_ROLE_CLAIM_KEY)"
max-rows = "$(PGRST_MAX_ROWS)"
pre-request = "$(PGRST_PRE_REQUEST)"
root-spec = "$(PGRST_ROOT_SPEC)"
raw-media-types = "$(PGRST_RAW_MEDIA_TYPES)"
+39 -46
View File
@@ -12,25 +12,17 @@ import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
import Control.Retry (RetryStatus, capDelay,
exponentialBackoff, retrying,
rsPreviousDelay)
import Data.Either.Combinators (whenLeft)
import Data.IORef (IORef, atomicWriteIORef, newIORef,
readIORef)
import Data.String (IsString (..))
import Data.Text (pack, replace, strip, stripPrefix,
unpack)
import Data.Text (pack, replace, strip, stripPrefix)
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.IO (hPutStrLn, readFile)
import Data.Time.Clock (getCurrentTime)
import Network.Socket (Family (AF_UNIX),
SockAddr (SockAddrUnix), Socket,
SocketType (Stream), bind, close,
defaultProtocol, listen,
maxListenQueue, socket)
import Network.Wai.Handler.Warp (defaultSettings, runSettings,
runSettingsSocket, setHost, setPort,
setServerName)
import System.Directory (removeFile)
setHost, setPort, setServerName)
import System.IO (BufferMode (..), hSetBuffering)
import System.IO.Error (isDoesNotExistError)
import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..), configPoolTimeout',
@@ -42,13 +34,15 @@ import PostgREST.OpenAPI (isMalformedProxyUri)
import PostgREST.Types (ConnectionStatus (..), DbStructure,
PgVersion (..), Schema,
minimumPgVersion)
import Protolude hiding (hPutStrLn, replace)
import Protolude hiding (hPutStrLn, head, replace)
#ifndef mingw32_HOST_OS
import System.Posix.Signals
import UnixSocket
#endif
{-|
The purpose of this worker is to fill the refDbStructure created in 'main'
with the 'DbStructure' returned from calling 'getDbStructure'. This method
@@ -70,11 +64,11 @@ import System.Posix.Signals
connectionWorker
:: ThreadId -- ^ This thread is killed if pg version is unsupported
-> P.Pool -- ^ The PostgreSQL connection pool
-> Schema -- ^ Schema PostgREST is serving up
-> [Schema] -- ^ Schemas PostgREST is serving up
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
-> IORef Bool -- ^ Used as a binary Semaphore
-> IO ()
connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
connectionWorker mainTid pool schemas refDbStructure refIsWorkerOn = do
isWorkerOn <- readIORef refIsWorkerOn
unless isWorkerOn $ do
atomicWriteIORef refIsWorkerOn True
@@ -90,7 +84,7 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
NotConnected -> return () -- Unreachable
Connected actualPgVersion -> do -- Procede with initialization
result <- P.use pool $ do
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schemas actualPgVersion
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
case result of
Left e -> do
@@ -159,10 +153,12 @@ main = do
-- readOptions builds the 'AppConfig' from the config file specified on the
-- command line
conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
let host = configHost conf
let schemas = toList $ configSchemas conf
host = configHost conf
port = configPort conf
proxy = configProxyUri conf
proxy = configOpenAPIProxyUri conf
maybeSocketAddr = configSocket conf
socketFileMode = configSocketMode conf
pgSettings = toS (configDatabase conf) -- is the db-uri
roleClaimKey = configRoleClaimKey conf
appSettings =
@@ -171,16 +167,18 @@ main = do
. setServerName (toS $ "postgrest/" <> prettyVersion) $
defaultSettings
whenLeft socketFileMode panic
-- Checks that the provided proxy uri is formated correctly
when (isMalformedProxyUri $ toS <$> proxy) $
panic
"Malformed proxy uri, a correct example: https://example.com:8443/basePath"
-- Checks that the provided jspath is valid
when (isLeft roleClaimKey) $
whenLeft roleClaimKey $
panic $ show roleClaimKey
--
-- create connection pool with the provided settings, returns either
-- a 'Connection' or a 'ConnectionError'. Does not throw.
pool <- P.acquire (configPool conf, configPoolTimeout' conf, pgSettings)
@@ -199,7 +197,7 @@ main = do
connectionWorker
mainTid
pool
(configSchema conf)
schemas
refDbStructure
refIsWorkerOn
--
@@ -221,7 +219,7 @@ main = do
Catch $ connectionWorker
mainTid
pool
(configSchema conf)
schemas
refDbStructure
refIsWorkerOn
) Nothing
@@ -240,22 +238,20 @@ main = do
(connectionWorker
mainTid
pool
(configSchema conf)
schemas
refDbStructure
refIsWorkerOn)
in case maybeSocketAddr of
Nothing -> do
-- run the postgrest application
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
runSettings appSettings postgrestApplication
Just socketAddr -> do
-- run postgrest application with user defined socket
sock <- createAndBindSocket (unpack socketAddr)
listen sock maxListenQueue
putStrLn $ ("Listening on unix socket " :: Text) <> show socketAddr
runSettingsSocket appSettings sock postgrestApplication
-- clean socket up when done
close sock
-- run the postgrest application with user defined socket. Only for UNIX systems.
#ifndef mingw32_HOST_OS
whenJust maybeSocketAddr $
runAppInSocket appSettings postgrestApplication socketFileMode
#endif
-- run the postgrest application
whenNothing maybeSocketAddr $ do
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
runSettings appSettings postgrestApplication
{-|
The purpose of this function is to load the JWT secret from a file if
@@ -324,14 +320,11 @@ loadDbUriFile conf = extractDbUri mDbUri
Just filename -> strip <$> readFile (toS filename)
setDbUri dbUri = conf {configDatabase = dbUri}
createAndBindSocket :: FilePath -> IO Socket
createAndBindSocket filePath = do
deleteSocketFileIfExist filePath
sock <- socket AF_UNIX Stream defaultProtocol
bind sock $ SockAddrUnix filePath
return sock
where
deleteSocketFileIfExist path = removeFile path `catch` handleDoesNotExist
handleDoesNotExist e
| isDoesNotExistError e = return ()
| otherwise = throwIO e
-- Utilitarian functions.
whenJust :: Applicative f => Maybe a -> (a -> f ()) -> f ()
whenJust (Just x) f = f x
whenJust Nothing _ = pass
whenNothing :: Applicative f => Maybe a -> f () -> f ()
whenNothing Nothing f = f
whenNothing _ _ = pass
+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
+76 -27
View File
@@ -1,13 +1,13 @@
name: postgrest
version: 6.0.0
version: 7.0.0
synopsis: REST API for any Postgres database
description: Reads the schema of a PostgreSQL database and creates RESTful routes
for the tables and views, supporting all HTTP verbs that security
permits.
license: MIT
license-file: LICENSE
author: Joe Nelson, Adam Baker
maintainer: Steve Chávez <stevechavezast@gmail.com>
author: Joe Nelson, Adam Baker, Steve Chavez
maintainer: Steve Chavez <stevechavezast@gmail.com>
category: Executable, PostgreSQL, Network APIs
homepage: https://postgrest.org
bug-reports: https://github.com/PostgREST/postgrest/issues
@@ -36,11 +36,14 @@ library
PostgREST.OpenAPI
PostgREST.Parsers
PostgREST.QueryBuilder
PostgREST.Statements
PostgREST.RangeQuery
PostgREST.Types
other-modules: Paths_postgrest
PostgREST.Private.Common
PostgREST.Private.QueryFragment
hs-source-dirs: src
build-depends: base >= 4.9 && < 4.13
build-depends: base >= 4.9 && < 4.14
, HTTP >= 4000.3.7 && < 4000.4
, Ranged-sets >= 0.3 && < 0.5
, aeson >= 0.11.3 && < 1.5
@@ -49,7 +52,7 @@ library
, bytestring >= 0.10.8 && < 0.11
, case-insensitive >= 1.2 && < 1.3
, cassava >= 0.4.5 && < 0.6
, configurator-pg >= 0.1 && < 0.2
, configurator-pg >= 0.2 && < 0.3
, containers >= 0.5.7 && < 0.7
, contravariant >= 1.4 && < 1.6
, contravariant-extras >= 0.3.3 && < 0.4
@@ -58,23 +61,23 @@ library
, gitrev >= 1.2 && < 1.4
, hasql >= 1.4 && < 1.5
, hasql-pool >= 0.5 && < 0.6
, hasql-transaction >= 0.7.2 && < 0.8
, hasql-transaction >= 0.7.2 && < 1.1
, heredoc >= 0.2 && < 0.3
, http-types >= 0.12.2 && < 0.13
, insert-ordered-containers >= 0.1 && < 0.3
, insert-ordered-containers >= 0.2.2 && < 0.3
, interpolatedstring-perl6 >= 1 && < 1.1
, jose >= 0.7 && < 0.8
, lens >= 4.14 && < 4.18
, lens-aeson >= 1.0.1 && < 1.1
, jose >= 0.8.1 && < 0.9
, lens >= 4.14 && < 4.19
, lens-aeson >= 1.0.1 && < 1.2
, network-uri >= 2.6.1 && < 2.7
, optparse-applicative >= 0.13 && < 0.15
, optparse-applicative >= 0.13 && < 0.16
, parsec >= 3.1.11 && < 3.2
, protolude >= 0.2.2 && < 0.3
, regex-tdfa >= 1.2.2 && < 1.3
, regex-tdfa >= 1.2.2 && < 1.4
, scientific >= 0.3.4 && < 0.4
, swagger2 >= 2.1.4 && < 2.4
, swagger2 >= 2.4 && < 2.6
, text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.9
, time >= 1.6 && < 1.10
, unordered-containers >= 0.2.8 && < 0.3
, vector >= 0.11 && < 0.13
, wai >= 3.2.1 && < 3.3
@@ -89,21 +92,23 @@ library
executable postgrest
main-is: Main.hs
hs-source-dirs: main
build-depends: base >= 4.9 && < 4.13
build-depends: base >= 4.9 && < 4.14
, auto-update >= 0.1.4 && < 0.2
, base64-bytestring >= 1 && < 1.1
, bytestring >= 0.10.8 && < 0.11
, directory >= 1.2.6 && < 1.4
, either >= 4.4.1 && < 5.1
, hasql >= 1.4 && < 1.5
, hasql-pool >= 0.5 && < 0.6
, hasql-transaction >= 0.7.2 && < 0.8
, network < 2.9
, hasql-transaction >= 0.7.2 && < 1.1
, network < 3.2
, postgrest
, protolude >= 0.2.2 && < 0.3
, retry >= 0.7.4 && < 0.9
, text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.9
, warp >= 3.2.12 && < 3.3
, time >= 1.6 && < 1.10
, wai >= 3.2.1 && < 3.3
, warp >= 3.2.12 && < 3.4
default-language: Haskell2010
default-extensions: OverloadedStrings
QuasiQuotes
@@ -112,6 +117,7 @@ executable postgrest
if !os(windows)
build-depends: unix
other-modules: UnixSocket
test-suite spec
type: exitcode-stdio-1.0
@@ -124,6 +130,7 @@ test-suite spec
Feature.ConcurrentSpec
Feature.CorsSpec
Feature.DeleteSpec
Feature.EmbedDisambiguationSpec
Feature.ExtraSearchPathSpec
Feature.InsertSpec
Feature.JsonOperatorSpec
@@ -141,10 +148,13 @@ test-suite spec
Feature.StructureSpec
Feature.UnicodeSpec
Feature.UpsertSpec
Feature.RawOutputTypesSpec
Feature.HtmlRawOutputSpec
Feature.MultipleSchemaSpec
SpecHelper
TestTypes
hs-source-dirs: test
build-depends: base >= 4.9 && < 4.13
build-depends: base >= 4.9 && < 4.14
, aeson >= 0.11.3 && < 1.5
, aeson-qq >= 0.8.1 && < 0.9
, async >= 2.1.1 && < 2.3
@@ -157,21 +167,21 @@ test-suite spec
, contravariant >= 1.4 && < 1.6
, hasql >= 1.4 && < 1.5
, hasql-pool >= 0.5 && < 0.6
, hasql-transaction >= 0.7.2 && < 0.8
, hasql-transaction >= 0.7.2 && < 1.1
, heredoc >= 0.2 && < 0.3
, hspec >= 2.3 && < 2.8
, hspec-wai >= 0.7 && < 0.10
, hspec-wai-json >= 0.7 && < 0.10
, hspec-wai >= 0.10 && < 0.11
, hspec-wai-json >= 0.10 && < 0.11
, http-types >= 0.12.3 && < 0.13
, lens >= 4.14 && < 4.18
, lens-aeson >= 1.0.1 && < 1.1
, lens >= 4.14 && < 4.19
, lens-aeson >= 1.0.1 && < 1.2
, monad-control >= 1.0.1 && < 1.1
, postgrest
, process >= 1.4.2 && < 1.7
, protolude >= 0.2.2 && < 0.3
, regex-tdfa >= 1.2.2 && < 1.3
, regex-tdfa >= 1.2.2 && < 1.4
, text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.9
, time >= 1.6 && < 1.10
, transformers-base >= 0.4.4 && < 0.5
, wai >= 3.2.1 && < 3.3
, wai-extra >= 3.0.19 && < 3.1
@@ -180,3 +190,42 @@ test-suite spec
QuasiQuotes
NoImplicitPrelude
ghc-options: -threaded -rtsopts -with-rtsopts=-N
Test-Suite spec-querycost
Type: exitcode-stdio-1.0
Default-Language: Haskell2010
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude
Hs-Source-Dirs: test
Main-Is: QueryCost.hs
Other-Modules: SpecHelper
Build-Depends: base >= 4.9 && < 4.14
, aeson >= 0.11.3 && < 1.5
, aeson-qq >= 0.8.1 && < 0.9
, async >= 2.1.1 && < 2.3
, auto-update >= 0.1.4 && < 0.2
, base64-bytestring >= 1 && < 1.1
, bytestring >= 0.10.8 && < 0.11
, case-insensitive >= 1.2 && < 1.3
, cassava >= 0.4.5 && < 0.6
, containers >= 0.5.7 && < 0.7
, contravariant >= 1.4 && < 1.6
, hasql >= 1.4 && < 1.5
, hasql-pool >= 0.5 && < 0.6
, hasql-transaction >= 0.7.2 && < 1.1
, heredoc >= 0.2 && < 0.3
, hspec >= 2.3 && < 2.8
, hspec-wai >= 0.10 && < 0.11
, hspec-wai-json >= 0.10 && < 0.11
, http-types >= 0.12.3 && < 0.13
, lens >= 4.14 && < 4.19
, lens-aeson >= 1.0.1 && < 1.2
, monad-control >= 1.0.1 && < 1.1
, postgrest
, process >= 1.4.2 && < 1.7
, protolude >= 0.2.2 && < 0.3
, regex-tdfa >= 1.2.2 && < 1.4
, text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.10
, transformers-base >= 0.4.4 && < 0.5
, wai >= 3.2.1 && < 3.3
, wai-extra >= 3.0.19 && < 3.1
+113 -80
View File
@@ -3,13 +3,14 @@ Module : PostgREST.ApiRequest
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
module PostgREST.ApiRequest (
ApiRequest(..)
, InvokeMethod(..)
, ContentType(..)
, Action(..)
, Target(..)
, PreferRepresentation (..)
, mutuallyAgreeable
, userApiRequest
) where
@@ -27,7 +28,8 @@ import qualified Data.Vector as V
import Control.Arrow ((***))
import Data.Aeson.Types (emptyArray, emptyObject)
import Data.List (last, lookup, partition)
import Data.List (elem, last, lookup, partition)
import Data.List.NonEmpty (NonEmpty, head)
import Data.Maybe (fromJust)
import Data.Ranged.Ranges (Range (..), emptyRange,
rangeIntersection)
@@ -47,24 +49,23 @@ import PostgREST.RangeQuery (NonnegRange, allRange, rangeGeq,
rangeLimit, rangeOffset, rangeRequested,
restrictRange)
import PostgREST.Types
import Protolude
import Protolude hiding (head)
type RequestBody = BL.ByteString
data InvokeMethod = InvHead | InvGet | InvPost deriving Eq
-- | Types of things a user wants to do to tables/views/procs
data Action = ActionCreate | ActionRead
| ActionUpdate | ActionDelete
| ActionInfo | ActionInvoke{isReadOnly :: Bool}
| ActionInspect | ActionSingleUpsert
data Action = ActionCreate | ActionRead{isHead :: Bool}
| ActionUpdate | ActionDelete
| ActionSingleUpsert | ActionInvoke InvokeMethod
| ActionInfo | ActionInspect{isHead :: Bool}
deriving Eq
-- | The target db object of a user action
data Target = TargetIdent QualifiedIdentifier
| TargetProc{tpQi :: QualifiedIdentifier, tpIsRootSpec :: Bool}
| TargetDefaultSpec -- The default spec offered at root "/"
| TargetDefaultSpec{tdsSchema :: Schema} -- The default spec offered at root "/"
| TargetUnknown [Text]
deriving Eq
-- | How to return the inserted data
data PreferRepresentation = Full | HeadersOnly | None deriving Eq
{-|
Describes what the user wants to do. This data type is a
@@ -74,83 +75,85 @@ data PreferRepresentation = Full | HeadersOnly | None deriving Eq
if it is an action we are able to perform.
-}
data ApiRequest = ApiRequest {
-- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
iAction :: Action
-- | Requested range of rows within response
, iRange :: M.HashMap ByteString NonnegRange
-- | The target, be it calling a proc or accessing a table
, iTarget :: Target
-- | Content types the client will accept, [CTAny] if no Accept header
, iAccepts :: [ContentType]
-- | Data sent by client and used for mutation actions
, iPayload :: Maybe PayloadJSON
-- | If client wants created items echoed back
, iPreferRepresentation :: PreferRepresentation
-- | Pass all parameters as a single json object to a stored procedure
, iPreferSingleObjectParameter :: Bool
-- | Whether the client wants a result count (slower)
, iPreferCount :: Bool
-- | Whether the client wants to UPSERT or ignore records on PK conflict
, iPreferResolution :: Maybe PreferResolution
-- | Filters on the result ("id", "eq.10")
, iFilters :: [(Text, Text)]
-- | &and and &or parameters used for complex boolean logic
, iLogic :: [(Text, Text)]
-- | &select parameter used to shape the response
, iSelect :: Text
-- | &columns parameter used to shape the payload
, iColumns :: Maybe Text
-- | &order parameters for each level
, iOrder :: [(Text, Text)]
-- | Alphabetized (canonical) request query string for response URLs
, iCanonicalQS :: ByteString
-- | JSON Web Token
, iJWT :: Text
-- | HTTP request headers
, iHeaders :: [(Text, Text)]
-- | Request Cookies
, iCookies :: [(Text, Text)]
iAction :: Action -- ^ Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
, iRange :: M.HashMap ByteString NonnegRange -- ^ Requested range of rows within response
, iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level
, iTarget :: Target -- ^ The target, be it calling a proc or accessing a table
, iAccepts :: [ContentType] -- ^ Content types the client will accept, [CTAny] if no Accept header
, iPayload :: Maybe PayloadJSON -- ^ Data sent by client and used for mutation actions
, iPreferRepresentation :: PreferRepresentation -- ^ If client wants created items echoed back
, iPreferParameters :: Maybe PreferParameters -- ^ How to pass parameters to a stored procedure
, iPreferCount :: Maybe PreferCount -- ^ Whether the client wants a result count
, iPreferResolution :: Maybe PreferResolution -- ^ Whether the client wants to UPSERT or ignore records on PK conflict
, iFilters :: [(Text, Text)] -- ^ Filters on the result ("id", "eq.10")
, iLogic :: [(Text, Text)] -- ^ &and and &or parameters used for complex boolean logic
, iSelect :: Maybe Text -- ^ &select parameter used to shape the response
, iOnConflict :: Maybe Text -- ^ &on_conflict parameter used to upsert on specific unique keys
, iColumns :: Maybe Text -- ^ &columns parameter used to shape the payload
, iOrder :: [(Text, Text)] -- ^ &order parameters for each level
, iCanonicalQS :: ByteString -- ^ Alphabetized (canonical) request query string for response URLs
, iJWT :: Text -- ^ JSON Web Token
, iHeaders :: [(Text, Text)] -- ^ HTTP request headers
, iCookies :: [(Text, Text)] -- ^ Request Cookies
, iPath :: ByteString -- ^ Raw request path
, iMethod :: ByteString -- ^ Raw request method
, iProfile :: Maybe Schema -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/.
, iSchema :: Schema -- ^ The request schema. Can vary depending on iProfile.
}
-- | Examines HTTP request and translates it into user intent.
userApiRequest :: Schema -> Maybe QualifiedIdentifier -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest schema rootSpec req reqBody
| isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate
userApiRequest :: NonEmpty Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest confSchemas rootSpec req reqBody
| isJust profile && fromJust profile `notElem` confSchemas = Left $ UnacceptableSchema $ toList confSchemas
| isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate
| topLevelRange == emptyRange = Left InvalidRange
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
| otherwise = Right ApiRequest {
iAction = action
, iTarget = target
, iRange = ranges
, iTopLevelRange = topLevelRange
, iAccepts = maybe [CTAny] (map decodeContentType . parseHttpAccept) $ lookupHeader "accept"
, iPayload = relevantPayload
, iPreferRepresentation = representation
, iPreferSingleObjectParameter = singleObject
, iPreferCount = hasPrefer "count=exact"
, iPreferResolution = if hasPrefer (show MergeDuplicates) then Just MergeDuplicates
else if hasPrefer (show IgnoreDuplicates) then Just IgnoreDuplicates
else Nothing
, iPreferParameters = if | hasPrefer (show SingleObject) -> Just SingleObject
| hasPrefer (show MultipleObjects) -> Just MultipleObjects
| otherwise -> Nothing
, iPreferCount = if | hasPrefer (show ExactCount) -> Just ExactCount
| hasPrefer (show PlannedCount) -> Just PlannedCount
| hasPrefer (show EstimatedCount) -> Just EstimatedCount
| otherwise -> Nothing
, iPreferResolution = if | hasPrefer (show MergeDuplicates) -> Just MergeDuplicates
| hasPrefer (show IgnoreDuplicates) -> Just IgnoreDuplicates
| otherwise -> Nothing
, iFilters = filters
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
, iSelect = toS $ fromMaybe "*" $ join $ lookup "select" qParams
, iSelect = toS <$> join (lookup "select" qParams)
, iOnConflict = toS <$> join (lookup "on_conflict" qParams)
, iColumns = columns
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
, iCanonicalQS = toS $ urlEncodeVars
. L.sortOn fst
. map (join (***) toS . second (fromMaybe BS.empty))
$ queryStringWPlus
$ qString
, iJWT = tokenStr
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie]
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hCookie]
, iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
, iPath = rawPathInfo req
, iMethod = method
, iProfile = profile
, iSchema = schema
}
where
-- queryString with '+' not converted to ' '
queryStringWPlus = parseQueryReplacePlus False $ rawQueryString req
-- queryString with '+' converted to ' '(space)
qString = parseQueryReplacePlus True $ rawQueryString req
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
(filters, rpcQParams) =
case action of
ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
_ -> (flts, [])
ActionInvoke InvGet -> partitionFlts
ActionInvoke InvHead -> partitionFlts
_ -> (flts, [])
partitionFlts = partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
flts =
[ (toS k, toS $ fromJust v) |
(k,v) <- qParams, isJust v,
@@ -163,13 +166,17 @@ userApiRequest schema rootSpec req reqBody
isTargetingProc = case target of
TargetProc _ _ -> True
_ -> False
isTargetingDefaultSpec = case target of
TargetDefaultSpec _ -> True
_ -> False
contentType = decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type"
columns | action `elem` [ActionCreate, ActionUpdate, ActionInvoke{isReadOnly=False}] = toS <$> join (lookup "columns" qParams)
| otherwise = Nothing
columns
| action `elem` [ActionCreate, ActionUpdate, ActionInvoke InvPost] = toS <$> join (lookup "columns" qParams)
| otherwise = Nothing
payload =
case (contentType, action) of
(_, ActionInvoke{isReadOnly=True}) ->
Right $ ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) PJObject (S.fromList $ fst <$> rpcQParams)
(_, ActionInvoke InvGet) -> Right rpcPrmsToJson
(_, ActionInvoke InvHead) -> Right rpcPrmsToJson
(CTApplicationJSON, _) ->
if isJust columns
then Right $ RawJSON reqBody
@@ -186,47 +193,73 @@ userApiRequest schema rootSpec req reqBody
Right $ ProcessedJSON (JSON.encode json) PJObject keys
(ct, _) ->
Left $ toS $ "Content-Type not acceptable: " <> toMime ct
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges
rpcPrmsToJson = ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams)
PJObject (S.fromList $ fst <$> rpcQParams)
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges -- if no limit is specified, get all the request rows
action =
case method of
"GET" | target == TargetDefaultSpec -> ActionInspect
| isTargetingProc -> ActionInvoke{isReadOnly=True}
| otherwise -> ActionRead
-- The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response
-- From https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
"HEAD" | isTargetingDefaultSpec -> ActionInspect{isHead=True}
| isTargetingProc -> ActionInvoke InvHead
| otherwise -> ActionRead{isHead=True}
"GET" | isTargetingDefaultSpec -> ActionInspect{isHead=False}
| isTargetingProc -> ActionInvoke InvGet
| otherwise -> ActionRead{isHead=False}
"POST" -> if isTargetingProc
then ActionInvoke{isReadOnly=False}
then ActionInvoke InvPost
else ActionCreate
"PATCH" -> ActionUpdate
"PUT" -> ActionSingleUpsert
"DELETE" -> ActionDelete
"OPTIONS" -> ActionInfo
_ -> ActionInspect
_ -> ActionInspect{isHead=False}
defaultSchema = head confSchemas
profile
| length confSchemas <= 1 -- only enable content negotiation by profile when there are multiple schemas specified in the config
= Nothing
| action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionDelete] -- POST/PATCH/PUT/DELETE don't use the same header as per the spec
= Just $ maybe defaultSchema toS $ lookupHeader "Content-Profile"
| action `elem` [ActionRead True, ActionRead False, ActionInvoke InvGet, ActionInvoke InvHead, ActionInvoke InvPost,
ActionInspect False, ActionInspect True, ActionInfo]
= Just $ maybe defaultSchema toS $ lookupHeader "Accept-Profile"
| otherwise = Nothing
schema = fromMaybe defaultSchema profile
target = case path of
[] -> case rootSpec of
Just rsQi -> TargetProc rsQi True
Nothing -> TargetDefaultSpec
Just pName -> TargetProc (QualifiedIdentifier schema pName) True
Nothing -> TargetDefaultSpec schema
[table] -> TargetIdent $ QualifiedIdentifier schema table
["rpc", proc] -> TargetProc (QualifiedIdentifier schema proc) False
other -> TargetUnknown other
shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke{isReadOnly=False}, ActionInvoke{isReadOnly=True}]
shouldParsePayload =
action `elem`
[ActionCreate, ActionUpdate, ActionSingleUpsert,
ActionInvoke InvPost,
-- Though ActionInvoke{isGet=True}(a GET /rpc/..) doesn't really have a payload, we use the payload variable as a way
-- to store the query string arguments to the function.
ActionInvoke InvGet,
ActionInvoke InvHead]
relevantPayload | shouldParsePayload = rightToMaybe payload
| otherwise = Nothing
path = pathInfo req
method = requestMethod req
hdrs = requestHeaders req
qParams = [(toS k, v)|(k,v) <- queryStringWPlus]
qParams = [(toS k, v)|(k,v) <- qString]
lookupHeader = flip lookup hdrs
hasPrefer :: Text -> Bool
hasPrefer val = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs
where
split :: BS.ByteString -> [Text]
split = map T.strip . T.split (==',') . toS
singleObject = hasPrefer "params=single-object"
representation
| hasPrefer "return=representation" = Full
| hasPrefer "return=minimal" = None
| otherwise = HeadersOnly
| hasPrefer (show Full) = Full
| hasPrefer (show None) = None
| otherwise = if action == ActionCreate
then HeadersOnly -- Assume the user wants the Location header(for POST) by default
else None
auth = fromMaybe "" $ lookupHeader hAuthorization
tokenStr = case T.split (== ' ') (toS auth) of
("Bearer" : t : _) -> t
+236 -205
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 MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
@@ -8,13 +20,13 @@ module PostgREST.App (
import qualified Data.ByteString.Char8 as BS
import qualified Data.HashMap.Strict as M
import qualified Data.List as L (union)
import qualified Data.Set as S
import qualified Hasql.Pool as P
import qualified Hasql.Transaction as H
import qualified Hasql.Transaction as HT
import qualified Hasql.Transaction.Sessions as HT
import Data.Aeson as JSON
import Data.Function (id)
import Data.IORef (IORef, readIORef)
import Data.Time.Clock (UTCTime)
@@ -29,26 +41,28 @@ import Network.Wai
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
ContentType (..),
PreferRepresentation (..),
Target (..), mutuallyAgreeable,
userApiRequest)
InvokeMethod (..), Target (..),
mutuallyAgreeable, userApiRequest)
import PostgREST.Auth (containsRole, jwtClaims,
parseSecret)
import PostgREST.Config (AppConfig (..))
import PostgREST.DbRequestBuilder (fieldNames, mutateRequest,
readRequest)
import PostgREST.DbRequestBuilder (mutateRequest, readRequest)
import PostgREST.DbStructure
import PostgREST.Error (PgError (..), SimpleError (..),
errorResponseFor, singularityError)
import PostgREST.Middleware
import PostgREST.OpenAPI
import PostgREST.Parsers (pRequestColumns)
import PostgREST.QueryBuilder (ResultsWithCount, callProc,
import PostgREST.QueryBuilder (limitedQuery, mutateRequestToQuery,
readRequestToCountQuery,
readRequestToQuery,
requestToCallProcQuery)
import PostgREST.RangeQuery (allRange, contentRangeH,
rangeStatusHeader)
import PostgREST.Statements (callProcStatement,
createExplainStatement,
createReadStatement,
createWriteStatement,
requestToCountQuery,
requestToQuery)
import PostgREST.RangeQuery (allRange, rangeOffset)
createWriteStatement)
import PostgREST.Types
import Protolude hiding (Proxy, intercalate)
@@ -56,7 +70,6 @@ postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> I
postgrest conf refDbStructure pool getTime worker =
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle
jwtSecret = parseSecret <$> configJwtSecret conf in
middle $ \ req respond -> do
time <- getTime
body <- strictRequestBody req
@@ -65,8 +78,9 @@ postgrest conf refDbStructure pool getTime worker =
Nothing -> respond . errorResponseFor $ ConnectionLostError
Just dbStructure -> do
response <- do
-- Need to parse ?columns early because findProc needs it to solve overloaded functions
let apiReq = userApiRequest (configSchema conf) (configRootSpec conf) req body
-- Need to parse ?columns early because findProc needs it to solve overloaded functions.
-- TODO: move this logic to the app function
let apiReq = userApiRequest (configSchemas conf) (configRootSpec conf) req body
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
case apiReqCols of
Left err -> return . errorResponseFor $ err
@@ -78,7 +92,7 @@ postgrest conf refDbStructure pool getTime worker =
(Just RawJSON{}, Just cls) -> cls
_ -> S.empty
proc = case iTarget apiRequest of
TargetProc qi _ -> findProc qi cols (iPreferSingleObjectParameter apiRequest) $ dbProcs dbStructure
TargetProc qi _ -> findProc qi cols (iPreferParameters apiRequest == Just SingleObject) $ dbProcs dbStructure
_ -> Nothing
handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest
txMode = transactionMode proc (iAction apiRequest)
@@ -90,46 +104,57 @@ postgrest conf refDbStructure pool getTime worker =
transactionMode :: Maybe ProcDescription -> Action -> HT.Mode
transactionMode proc action =
case action of
ActionRead -> HT.Read
ActionInfo -> HT.Read
ActionInspect -> HT.Read
ActionInvoke{isReadOnly=False} ->
ActionRead _ -> HT.Read
ActionInfo -> HT.Read
ActionInspect _ -> HT.Read
ActionInvoke InvGet -> HT.Read
ActionInvoke InvHead -> HT.Read
ActionInvoke InvPost ->
let v = maybe Volatile pdVolatility proc in
if v == Stable || v == Immutable
then HT.Read
else HT.Write
ActionInvoke{isReadOnly=True} -> HT.Read
_ -> HT.Write
app :: DbStructure -> Maybe ProcDescription -> S.Set FieldName -> AppConfig -> ApiRequest -> H.Transaction Response
app dbStructure proc cols conf apiRequest =
case responseContentTypeOrError (iAccepts apiRequest) (iAction apiRequest) (iTarget apiRequest) of
let rawContentTypes = (decodeContentType <$> configRawMediaTypes conf) `L.union` [ CTOctetStream, CTTextPlain ] in
case responseContentTypeOrError (iAccepts apiRequest) rawContentTypes (iAction apiRequest) (iTarget apiRequest) of
Left errorResponse -> return errorResponse
Right contentType ->
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
(ActionRead, TargetIdent qi, Nothing) ->
let partsField = (,) <$> readSqlParts
<*> (binaryField contentType =<< fldNames) in
case partsField of
(ActionRead headersOnly, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
case readSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right ((q, cq), bField) -> do
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount
(contentType == CTTextCSV) bField
Right (q, cq, bField) -> do
let cQuery = if estimatedCount
then limitedQuery cq ((+ 1) <$> maxRows) -- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
else cq
stm = createReadStatement q cQuery (contentType == CTSingularJSON) shouldCount
(contentType == CTTextCSV) bField pgVer
explStm = createExplainStatement cq
row <- H.statement () stm
let (tableTotal, queryTotal, _ , body) = row
(status, contentRange) = rangeHeader queryTotal tableTotal
canonical = iCanonicalQS apiRequest
return $
if contentType == CTSingularJSON && queryTotal /= 1
then errorResponseFor . singularityError $ queryTotal
else responseLBS status
[toHeader contentType, contentRange,
("Content-Location",
"/" <> toS (qiName qi) <>
if BS.null canonical then "" else "?" <> toS canonical
)
] (toS body)
let (tableTotal, queryTotal, _ , body, gucHeaders) = row
case gucHeaders of
Left _ -> return . errorResponseFor $ GucHeadersError
Right ghdrs -> do
total <- if | plannedCount -> H.statement () explStm
| estimatedCount -> if tableTotal > (fromIntegral <$> maxRows)
then do estTotal <- H.statement () explStm
pure $ if estTotal > tableTotal then estTotal else tableTotal
else pure tableTotal
| otherwise -> pure tableTotal
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal total
headers = addHeadersIfNotIncluded (catMaybes [
Just $ toHeader contentType, Just contentRange,
Just $ contentLocationH tName (iCanonicalQS apiRequest), profileH])
(unwrapGucHeader <$> ghdrs)
rBody = if headersOnly then mempty else toS body
return $
if contentType == CTSingularJSON && queryTotal /= 1
then errorResponseFor . singularityError $ queryTotal
else responseLBS status headers rBody
(ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
case mutateSqlParts tSchema tName of
@@ -138,32 +163,31 @@ app dbStructure proc cols conf apiRequest =
let pkCols = tablePKCols dbStructure tSchema tName
stm = createWriteStatement sq mq
(contentType == CTSingularJSON) True
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols pgVer
row <- H.statement (toS $ pjRaw pJson) stm
let (_, queryTotal, fs, body) = extractQueryResult row
headers = catMaybes [
if null fs
then Nothing
else Just (hLocation, "/" <> toS tName <> renderLocationFields fs)
, if iPreferRepresentation apiRequest == Full
then Just $ toHeader contentType
else Nothing
, Just $ contentRangeH 1 0 $
if shouldCount then Just queryTotal else Nothing
, if null pkCols
then Nothing
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
]
if contentType == CTSingularJSON
&& queryTotal /= 1
&& iPreferRepresentation apiRequest == Full
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return . responseLBS status201 headers $
if iPreferRepresentation apiRequest == Full
then toS body else ""
let (_, queryTotal, fields, body, gucHeaders) = row
case gucHeaders of
Left _ -> return . errorResponseFor $ GucHeadersError
Right ghdrs -> do
let
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
then ([Just $ toHeader contentType, profileH], toS body)
else ([], mempty)
headers = addHeadersIfNotIncluded (catMaybes ([
if null fields
then Nothing
else Just $ locationH tName fields
, Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
, if null pkCols && isNothing (iOnConflict apiRequest)
then Nothing
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
] ++ ctHeaders)) (unwrapGucHeader <$> ghdrs)
if contentType == CTSingularJSON && queryTotal /= 1
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return $ responseLBS status201 headers rBody
(ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
case mutateSqlParts tSchema tName of
@@ -171,31 +195,28 @@ app dbStructure proc cols conf apiRequest =
Right (sq, mq) -> do
let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) []
(iPreferRepresentation apiRequest) [] pgVer
row <- H.statement (toS $ pjRaw pJson) stm
let (_, queryTotal, _, body) = extractQueryResult row
updateIsNoOp = S.null cols
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $
if shouldCount then Just queryTotal else Nothing
minimalHeaders = [contentRangeHeader]
fullHeaders = toHeader contentType : minimalHeaders
status | queryTotal == 0 && not updateIsNoOp = status404
| iPreferRepresentation apiRequest == Full = status200
| otherwise = status204
case (contentType, iPreferRepresentation apiRequest) of
(CTSingularJSON, Full)
| queryTotal == 1 -> return $ responseLBS status fullHeaders (toS body)
| otherwise -> HT.condemn >> (return . errorResponseFor . singularityError) queryTotal
(_, Full) ->
return $ responseLBS status fullHeaders (toS body)
(_, _) ->
return $ responseLBS status minimalHeaders mempty
let (_, queryTotal, _, body, gucHeaders) = row
case gucHeaders of
Left _ -> return . errorResponseFor $ GucHeadersError
Right ghdrs -> do
let
updateIsNoOp = S.null cols
status | queryTotal == 0 && not updateIsNoOp = status404
| iPreferRepresentation apiRequest == Full = status200
| otherwise = status204
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
then ([Just $ toHeader contentType, profileH], toS body)
else ([], mempty)
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
if contentType == CTSingularJSON && queryTotal /= 1
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return $ responseLBS status headers rBody
(ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just ProcessedJSON{pjRaw, pjType, pjKeys}) ->
case mutateSqlParts tSchema tName of
@@ -214,19 +235,22 @@ app dbStructure proc cols conf apiRequest =
else do
row <- H.statement (toS pjRaw) $
createWriteStatement sq mq (contentType == CTSingularJSON) False
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) []
let (_, queryTotal, _, body) = extractQueryResult row
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
if queryTotal /= 1
then do
HT.condemn
return . errorResponseFor $ PutMatchingPkError
else
return $ if iPreferRepresentation apiRequest == Full
then responseLBS status200 [toHeader contentType] (toS body)
else responseLBS status204 [] ""
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) [] pgVer
let (_, queryTotal, _, body, gucHeaders) = row
case gucHeaders of
Left _ -> return . errorResponseFor $ GucHeadersError
Right ghdrs -> do
let headers = addHeadersIfNotIncluded (catMaybes [Just $ toHeader contentType, profileH]) (unwrapGucHeader <$> ghdrs)
(status, rBody) = if iPreferRepresentation apiRequest == Full then (status200, toS body) else (status204, mempty)
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
if queryTotal /= 1
then do
HT.condemn
return . errorResponseFor $ PutMatchingPkError
else
return $ responseLBS status headers rBody
(ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
case mutateSqlParts tSchema tName of
@@ -235,65 +259,68 @@ app dbStructure proc cols conf apiRequest =
let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False
(contentType == CTTextCSV)
(iPreferRepresentation apiRequest) []
(iPreferRepresentation apiRequest) [] pgVer
row <- H.statement mempty stm
let (_, queryTotal, _, body) = extractQueryResult row
r = contentRangeH 1 0 $
if shouldCount then Just queryTotal else Nothing
if contentType == CTSingularJSON
&& queryTotal /= 1
&& iPreferRepresentation apiRequest == Full
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return $ if iPreferRepresentation apiRequest == Full
then responseLBS status200 [toHeader contentType, r] (toS body)
else responseLBS status204 [r] ""
let (_, queryTotal, _, body, gucHeaders) = row
case gucHeaders of
Left _ -> return . errorResponseFor $ GucHeadersError
Right ghdrs -> do
let
status = if iPreferRepresentation apiRequest == Full then status200 else status204
contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
then ([Just $ toHeader contentType, profileH], toS body)
else ([], mempty)
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
if contentType == CTSingularJSON
&& queryTotal /= 1
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return $ responseLBS status headers rBody
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->
let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in
case mTable of
Nothing -> return notFound
Just table ->
let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
return $ responseLBS status200 [allOrigins, acceptH] ""
let allowH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET")
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header in
return $ responseLBS status200 [allOrigins, allowH] mempty
(ActionInvoke _, TargetProc qi _, Just pJson) ->
let returnsScalar = case proc of
Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True
_ -> False
rpcBinaryField = if returnsScalar
then Right Nothing
else binaryField contentType =<< fldNames
parts = (,) <$> readSqlParts <*> rpcBinaryField in
case parts of
(ActionInvoke invMethod, TargetProc qi@(QualifiedIdentifier tSchema pName) _, Just pJson) ->
let tName = fromMaybe pName $ procTableName =<< proc in
case readSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right ((q, cq), bField) -> do
let singular = contentType == CTSingularJSON
row <- H.statement (toS $ pjRaw pJson) $
callProc qi (specifiedProcArgs cols proc) returnsScalar q cq shouldCount
singular (iPreferSingleObjectParameter apiRequest)
(contentType == CTTextCSV)
(contentType `elem` rawContentTypes) bField
(pgVersion dbStructure)
let (tableTotal, queryTotal, body, jsonHeaders) =
fromMaybe (Just 0, 0, "[]", "[]") row
(status, contentRange) = rangeHeader queryTotal tableTotal
decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]
case decodedHeaders of
Right (q, cq, bField) -> do
let
preferParams = iPreferParameters apiRequest
pq = requestToCallProcQuery qi (specifiedProcArgs cols proc) returnsScalar preferParams
stm = callProcStatement returnsScalar pq q cq shouldCount (contentType == CTSingularJSON)
(contentType == CTTextCSV) (contentType `elem` rawContentTypes) (preferParams == Just MultipleObjects)
bField pgVer
row <- H.statement (toS $ pjRaw pJson) stm
let (tableTotal, queryTotal, body, gucHeaders) = row
case gucHeaders of
Left _ -> return . errorResponseFor $ GucHeadersError
Right hs ->
if singular && queryTotal /= 1
Right ghdrs -> do
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal tableTotal
headers = addHeadersIfNotIncluded
(catMaybes [Just $ toHeader contentType, Just contentRange, profileH])
(unwrapGucHeader <$> ghdrs)
rBody = if invMethod == InvHead then mempty else toS body
if contentType == CTSingularJSON && queryTotal /= 1
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body)
else
return $ responseLBS status headers rBody
(ActionInspect, TargetDefaultSpec, Nothing) -> do
(ActionInspect headersOnly, TargetDefaultSpec tSchema, Nothing) -> do
let host = configHost conf
port = toInteger $ configPort conf
proxy = pickProxy $ toS <$> configProxyUri conf
proxy = pickProxy $ toS <$> configOpenAPIProxyUri conf
uri Nothing = ("http", host, port, "/")
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
uri' = uri proxy
@@ -301,46 +328,57 @@ app dbStructure proc cols conf apiRequest =
toTableInfo = map (\t -> let (s, tn) = (tableSchema t, tableName t) in (t, tableCols dbStructure s tn, tablePKCols dbStructure s tn))
encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd $ dbPrimaryKeys dbStructure
body <- encodeApi <$> H.statement schema accessibleTables <*> H.statement schema schemaDescription <*> H.statement schema accessibleProcs
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
body <- encodeApi <$>
H.statement tSchema accessibleTables <*>
H.statement tSchema schemaDescription <*>
H.statement tSchema accessibleProcs
return $ responseLBS status200 (catMaybes [Just $ toHeader CTOpenAPI, profileH]) (if headersOnly then mempty else toS body)
_ -> return notFound
where
notFound = responseLBS status404 [] ""
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
shouldCount = iPreferCount apiRequest
schema = toS $ configSchema conf
topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest
rangeHeader queryTotal tableTotal =
let lower = rangeOffset topLevelRange
upper = lower + toInteger queryTotal - 1
contentRange = contentRangeH lower upper (toInteger <$> tableTotal)
status = rangeStatus lower upper (toInteger <$> tableTotal)
in (status, contentRange)
where
notFound = responseLBS status404 [] ""
maxRows = configMaxRows conf
exactCount = iPreferCount apiRequest == Just ExactCount
estimatedCount = iPreferCount apiRequest == Just EstimatedCount
plannedCount = iPreferCount apiRequest == Just PlannedCount
shouldCount = exactCount || estimatedCount
topLevelRange = iTopLevelRange apiRequest
returnsScalar = maybe False procReturnsScalar proc
pgVer = pgVersion dbStructure
profileH = contentProfileH <$> iProfile apiRequest
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) proc apiRequest
fldNames = fieldNames <$> readReq
readDbRequest = DbRead <$> readReq
selectQuery = requestToQuery schema False <$> readDbRequest
countQuery = requestToCountQuery schema <$> readDbRequest
readSqlParts = (,) <$> selectQuery <*> countQuery
mutationDbRequest s t = mutateRequest apiRequest t cols (tablePKCols dbStructure s t) =<< fldNames
mutateSqlParts s t =
(,) <$> selectQuery
<*> (requestToQuery schema False . DbMutate <$> mutationDbRequest s t)
readSqlParts s t =
let
readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
in
(,,) <$>
(readRequestToQuery <$> readReq) <*>
(readRequestToCountQuery <$> readReq) <*>
(binaryField contentType rawContentTypes returnsScalar =<< readReq)
responseContentTypeOrError :: [ContentType] -> Action -> Target -> Either Response ContentType
responseContentTypeOrError accepts action target = serves contentTypesForRequest accepts
mutateSqlParts s t =
let
readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
mutReq = mutateRequest s t apiRequest cols (tablePKCols dbStructure s t) =<< readReq
in
(,) <$>
(readRequestToQuery <$> readReq) <*>
(mutateRequestToQuery <$> mutReq)
responseContentTypeOrError :: [ContentType] -> [ContentType] -> Action -> Target -> Either Response ContentType
responseContentTypeOrError accepts rawContentTypes action target = serves contentTypesForRequest accepts
where
contentTypesForRequest = case action of
ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ++ rawContentTypes
ActionRead _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
++ rawContentTypes
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ++ rawContentTypes ++
[CTOpenAPI | tpIsRootSpec target]
ActionInspect -> [CTOpenAPI, CTApplicationJSON]
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
++ rawContentTypes
++ [CTOpenAPI | tpIsRootSpec target]
ActionInspect _ -> [CTOpenAPI, CTApplicationJSON]
ActionInfo -> [CTTextCSV]
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
serves sProduces cAccepts =
@@ -352,41 +390,34 @@ responseContentTypeOrError accepts action target = serves contentTypesForRequest
| If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that
| `?select=...` contains only one field other than `*`
-}
binaryField :: ContentType -> [FieldName] -> Either Response (Maybe FieldName)
binaryField ct fldNames
binaryField :: ContentType -> [ContentType] -> Bool -> ReadRequest -> Either Response (Maybe FieldName)
binaryField ct rawContentTypes isScalarProc readReq
| isScalarProc = Right Nothing
| ct `elem` rawContentTypes =
let fieldName = headMay fldNames in
if length fldNames == 1 && fieldName /= Just "*"
then Right fieldName
else Left . errorResponseFor $ BinaryFieldError ct
| otherwise = Right Nothing
where
fldNames = fstFieldNames readReq
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
splitKeyValue kv = (k, BS.tail v)
where (k, v) = BS.break (== '=') kv
locationH :: TableName -> [BS.ByteString] -> Header
locationH tName fields =
let
locationFields = renderSimpleQuery True $ splitKeyValue <$> fields
in
(hLocation, "/" <> toS tName <> locationFields)
where
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
splitKeyValue kv =
let (k, v) = BS.break (== '=') kv
in (k, BS.tail v)
renderLocationFields :: [BS.ByteString] -> BS.ByteString
renderLocationFields fields =
renderSimpleQuery True $ map splitKeyValue fields
contentLocationH :: TableName -> ByteString -> Header
contentLocationH tName qString =
("Content-Location", "/" <> toS tName <> if BS.null qString then mempty else "?" <> toS qString)
rangeStatus :: Integer -> Integer -> Maybe Integer -> Status
rangeStatus _ _ Nothing = status200
rangeStatus lower upper (Just total)
| lower > total = status416
| (1 + upper - lower) < total = status206
| otherwise = status200
contentRangeH :: (Integral a, Show a) => a -> a -> Maybe a -> Header
contentRangeH lower upper total =
("Content-Range", headerValue)
where
headerValue = rangeString <> "/" <> totalString
rangeString
| totalNotZero && fromInRange = show lower <> "-" <> show upper
| otherwise = "*"
totalString = maybe "*" show total
totalNotZero = maybe True (0 /=) total
fromInRange = lower <= upper
extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
extractQueryResult = fromMaybe (Nothing, 0, [], "")
contentProfileH :: Schema -> Header
contentProfileH schema =
("Content-Profile", toS schema)
+45 -17
View File
@@ -37,6 +37,7 @@ import Control.Lens (preview)
import Control.Monad (fail)
import Crypto.JWT (StringOrURI, stringOrUri)
import Data.List (lookup)
import Data.List.NonEmpty (NonEmpty, fromList)
import Data.Scientific (floatingOrInteger)
import Data.Text (dropEnd, dropWhileEnd,
intercalate, lines, splitOn,
@@ -46,8 +47,10 @@ import Data.Text.IO (hPutStrLn)
import Data.Version (versionBranch)
import Development.GitRev (gitHash)
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
import Numeric (readOct)
import Paths_postgrest (version)
import System.IO.Error (IOError)
import System.Posix.Types (FileMode)
import Control.Applicative
import Data.Monoid
@@ -58,21 +61,22 @@ import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))
import PostgREST.Error (ApiRequestError (..))
import PostgREST.Parsers (pRoleClaimKey)
import PostgREST.Types (JSPath, JSPathExp (..),
QualifiedIdentifier (..))
import PostgREST.Types (JSPath, JSPathExp (..))
import Protolude hiding (concat, hPutStrLn, intercalate, null,
take, (<>))
-- | Config file settings for the server
data AppConfig = AppConfig {
configDatabase :: Text
, configAnonRole :: Text
, configProxyUri :: Maybe Text
, configSchema :: Text
, configOpenAPIProxyUri :: Maybe Text
, configSchemas :: NonEmpty Text
, configHost :: Text
, configPort :: Int
, configSocket :: Maybe Text
, configSocket :: Maybe FilePath
, configSocketMode :: Either Text FileMode
, configJwtSecret :: Maybe B.ByteString
, configJwtSecretIsBase64 :: Bool
@@ -87,7 +91,8 @@ data AppConfig = AppConfig {
, configRoleClaimKey :: Either ApiRequestError JSPath
, configExtraSearchPath :: [Text]
, configRootSpec :: Maybe QualifiedIdentifier
, configRootSpec :: Maybe Text
, configRawMediaTypes :: [B.ByteString]
}
configPoolTimeout' :: (Fractional a) => AppConfig -> a
@@ -136,7 +141,7 @@ readOptions = do
-- Now read the actual config file
conf <- catches (C.load cfgPath)
[ Handler (\(ex :: IOError) -> exitErr $ "Cannot open config file:\n\t" <> show ex)
, Handler (\(C.ParseError err) -> exitErr $ "Error parsing config file:\n\t" <> err)
, Handler (\(C.ParseError err) -> exitErr $ "Error parsing config file:\n" <> err)
]
case C.runParser parseConfig conf of
@@ -146,16 +151,16 @@ readOptions = do
return appConf
where
dbSchema = reqString "db-schema"
parseConfig =
AppConfig
<$> reqString "db-uri"
<*> reqString "db-anon-role"
<*> optString "server-proxy-uri"
<*> dbSchema
<*> (fromList . splitOnCommas <$> reqValue "db-schema")
<*> (fromMaybe "!4" <$> optString "server-host")
<*> (fromMaybe 3000 <$> optInt "server-port")
<*> optString "server-unix-socket"
<*> (fmap unpack <$> optString "server-unix-socket")
<*> parseSocketFileMode "server-unix-socket-mode"
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
<*> (fromMaybe False <$> optBool "secret-is-base64")
<*> parseJwtAudience "jwt-aud"
@@ -166,8 +171,22 @@ readOptions = do
<*> pure False
<*> (fmap (fmap coerceText) <$> C.subassocs "app.settings" C.value)
<*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> optValue "role-claim-key")
<*> (maybe ["public"] splitExtraSearchPath <$> optValue "db-extra-search-path")
<*> ((\x y -> QualifiedIdentifier x <$> y) <$> dbSchema <*> optString "root-spec")
<*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path")
<*> optString "root-spec"
<*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types")
parseSocketFileMode :: C.Key -> C.Parser C.Config (Either Text FileMode)
parseSocketFileMode k =
C.optional k C.string >>= \case
Nothing -> pure $ Right 432 -- return default 660 mode if no value was provided
Just fileModeText ->
case (readOct . unpack) fileModeText of
[] ->
pure $ Left "Invalid server-unix-socket-mode: not an octal"
(fileMode, _):_ ->
if fileMode < 384 || fileMode > 511
then pure $ Left "Invalid server-unix-socket-mode: needs to be between 600 and 777"
else pure $ Right fileMode
parseJwtAudience :: C.Key -> C.Parser C.Config (Maybe StringOrURI)
parseJwtAudience k =
@@ -181,6 +200,9 @@ readOptions = do
reqString :: C.Key -> C.Parser C.Config Text
reqString k = C.required k C.string
reqValue :: C.Key -> C.Parser C.Config C.Value
reqValue k = C.required k C.value
optString :: C.Key -> C.Parser C.Config (Maybe Text)
optString k = mfilter (/= "") <$> C.optional k C.string
@@ -211,9 +233,9 @@ readOptions = do
parseRoleClaimKey (C.String s) = pRoleClaimKey s
parseRoleClaimKey v = pRoleClaimKey $ show v
splitExtraSearchPath :: C.Value -> [Text]
splitExtraSearchPath (C.String s) = strip <$> splitOn "," s
splitExtraSearchPath _ = []
splitOnCommas :: C.Value -> [Text]
splitOnCommas (C.String s) = strip <$> splitOn "," s
splitOnCommas _ = []
opts = info (helper <*> pathParser) $
fullDesc
@@ -248,13 +270,16 @@ readOptions = do
|## unix socket location
|## if specified it takes precedence over server-port
|# server-unix-socket = "/tmp/pgrst.sock"
|## unix socket file mode
|## when none is provided, 660 is applied by default
|# server-unix-socket-mode = "660"
|
|## base url for swagger output
|# server-proxy-uri = ""
|# openapi-server-proxy-uri = ""
|
|## choose a secret, JSON Web Key (or set) to enable JWT auth
|## (use "@filename" to load from separate file)
|# jwt-secret = "foo"
|# jwt-secret = "secret_with_at_least_32_characters"
|# secret-is-base64 = false
|# jwt-aud = "your_audience_claim"
|
@@ -273,6 +298,9 @@ readOptions = do
|## stored proc that overrides the root "/" spec
|## it must be inside the db-schema
|# root-spec = "stored_proc_name"
|
|## content types to produce raw output
|# raw-media-types="image/png, image/jpg"
|]
pathParser :: Parser FilePath
+185 -195
View File
@@ -14,248 +14,217 @@ A query tree is built in case of resource embedding. By inferring the relationsh
module PostgREST.DbRequestBuilder (
readRequest
, mutateRequest
, fieldNames
) where
import qualified Data.ByteString.Char8 as BS
import qualified Data.HashMap.Strict as M
import qualified Data.Set as S
import qualified Data.HashMap.Strict as M
import qualified Data.Set as S
import Control.Arrow ((***))
import Control.Lens.Getter (view)
import Control.Lens.Tuple (_1)
import Data.Either.Combinators (mapLeft)
import Data.Foldable (foldr1)
import Data.List (delete)
import Data.Maybe (fromJust)
import Data.Text (isInfixOf)
import Text.Regex.TDFA ((=~))
import Unsafe (unsafeHead)
import Control.Applicative
import Data.Tree
import Network.Wai
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
PreferRepresentation (..),
PreferRepresentation (..), Target (..))
import PostgREST.ApiRequest (Action (..), ApiRequest (..))
import PostgREST.Error (ApiRequestError (..), errorResponseFor)
import PostgREST.Parsers
import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange)
import PostgREST.Types
import Protolude hiding (from)
readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest
readRequest maxRows allRels proc apiRequest =
readRequest :: Schema -> TableName -> Maybe Integer -> [Relation] -> ApiRequest -> Either Response ReadRequest
readRequest schema rootTableName maxRows allRels apiRequest =
mapLeft errorResponseFor $
treeRestrictRange maxRows =<<
augumentRequestWithJoin schema relations =<<
augumentRequestWithJoin schema rootRels =<<
addFiltersOrdersRanges apiRequest <*>
(buildReadRequest <$> pRequestSelect (iSelect apiRequest))
(initReadRequest rootName <$> pRequestSelect sel)
where
action = iAction apiRequest
(schema, rootTableName) = fromJust $ -- Make it safe
let target = iTarget apiRequest in
case target of
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
(TargetProc (QualifiedIdentifier s pName) _ ) -> Just (s, tName)
where
tName = case pdReturnType <$> proc of
Just (SetOf (Composite qi)) -> qiName qi
Just (Single (Composite qi)) -> qiName qi
_ -> pName
sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param
(rootName, rootRels) = rootWithRels schema rootTableName allRels (iAction apiRequest)
_ -> Nothing
-- Get the root table name with its relationships according to the Action type.
-- This is done because of the shape of the final SQL Query. The mutation cases are wrapped in a WITH {sourceCTEName}(see Statements.hs).
-- So we need a FROM {sourceCTEName} instead of FROM {tableName}.
rootWithRels :: Schema -> TableName -> [Relation] -> Action -> (QualifiedIdentifier, [Relation])
rootWithRels schema rootTableName allRels action = case action of
ActionRead _ -> (QualifiedIdentifier schema rootTableName, allRels) -- normal read case
_ -> (QualifiedIdentifier mempty sourceCTEName, mapMaybe toSourceRel allRels ++ allRels) -- mutation cases and calling proc
where
-- To enable embedding in the sourceCTEName cases we need to replace the foreign key tableName in the Relation
-- with {sourceCTEName}. This way findRel can find relationships with sourceCTEName.
toSourceRel :: Relation -> Maybe Relation
toSourceRel r@Relation{relTable=t}
| rootTableName == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
| otherwise = Nothing
-- Build tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having
-- an alias like "table_depth", this is related to issue #987.
buildReadRequest :: [Tree SelectItem] -> ReadRequest
buildReadRequest fieldTree =
let rootDepth = 0
rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in
foldr (treeEntry rootDepth) (Node (Select [] rootNodeName Nothing [] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree
where
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
let nxtDepth = succ depth in
case fldForest of
[] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) $
foldr (treeEntry nxtDepth) (Node (Select [] fn Nothing [] [] [] [] allRange, (fn, Nothing, alias, relationDetail, nxtDepth)) []) fldForest:rForest
relations :: [Relation]
relations = case action of
ActionCreate -> fakeSourceRelations ++ allRels
ActionUpdate -> fakeSourceRelations ++ allRels
ActionDelete -> fakeSourceRelations ++ allRels
ActionInvoke _ -> fakeSourceRelations ++ allRels
_ -> allRels
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels
-- in a relation where one of the tables matches "TableName"
-- replace the name to that table with pg_source
-- this "fake" relations is needed so that in a mutate query
-- we can look at the "returning *" part which is wrapped with a "with"
-- as just another table that has relations with other tables
toSourceRelation :: TableName -> Relation -> Maybe Relation
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
| mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
| Just mt == (tableName <$> rt) = Just $ r {relLinkTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
| otherwise = Nothing
-- Build the initial tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having
-- an alias like "table_depth", this is related to http://github.com/PostgREST/postgrest/issues/987.
initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadRequest
initReadRequest rootQi =
foldr (treeEntry rootDepth) initial
where
rootDepth = 0
rootSchema = qiSchema rootQi
rootName = qiName rootQi
initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, rootDepth)) []
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
treeEntry depth (Node fld@((fn, _),_,alias, embedHint) fldForest) (Node (q, i) rForest) =
let nxtDepth = succ depth in
case fldForest of
[] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) $
foldr (treeEntry nxtDepth)
(Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,
(fn, Nothing, alias, embedHint, nxtDepth)) [])
fldForest:rForest
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
treeRestrictRange maxRows request = pure $ nodeRestrictRange maxRows <$> request
where
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
augumentRequestWithJoin schema allRels request =
addRelations schema allRels Nothing request
>>= addJoinConditions schema Nothing
addRels schema allRels Nothing request
>>= addJoinConditions Nothing
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
addRelations schema allRelations parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, relationDetail, depth)) forest) =
addRels :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, depth)) forest) =
case parentNode of
Just (Node (Select{from=parentNodeTable}, _) _) ->
let newFrom r = if tbl == nodeName then tableName (relTable r) else tbl
Just (Node (Select{from=parentNodeQi}, _) _) ->
let newFrom r = if qiName tbl == nodeName then tableQi (relFTable r) else tbl
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
rel :: Either ApiRequestError Relation
rel = note (NoRelationBetween parentNodeTable nodeName) $
findRelation schema allRelations nodeName parentNodeTable relationDetail in
rel = findRel schema allRels (qiName parentNodeQi) nodeName hint
in
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
_ ->
let rn = (query, (nodeName, Just r, alias, Nothing, depth))
r = Relation t [] t [] Root Nothing Nothing Nothing
t = Table schema nodeName Nothing True in -- !!! TODO find another way to get the table from the query
let rn = (query, (nodeName, Nothing, alias, Nothing, depth)) in
Node rn <$> updateForest (Just $ Node rn forest)
where
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
updateForest rq = mapM (addRelations schema allRelations rq) forest
updateForest rq = mapM (addRels schema allRels rq) forest
findRelation :: Schema -> [Relation] -> NodeName -> TableName -> Maybe RelationDetail -> Maybe Relation
findRelation schema allRelations nodeTableName parentNodeTableName relationDetail =
find (\Relation{relTable, relColumns, relFTable, relFColumns, relType, relLinkTable} ->
-- Both relation ends need to be on the exposed schema
schema == tableSchema relTable && schema == tableSchema relFTable &&
case relationDetail of
Nothing ->
-- (request) => projects { ..., clients{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
-- Finds a relationship between an origin and a target in the request: /origin?select=target(*)
-- If more than one relationship is found then the request is ambiguous and we return an error.
-- In that case the request can be disambiguated by adding precision to the target or by using a hint: /origin?select=target!hint(*)
-- The elements will be matched according to these rules:
-- origin = table / view
-- target = table / view / constraint / column-from-origin
-- hint = table / view / constraint / column-from-origin / column-from-target
-- (hint can take table / view values to aid in finding the junction in an m2m relationship)
findRel :: Schema -> [Relation] -> NodeName -> NodeName -> Maybe EmbedHint -> Either ApiRequestError Relation
findRel schema allRels origin target hint =
case rel of
[] -> Left $ NoRelBetween origin target
[r] -> Right r
rs ->
-- Return error if more than one relationship is found, unless we're in a self reference case.
--
-- Here we handle a self reference relationship to not cause a breaking change:
-- In a self reference we get two relationships with the same foreign key and relTable/relFtable but with different cardinalities(m2o/o2m)
-- We output the O2M rel, the M2O rel can be obtained by using the origin column as an embed hint.
let [rel0, rel1] = take 2 rs in
if length rs == 2 && relConstraint rel0 == relConstraint rel1 && relTable rel0 == relTable rel1 && relFTable rel0 == relFTable rel1
then note (NoRelBetween origin target) (find (\r -> relType r == O2M) rs)
else Left $ AmbiguousRelBetween origin target rs
where
matchFKSingleCol hint_ cols = length cols == 1 && hint_ == (colName <$> head cols)
rel = filter (
\Relation{relTable, relColumns, relConstraint, relFTable, relFColumns, relType, relJunction} ->
-- Both relationship ends need to be on the exposed schema
schema == tableSchema relTable && schema == tableSchema relFTable &&
(
nodeTableName == tableName relTable && -- match relation table name
parentNodeTableName == tableName relFTable -- match relation foreign table name
) ||
-- /projects?select=clients(*)
origin == tableName relTable && -- projects
target == tableName relFTable || -- clients
-- (request) => projects { ..., client_id{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
parentNodeTableName == tableName relFTable &&
length relFColumns == 1 &&
-- match common foreign key names(table_name_id, table_name_fk) to table_name
(toS ("^" <> colName (unsafeHead relFColumns) <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS nodeTableName :: BS.ByteString)
-- /projects?select=projects_client_id_fkey(*)
(
origin == tableName relTable && -- projects
Just target == relConstraint -- projects_client_id_fkey
) ||
-- /projects?select=client_id(*)
(
origin == tableName relTable && -- projects
matchFKSingleCol (Just target) relColumns -- client_id
)
) && (
isNothing hint || -- hint is optional
-- /projects?select=clients!projects_client_id_fkey(*)
hint == relConstraint || -- projects_client_id_fkey
-- /projects?select=clients!client_id(*) or /projects?select=clients!id(*)
matchFKSingleCol hint relColumns || -- client_id
matchFKSingleCol hint relFColumns || -- id
-- /users?select=tasks!users_tasks(*)
(
relType == M2M && -- many-to-many between users and tasks
hint == (tableName . junTable <$> relJunction) -- users_tasks
)
)
-- (request) => project_id { ..., client_id{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
-- this case works becasue before reaching this place
-- addRelation will turn project_id to project so the above condition will match
Just rd ->
-- (request) => clients { ..., projects.client_id{...} }
-- will match
-- (relation type) => child
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
relType == Child &&
nodeTableName == tableName relTable && -- match relation table name
parentNodeTableName == tableName relFTable && -- match relation foreign table name
length relColumns == 1 &&
rd == colName (unsafeHead relColumns)
) ||
-- (request) => message { ..., person_detail.sender{...} }
-- will match
-- (relation type) => parent
-- (entity) => message {sender}
-- (foriegn entity) => person_detail {id}
(
relType == Parent &&
nodeTableName == tableName relTable && -- match relation table name
parentNodeTableName == tableName relFTable && -- match relation foreign table name
length relFColumns == 1 &&
rd == colName (unsafeHead relFColumns)
) ||
-- (request) => tasks { ..., users.tasks_users{...} }
-- will match
-- (relation type) => many
-- (entity) => users
-- (foriegn entity) => tasks
(
relType == Many &&
nodeTableName == tableName relTable && -- match relation table name
parentNodeTableName == tableName relFTable && -- match relation foreign table name
rd == tableName (fromJust relLinkTable)
)
) allRelations
) allRels
-- previousAlias is only used for the case of self joins
addJoinConditions :: Schema -> Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
addJoinConditions schema previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, relation, _, _, depth)) forest) =
case relation of
Just Relation{relType=Root} -> Node node <$> updatedForest -- this is the root node
Just rel@Relation{relType=Parent} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
Just rel@Relation{relType=Many, relLinkTable=(Just linkTable)} ->
let rq = augmentQuery rel in
Node (rq{implicitJoins=tableName linkTable:implicitJoins rq}, nodeProps) <$> updatedForest
_ -> Left UnknownRelation
addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, depth)) forest) =
case rel of
Just r@Relation{relType=O2M} -> Node (augmentQuery r, nodeProps) <$> updatedForest
Just r@Relation{relType=M2O} -> Node (augmentQuery r, nodeProps) <$> updatedForest
Just r@Relation{relType=M2M, relJunction=junction} ->
case junction of
Just Junction{junTable} ->
let rq = augmentQuery r in
Node (rq{implicitJoins=tableQi junTable:implicitJoins rq}, nodeProps) <$> updatedForest
Nothing ->
Left UnknownRelation
Nothing -> Node node <$> updatedForest
where
newAlias = case isSelfJoin <$> relation of
newAlias = case isSelfReference <$> rel of
Just True
| depth /= 0 -> Just (tbl <> "_" <> show depth) -- root node doesn't get aliased
| depth /= 0 -> Just (qiName tbl <> "_" <> show depth) -- root node doesn't get aliased
| otherwise -> Nothing
_ -> Nothing
augmentQuery rel =
augmentQuery r =
foldr
(\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})
query{fromAlias=newAlias}
(getJoinConditions previousAlias newAlias rel)
updatedForest = mapM (addJoinConditions schema newAlias) forest
(getJoinConditions previousAlias newAlias r)
updatedForest = mapM (addJoinConditions newAlias) forest
-- previousAlias and newAlias are used in the case of self joins
getJoinConditions :: Maybe Alias -> Maybe Alias -> Relation -> [JoinCondition]
getJoinConditions previousAlias newAlias (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols typ lt lc1 lc2) =
getJoinConditions previousAlias newAlias (Relation Table{tableSchema=tSchema, tableName=tN} cols _ Table{tableName=ftN} fCols typ jun) =
case typ of
Child ->
O2M ->
zipWith (toJoinCondition tN ftN) cols fCols
Parent ->
M2O ->
zipWith (toJoinCondition tN ftN) cols fCols
Many ->
let ltN = maybe "" tableName lt in
zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2)
Root -> witness
M2M -> case jun of
Just (Junction jt _ jc1 _ jc2) ->
let jtn = tableName jt in
zipWith (toJoinCondition tN jtn) cols jc1 ++ zipWith (toJoinCondition ftN jtn) fCols jc2
Nothing -> []
where
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
toJoinCondition tb ftb c fc =
let qi1 = QualifiedIdentifier tSchema tb
qi2 = QualifiedIdentifier tSchema ftb in
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) newAlias, colName c)
(maybe qi2 (QualifiedIdentifier mempty) previousAlias, colName fc)
let qi1 = removeSourceCTESchema tSchema tb
qi2 = removeSourceCTESchema tSchema ftb in
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, colName c)
(maybe qi2 (QualifiedIdentifier mempty) newAlias, colName fc)
-- On mutation and calling proc cases we wrap the target table in a WITH {sourceCTEName}
-- if this happens remove the schema `FROM "schema"."{sourceCTEName}"` and use only the
-- `FROM "{sourceCTEName}"`. If the schema remains the FROM would be invalid.
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then mempty else schema) tbl
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
@@ -266,7 +235,7 @@ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
]
{-
The esence of what is going on above is that we are composing tree functions
of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context
of type (ReadRequest->ReadRequest) that are in (Either ApiRequestError a) context
-}
where
filters :: Either ApiRequestError [(EmbedPath, Filter)]
@@ -278,7 +247,7 @@ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
(flts, logFrst) =
case action of
ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)
ActionRead -> (iFilters apiRequest, iLogic apiRequest)
ActionRead _ -> (iFilters apiRequest, iLogic apiRequest)
_ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
orders = mapM pRequestOrder $ iOrder apiRequest
@@ -318,11 +287,15 @@ addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
where
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
mutateRequest :: ApiRequest -> TableName -> S.Set FieldName -> [FieldName] -> [FieldName] -> Either Response MutateRequest
mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
mutateRequest :: Schema -> TableName -> ApiRequest -> S.Set FieldName -> [FieldName] -> ReadRequest -> Either Response MutateRequest
mutateRequest schema tName apiRequest cols pkCols readReq = mapLeft errorResponseFor $
case action of
ActionCreate -> Right $ Insert tName cols ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings
ActionUpdate -> Update tName cols <$> combinedLogic <*> pure returnings
ActionCreate -> do
confCols <- case iOnConflict apiRequest of
Nothing -> pure pkCols
Just param -> pRequestOnConflict param
pure $ Insert qi cols ((,) <$> iPreferResolution apiRequest <*> Just confCols) [] returnings
ActionUpdate -> Update qi cols <$> combinedLogic <*> pure returnings
ActionSingleUpsert ->
(\flts ->
if null (iLogic apiRequest) &&
@@ -331,14 +304,18 @@ mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
all (\case
Filter _ (OpExpr False (Op "eq" _)) -> True
_ -> False) flts
then Insert tName cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
then Insert qi cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
else
Left InvalidFilters) =<< filters
ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings
ActionDelete -> Delete qi <$> combinedLogic <*> pure returnings
_ -> Left UnsupportedVerb
where
qi = QualifiedIdentifier schema tName
action = iAction apiRequest
returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
returnings =
if iPreferRepresentation apiRequest == None
then []
else returningCols readReq
filters = map snd <$> mapM pRequestFilter mutateFilters
logic = map snd <$> mapM pRequestLogicTree logicFilters
combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters
@@ -346,13 +323,26 @@ mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
(mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
fieldNames :: ReadRequest -> [FieldName]
fieldNames (Node (sel, _) forest) =
map (fst . view _1) (select sel) ++ map colName fks
returningCols :: ReadRequest -> [FieldName]
returningCols rr@(Node _ forest) = returnings
where
fks = concatMap (fromMaybe [] . f) forest
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _, _)) _) = Just cols
f _ = Nothing
fldNames = fstFieldNames rr
-- Without fkCols, when a mutateRequest to /projects?select=name,clients(name) occurs, the RETURNING SQL part would be
-- `RETURNING name`(see QueryBuilder).
-- This would make the embedding fail because the following JOIN would need the "client_id" column from projects.
-- So this adds the foreign key columns to ensure the embedding succeeds, result would be `RETURNING name, client_id`.
-- This also works for the other relType's.
fkCols = concat $ mapMaybe (\case
Node (_, (_, Just Relation{relColumns=cols, relType=relTyp}, _, _, _)) _ -> case relTyp of
O2M -> Just cols
M2O -> Just cols
M2M -> Just cols
_ -> Nothing
) forest
-- However if the "client_id" is present, e.g. mutateRequest to /projects?select=client_id,name,clients(name)
-- we would get `RETURNING client_id, name, client_id` and then we would produce the "column reference \"client_id\" is ambiguous"
-- error from PostgreSQL. So we deduplicate with Set:
returnings = S.toList . S.fromList $ fldNames ++ (colName <$> fkCols)
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
-- they are later concatenated with AND in the QueryBuilder
+132 -138
View File
@@ -40,34 +40,23 @@ import Unsafe (unsafeHead)
import Control.Applicative
import PostgREST.Private.Common
import PostgREST.Types
import Protolude
column :: HD.Value a -> HD.Row a
column = HD.column . HD.nonNullable
getDbStructure :: [Schema] -> PgVersion -> HT.Transaction DbStructure
getDbStructure schemas pgVer = do
HT.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
tabs <- HT.statement () allTables
cols <- HT.statement schemas $ allColumns tabs
srcCols <- HT.statement schemas $ allSourceColumns cols pgVer
m2oRels <- HT.statement () $ allM2ORels tabs cols
keys <- HT.statement () $ allPrimaryKeys tabs
procs <- HT.statement schemas allProcs
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
nullableColumn = HD.column . HD.nullable
element :: HD.Value a -> HD.Array a
element = HD.element . HD.nonNullable
param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable
getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure
getDbStructure schema pgVer = do
HT.sql "set local schema ''" -- for getting the fully qualified name(schema.name) of every db object
tabs <- HT.statement () allTables
cols <- HT.statement schema $ allColumns tabs
syns <- HT.statement schema $ allSynonyms cols pgVer
childRels <- HT.statement () $ allChildRelations tabs cols
keys <- HT.statement () $ allPrimaryKeys tabs
procs <- HT.statement schema allProcs
let rels = addManyToManyRelations . addParentRelations $ addViewChildRelations syns childRels
let rels = addM2MRels . addO2MRels $ addViewM2ORels srcCols m2oRels
cols' = addForeignKeys rels cols
keys' = addViewPrimaryKeys syns keys
keys' = addViewPrimaryKeys srcCols keys
return DbStructure {
dbTables = tabs
@@ -102,13 +91,14 @@ decodeColumns tables =
<*> nullableColumn HD.text
<*> nullableColumn HD.text
decodeRelations :: [Table] -> [Column] -> HD.Result [Relation]
decodeRelations tables cols =
mapMaybe (relationFromRow tables cols) <$> HD.rowList relRow
decodeRels :: [Table] -> [Column] -> HD.Result [Relation]
decodeRels tables cols =
mapMaybe (relFromRow tables cols) <$> HD.rowList relRow
where
relRow = (,,,,,)
relRow = (,,,,,,)
<$> column HD.text
<*> column HD.text
<*> column HD.text
<*> column (HD.array (HD.dimension replicateM (element HD.text)))
<*> column HD.text
<*> column HD.text
@@ -120,22 +110,30 @@ decodePks tables =
where
pkRow = (,,) <$> column HD.text <*> column HD.text <*> column HD.text
decodeSynonyms :: [Column] -> HD.Result [Synonym]
decodeSynonyms cols =
mapMaybe (synonymFromRow cols) <$> HD.rowList synRow
decodeSourceColumns :: [Column] -> HD.Result [SourceColumn]
decodeSourceColumns cols =
mapMaybe (sourceColumnFromRow cols) <$> HD.rowList srcColRow
where
synRow = (,,,,,)
srcColRow = (,,,,,)
<$> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text
decodeProcs :: HD.Result (M.HashMap Text [ProcDescription])
sourceColumnFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe SourceColumn
sourceColumnFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
where
col1 = findCol s1 t1 c1
col2 = findCol s2 t2 c2
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
decodeProcs :: HD.Result ProcsMap
decodeProcs =
-- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addName) <$> HD.rowList tblRow
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addKey) <$> HD.rowList procRow
where
tblRow = ProcDescription
procRow = ProcDescription
<$> column HD.text
<*> column HD.text
<*> nullableColumn HD.text
<*> (parseArgs <$> column HD.text)
<*> (parseRetType
@@ -145,8 +143,8 @@ decodeProcs =
<*> column HD.char)
<*> (parseVolatility <$> column HD.char)
addName :: ProcDescription -> (Text, ProcDescription)
addName pd = (pdName pd, pd)
addKey :: ProcDescription -> (QualifiedIdentifier, ProcDescription)
addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd)
parseArgs :: Text -> [PgArg]
parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')
@@ -179,31 +177,34 @@ decodeProcs =
| v == 's' = Stable
| otherwise = Volatile -- only 'v' can happen here
allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
allProcs = H.Statement (toS procsSqlQuery) (param HE.text) decodeProcs True
allProcs :: H.Statement [Schema] ProcsMap
allProcs = H.Statement (toS sql) (arrayParam HE.text) decodeProcs True
where
sql = procsSqlQuery <> " WHERE pn.nspname = ANY($1)"
accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
accessibleProcs :: H.Statement Schema ProcsMap
accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs True
where
sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"
sql = procsSqlQuery <> " WHERE pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')"
procsSqlQuery :: SqlQuery
procsSqlQuery = [q|
SELECT p.proname as "proc_name",
d.description as "proc_description",
pg_get_function_arguments(p.oid) as "args",
tn.nspname as "rettype_schema",
coalesce(comp.relname, t.typname) as "rettype_name",
p.proretset as "rettype_is_setof",
t.typtype as "rettype_typ",
p.provolatile
SELECT
pn.nspname as "proc_schema",
p.proname as "proc_name",
d.description as "proc_description",
pg_get_function_arguments(p.oid) as "args",
tn.nspname as "rettype_schema",
coalesce(comp.relname, t.typname) as "rettype_name",
p.proretset as "rettype_is_setof",
t.typtype as "rettype_typ",
p.provolatile
FROM pg_proc p
JOIN pg_namespace pn ON pn.oid = p.pronamespace
JOIN pg_type t ON t.oid = p.prorettype
JOIN pg_namespace tn ON tn.oid = t.typnamespace
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
WHERE pn.nspname = $1
|]
schemaDescription :: H.Statement Schema (Maybe Text)
@@ -254,20 +255,20 @@ addForeignKeys rels = map addFk
addFk col = col { colFK = fk col }
fk col = find (lookupFn col) rels >>= relToFk col
lookupFn :: Column -> Relation -> Bool
lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==Child
lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==M2O
relToFk col Relation{relColumns=cols, relFColumns=colsF} = do
pos <- L.elemIndex col cols
colF <- atMay colsF pos
return $ ForeignKey colF
{-
Adds Views Child Relations based on Synonyms found, the logic is as follows:
Adds Views M2O Relations based on SourceColumns found, the logic is as follows:
Having a Relation{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relType=Child} represented by:
Having a Relation{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relType=M2O} represented by:
t1.c1------t2.c2
When only having a t1_view.c1 synonym, we need to add a View to Table Child Relation
When only having a t1_view.c1 source column, we need to add a View-Table M2O Relation
t1.c1----t2.c2 t1.c1----------t2.c2
-> ________/
@@ -275,70 +276,72 @@ When only having a t1_view.c1 synonym, we need to add a View to Table Child Rela
t1_view.c1 t1_view.c1
When only having a t2_view.c2 synonym, we need to add a Table to View Child Relation
When only having a t2_view.c2 source column, we need to add a Table-View M2O Relation
t1.c1----t2.c2 t1.c1----------t2.c2
-> \________
\
t2_view.c2 t2_view.c1
When having t1_view.c1 and a t2_view.c2 synonyms, we need to add a View to View Child Relation in addition to the prior
When having t1_view.c1 and a t2_view.c2 source columns, we need to add a View-View M2O Relation in addition to the prior
t1.c1----t2.c2 t1.c1----------t2.c2
-> \________/
/ \
t1_view.c1 t2_view.c2 t1_view.c1-------t2_view.c1
The logic for composite pks is similar just need to make sure all the Relation columns have synonyms.
The logic for composite pks is similar just need to make sure all the Relation columns have source columns.
-}
addViewChildRelations :: [Synonym] -> [Relation] -> [Relation]
addViewChildRelations allSyns = concatMap (\rel ->
addViewM2ORels :: [SourceColumn] -> [Relation] -> [Relation]
addViewM2ORels allSrcCols = concatMap (\rel ->
rel : case rel of
Relation{relType=Child, relTable, relColumns, relFTable, relFColumns} ->
Relation{relType=M2O, relTable, relColumns, relConstraint, relFTable, relFColumns} ->
let colSynsGroupedByView :: [Column] -> [[Synonym]]
colSynsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $
filter (\(c, _) -> c `elem` relCols) allSyns
colsSyns = colSynsGroupedByView relColumns
fColsSyns = colSynsGroupedByView relFColumns
getView :: [Synonym] -> Table
let srcColsGroupedByView :: [Column] -> [[SourceColumn]]
srcColsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $
filter (\(c, _) -> c `elem` relCols) allSrcCols
relSrcCols = srcColsGroupedByView relColumns
relFSrcCols = srcColsGroupedByView relFColumns
getView :: [SourceColumn] -> Table
getView = colTable . snd . unsafeHead
syns `allSynsOf` cols = S.fromList (fst <$> syns) == S.fromList cols
srcCols `allSrcColsOf` cols = S.fromList (fst <$> srcCols) == S.fromList cols
-- Relation is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query.
-- So we need to change the order of the synonyms to match the relColumns
-- This could be avoided if the Relation type is improved with a structure that maintains the association of relColumns and relFColumns
syns `sortAccordingTo` columns = sortOn (\(k, _) -> L.lookup k $ zip columns [0::Int ..]) syns
-- So we need to change the order of the SourceColumns to match the relColumns
-- TODO: This could be avoided if the Relation type is improved with a structure that maintains the association of relColumns and relFColumns
srcCols `sortAccordingTo` cols = sortOn (\(k, _) -> L.lookup k $ zip cols [0::Int ..]) srcCols
viewTableChild =
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
relFTable relFColumns
Child Nothing Nothing Nothing
| syns <- colsSyns, syns `allSynsOf` relColumns ]
viewTableM2O =
[ Relation (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)
relConstraint relFTable relFColumns
M2O Nothing
| srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns ]
tableViewChild =
tableViewM2O =
[ Relation relTable relColumns
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
Child Nothing Nothing Nothing
| fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
relConstraint
(getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relFColumns)
M2O Nothing
| fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relFColumns ]
viewViewChild =
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
Child Nothing Nothing Nothing
| syns <- colsSyns, syns `allSynsOf` relColumns
, fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
viewViewM2O =
[ Relation (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)
relConstraint
(getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relFColumns)
M2O Nothing
| srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns
, fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relFColumns ]
in viewTableChild ++ tableViewChild ++ viewViewChild
in viewTableM2O ++ tableViewM2O ++ viewViewM2O
_ -> [])
addParentRelations :: [Relation] -> [Relation]
addParentRelations = concatMap (\rel@(Relation t c ft fc _ _ _ _) -> [rel, Relation ft fc t c Parent Nothing Nothing Nothing])
addO2MRels :: [Relation] -> [Relation]
addO2MRels = concatMap (\rel@(Relation t c cn ft fc _ _) -> [rel, Relation ft fc cn t c O2M Nothing])
addManyToManyRelations :: [Relation] -> [Relation]
addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation links)
addM2MRels :: [Relation] -> [Relation]
addM2MRels rels = rels ++ addMirrorRel (mapMaybe junction2Rel junctions)
where
links = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==Child). relType) rels
junctions = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==M2O). relType) rels
groupFn :: Relation -> Text
groupFn Relation{relTable=Table{tableSchema=s, tableName=t}} = s <> "_" <> t
-- Reference : https://wiki.haskell.org/99_questions/Solutions/26
@@ -346,19 +349,20 @@ addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation
combinations 0 _ = [ [] ]
combinations n xs = [ y:ys | y:xs' <- tails xs
, ys <- combinations (n-1) xs']
addMirrorRelation = concatMap (\rel@(Relation t c ft fc _ lt lc1 lc2) -> [rel, Relation ft fc t c Many lt lc2 lc1])
link2Relation [
Relation{relTable=lt, relColumns=lc1, relFTable=t, relFColumns=c},
Relation{ relColumns=lc2, relFTable=ft, relFColumns=fc}
junction2Rel [
Relation{relTable=jt, relColumns=jc1, relConstraint=const1, relFTable=t, relFColumns=c},
Relation{ relColumns=jc2, relConstraint=const2, relFTable=ft, relFColumns=fc}
]
| lc1 /= lc2 && length lc1 == 1 && length lc2 == 1 = Just $ Relation t c ft fc Many (Just lt) (Just lc1) (Just lc2)
| jc1 /= jc2 && length jc1 == 1 && length jc2 == 1 = Just $ Relation t c Nothing ft fc M2M (Just $ Junction jt const1 jc1 const2 jc2)
| otherwise = Nothing
link2Relation _ = Nothing
junction2Rel _ = Nothing
addMirrorRel = concatMap (\rel@(Relation t c _ ft fc _ (Just (Junction jt const1 jc1 const2 jc2))) ->
[rel, Relation ft fc Nothing t c M2M (Just (Junction jt const2 jc2 const1 jc1))])
addViewPrimaryKeys :: [Synonym] -> [PrimaryKey] -> [PrimaryKey]
addViewPrimaryKeys syns = concatMap (\pk ->
addViewPrimaryKeys :: [SourceColumn] -> [PrimaryKey] -> [PrimaryKey]
addViewPrimaryKeys srcCols = concatMap (\pk ->
let viewPks = (\(_, viewCol) -> PrimaryKey{pkTable=colTable viewCol, pkName=colName viewCol}) <$>
filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) syns in
filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) srcCols in
pk : viewPks)
allTables :: H.Statement () [Table]
@@ -384,9 +388,9 @@ allTables =
GROUP BY table_schema, table_name, insertable
ORDER BY table_schema, table_name |]
allColumns :: [Table] -> H.Statement Schema [Column]
allColumns :: [Table] -> H.Statement [Schema] [Column]
allColumns tabs =
H.Statement sql (param HE.text) (decodeColumns tabs) True
H.Statement sql (arrayParam HE.text) (decodeColumns tabs) True
where
sql = [q|
SELECT DISTINCT
@@ -404,7 +408,7 @@ allColumns tabs =
array_to_string(enum_info.vals, ',') AS enum
FROM (
/*
-- CTE based on pg_catalog to get only Primary and Foreign key columns outside api schema
-- CTE based on pg_catalog to get PRIMARY/FOREIGN key and UNIQUE columns outside api schema
*/
WITH key_columns AS (
SELECT
@@ -420,11 +424,11 @@ allColumns tabs =
pg_catalog.pg_class c,
pg_catalog.pg_namespace n
WHERE
r.contype IN ('f', 'p')
r.contype IN ('f', 'p', 'u')
AND c.relkind IN ('r', 'v', 'f', 'm')
AND r.conrelid = c.oid
AND c.relnamespace = n.oid
AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1)
AND n.nspname <> ANY (ARRAY['pg_catalog', 'information_schema'] || $1)
),
/*
-- CTE based on information_schema.columns
@@ -437,7 +441,7 @@ allColumns tabs =
nc.nspname::information_schema.sql_identifier AS table_schema,
c.relname::information_schema.sql_identifier AS table_name,
a.attname::information_schema.sql_identifier AS column_name,
d.description::information_schema.sql_identifier AS description,
d.description AS description,
a.attnum::information_schema.cardinal_number AS ordinal_position,
pg_get_expr(ad.adbin, ad.adrelid)::information_schema.character_data AS column_default,
CASE
@@ -526,7 +530,7 @@ allColumns tabs =
AND a.attnum > 0
AND NOT a.attisdropped
AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char", 'm'::"char"]))
AND (nc.nspname = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
AND (nc.nspname = ANY ($1) OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
/*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
)
SELECT
@@ -571,39 +575,36 @@ columnFromRow tabs (s, t, n, desc, pos, nul, typ, u, l, p, d, e) = buildColumn <
parseEnum :: Maybe Text -> [Text]
parseEnum = maybe [] (split (==','))
allChildRelations :: [Table] -> [Column] -> H.Statement () [Relation]
allChildRelations tabs cols =
H.Statement sql HE.noParams (decodeRelations tabs cols) True
allM2ORels :: [Table] -> [Column] -> H.Statement () [Relation]
allM2ORels tabs cols =
H.Statement sql HE.noParams (decodeRels tabs cols) True
where
sql = [q|
SELECT ns1.nspname AS table_schema,
tab.relname AS table_name,
conname AS constraint_name,
column_info.cols AS columns,
ns2.nspname AS foreign_table_schema,
other.relname AS foreign_table_name,
column_info.refs AS foreign_columns
FROM pg_constraint,
LATERAL (SELECT array_agg(cols.attname) AS cols,
array_agg(cols.attnum) AS nums,
array_agg(refs.attname) AS refs
FROM ( SELECT unnest(conkey) AS col, unnest(confkey) AS ref) k,
LATERAL (SELECT * FROM pg_attribute
WHERE attrelid = conrelid AND attnum = col)
AS cols,
LATERAL (SELECT * FROM pg_attribute
WHERE attrelid = confrelid AND attnum = ref)
AS refs)
AS column_info,
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = connamespace) AS ns1,
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = conrelid) AS tab,
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = confrelid) AS other,
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = other.relnamespace) AS ns2
LATERAL (
SELECT array_agg(cols.attname) AS cols,
array_agg(cols.attnum) AS nums,
array_agg(refs.attname) AS refs
FROM ( SELECT unnest(conkey) AS col, unnest(confkey) AS ref) k,
LATERAL (SELECT * FROM pg_attribute WHERE attrelid = conrelid AND attnum = col) AS cols,
LATERAL (SELECT * FROM pg_attribute WHERE attrelid = confrelid AND attnum = ref) AS refs) AS column_info,
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = connamespace) AS ns1,
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = conrelid) AS tab,
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = confrelid) AS other,
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = other.relnamespace) AS ns2
WHERE confrelid != 0
ORDER BY (conrelid, column_info.nums) |]
relationFromRow :: [Table] -> [Column] -> (Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation
relationFromRow allTabs allCols (rs, rt, rcs, frs, frt, frcs) =
Relation <$> table <*> cols <*> tableF <*> colsF <*> pure Child <*> pure Nothing <*> pure Nothing <*> pure Nothing
relFromRow :: [Table] -> [Column] -> (Text, Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation
relFromRow allTabs allCols (rs, rt, cn, rcs, frs, frt, frcs) =
Relation <$> table <*> cols <*> pure (Just cn) <*> tableF <*> colsF <*> pure M2O <*> pure Nothing
where
findTable s t = find (\tbl -> tableSchema tbl == s && tableName tbl == t) allTabs
findCol s t c = find (\col -> tableSchema (colTable col) == s && tableName (colTable col) == t && colName col == c) allCols
@@ -722,9 +723,9 @@ pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
allSynonyms :: [Column] -> PgVersion -> H.Statement Schema [Synonym]
allSynonyms cols pgVer =
H.Statement sql (param HE.text) (decodeSynonyms cols) True
allSourceColumns :: [Column] -> PgVersion -> H.Statement [Schema] [SourceColumn]
allSourceColumns cols pgVer =
H.Statement sql (arrayParam HE.text) (decodeSourceColumns cols) True
-- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
where
subselectRegex :: Text
@@ -743,7 +744,7 @@ allSynonyms cols pgVer =
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
join pg_rewrite r on r.ev_class = c.oid
where (c.relkind in ('v', 'm')) and n.nspname = $1
where (c.relkind in ('v', 'm')) and n.nspname = ANY ($1)
),
removed_subselects as(
select
@@ -791,13 +792,6 @@ allSynonyms cols pgVer =
where resorigtbl <> '0'
order by view_schema, view_name, view_colum_name; |]
synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe Synonym
synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
where
col1 = findCol s1 t1 c1
col2 = findCol s2 t2 c2
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
getPgVersion :: H.Session PgVersion
getPgVersion = H.statement () $ H.Statement sql HE.noParams versionRow False
where
+37 -7
View File
@@ -16,12 +16,12 @@ module PostgREST.Error (
) where
import qualified Data.Aeson as JSON
import qualified Data.Text as T
import qualified Hasql.Pool as P
import qualified Hasql.Session as H
import qualified Network.HTTP.Types.Status as HT
import Data.Aeson ((.=))
import Data.Text (unwords)
import Network.Wai (Response, responseLBS)
import Text.Read (readMaybe)
@@ -48,8 +48,10 @@ data ApiRequestError
| InvalidRange
| InvalidBody ByteString
| ParseRequestError Text Text
| NoRelationBetween Text Text
| NoRelBetween Text Text
| AmbiguousRelBetween Text Text [Relation]
| InvalidFilters
| UnacceptableSchema [Text]
| UnknownRelation -- Unreachable?
| UnsupportedVerb -- Unreachable?
deriving (Show, Eq)
@@ -62,7 +64,9 @@ instance PgrstError ApiRequestError where
status UnknownRelation = HT.status404
status ActionInappropriate = HT.status405
status (ParseRequestError _ _) = HT.status400
status (NoRelationBetween _ _) = HT.status400
status (NoRelBetween _ _) = HT.status400
status AmbiguousRelBetween{} = HT.status300
status (UnacceptableSchema _) = HT.status406
headers _ = [toHeader CTApplicationJSON]
@@ -77,13 +81,39 @@ instance JSON.ToJSON ApiRequestError where
"message" .= ("HTTP Range error" :: Text)]
toJSON UnknownRelation = JSON.object [
"message" .= ("Unknown relation" :: Text)]
toJSON (NoRelationBetween parent child) = JSON.object [
"message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)]
toJSON (NoRelBetween parent child) = JSON.object [
"message" .= ("Could not find foreign keys between these entities. No relationship found between " <> parent <> " and " <> child :: Text)]
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [
"hint" .= ("By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)" :: Text),
"message" .= ("More than one relationship was found for " <> parent <> " and " <> child :: Text),
"details" .= (compressedRel <$> rels) ]
toJSON UnsupportedVerb = JSON.object [
"message" .= ("Unsupported HTTP verb" :: Text)]
toJSON InvalidFilters = JSON.object [
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
toJSON (UnacceptableSchema schemas) = JSON.object [
"message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas)]
compressedRel :: Relation -> JSON.Value
compressedRel rel =
let
fmtTbl tbl = tableSchema tbl <> "." <> tableName tbl
fmtEls els = "[" <> T.intercalate ", " els <> "]"
in
JSON.object $ [
"origin" .= fmtTbl (relTable rel)
, "target" .= fmtTbl (relFTable rel)
, "cardinality" .= (show $ relType rel :: Text)
] ++
case (relType rel, relJunction rel, relConstraint rel) of
(M2M, Just (Junction jt (Just const1) _ (Just const2) _), _) -> [
"relationship" .= (fmtTbl jt <> fmtEls [const1] <> fmtEls [const2])
]
(_, _, Just relCon) -> [
"relationship" .= (relCon <> fmtEls (colName <$> relColumns rel) <> fmtEls (colName <$> relFColumns rel))
]
(_, _, _) ->
mempty
data PgError = PgError Authenticated P.UsageError
type Authenticated = Bool
@@ -224,7 +254,7 @@ instance JSON.ToJSON SimpleError where
toJSON GucHeadersError = JSON.object [
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text)]
toJSON (BinaryFieldError ct) = JSON.object [
"message" .= ((toS (toMime ct) <> " requested but a single column was not selected") :: Text)]
"message" .= ((toS (toMime ct) <> " requested but more than one column was selected") :: Text)]
toJSON ConnectionLostError = JSON.object [
"message" .= ("Database connection lost, retrying the connection." :: Text)]
@@ -241,7 +271,7 @@ instance JSON.ToJSON SimpleError where
"message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)]
toJSON (SingularityError n) = JSON.object [
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),
"details" .= unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]]
"details" .= T.unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]]
toJSON JwtTokenMissing = JSON.object [
"message" .= ("Server lacks JWT secret" :: Text)]
+20 -10
View File
@@ -10,6 +10,8 @@ module PostgREST.Middleware where
import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M
import Data.Scientific (FPFormat (..), formatScientific,
isInteger)
import qualified Hasql.Transaction as H
import Network.Wai (Application, Response)
@@ -24,9 +26,8 @@ import PostgREST.Auth (JWTAttempt (..))
import PostgREST.Config (AppConfig (..), corsPolicy)
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
errorResponseFor)
import PostgREST.QueryBuilder (pgFmtSetLocal, pgFmtSetLocalSearchPath,
unquoted)
import Protolude
import PostgREST.QueryBuilder (setLocalQuery, setLocalSearchPathQuery)
import Protolude hiding (head)
runWithClaims :: AppConfig -> JWTAttempt ->
(ApiRequest -> H.Transaction Response) ->
@@ -37,17 +38,19 @@ runWithClaims conf eClaims app req =
JWTInvalid JWTExpired -> return . errorResponseFor . JwtTokenInvalid $ "JWT expired"
JWTInvalid e -> return . errorResponseFor . JwtTokenInvalid . show $ e
JWTClaims claims -> do
H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql
H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql
mapM_ H.sql customReqCheck
app req
where
headersSql = pgFmtSetLocal "request.header." <$> iHeaders req
cookiesSql = pgFmtSetLocal "request.cookie." <$> iCookies req
claimsSql = pgFmtSetLocal "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
appSettingsSql = pgFmtSetLocal mempty <$> configSettings conf
methodSql = setLocalQuery mempty ("request.method", toS $ iMethod req)
pathSql = setLocalQuery mempty ("request.path", toS $ iPath req)
headersSql = setLocalQuery "request.header." <$> iHeaders req
cookiesSql = setLocalQuery "request.cookie." <$> iCookies req
claimsSql = setLocalQuery "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
appSettingsSql = setLocalQuery mempty <$> configSettings conf
setRoleSql = maybeToList $ (\x ->
pgFmtSetLocal mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
setSearchPathSql = pgFmtSetLocalSearchPath $ configSchema conf : configExtraSearchPath conf
setLocalQuery mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
setSearchPathSql = setLocalSearchPathQuery (iSchema req : configExtraSearchPath conf)
-- role claim defaults to anon if not specified in jwt
claimsWithRole = M.union claims (M.singleton "role" anon)
anon = JSON.String . toS $ configAnonRole conf
@@ -58,3 +61,10 @@ defaultMiddle =
gzip def
. cors corsPolicy
. staticPolicy (only [("favicon.ico", "static/favicon.ico")])
unquoted :: JSON.Value -> Text
unquoted (JSON.String t) = t
unquoted (JSON.Number n) =
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
unquoted (JSON.Bool b) = show b
unquoted v = toS $ JSON.encode v
+22 -15
View File
@@ -10,7 +10,7 @@ module PostgREST.OpenAPI (
, pickProxy
) where
import qualified Data.Set as Set
import qualified Data.HashSet.InsOrd as Set
import Control.Arrow ((&&&))
import Data.Aeson (decode, encode)
@@ -55,7 +55,7 @@ makeTableDef pks (t, cs, _) =
let tn = tableName t in
(tn, (mempty :: Schema)
& description .~ tableDescription t
& type_ .~ SwaggerObject
& type_ ?~ SwaggerObject
& properties .~ fromList (map (makeProperty pks) cs)
& required .~ map colName (filter (not . colNullable) cs))
@@ -84,13 +84,13 @@ makeProperty pks c = (colName c, Inline s)
& enum_ .~ e
& format ?~ colType c
& maxLength .~ (fromIntegral <$> colMaxLen c)
& type_ .~ toSwaggerType (colType c)
& type_ ?~ toSwaggerType (colType c)
makeProcSchema :: ProcDescription -> Schema
makeProcSchema pd =
(mempty :: Schema)
& description .~ pdDescription pd
& type_ .~ SwaggerObject
& type_ ?~ SwaggerObject
& properties .~ fromList (map makeProcProperty (pdArgs pd))
& required .~ map pgaName (filter pgaReq (pdArgs pd))
@@ -98,7 +98,7 @@ makeProcProperty :: PgArg -> (Text, Referenced Schema)
makeProcProperty (PgArg n t _) = (n, Inline s)
where
s = (mempty :: Schema)
& type_ .~ toSwaggerType t
& type_ ?~ toSwaggerType t
& format ?~ t
makePreferParam :: [Text] -> Param
@@ -109,7 +109,7 @@ makePreferParam ts =
& required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamHeader
& type_ .~ SwaggerString
& type_ ?~ SwaggerString
& enum_ .~ decode (encode ts))
makeProcParam :: ProcDescription -> [Referenced Param]
@@ -132,28 +132,35 @@ makeParamDefs ti =
& required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery
& type_ .~ SwaggerString))
& type_ ?~ SwaggerString))
, ("on_conflict", (mempty :: Param)
& name .~ "on_conflict"
& description ?~ "On Conflict"
& required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery
& type_ ?~ SwaggerString))
, ("order", (mempty :: Param)
& name .~ "order"
& description ?~ "Ordering"
& required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery
& type_ .~ SwaggerString))
& type_ ?~ SwaggerString))
, ("range", (mempty :: Param)
& name .~ "Range"
& description ?~ "Limiting and Pagination"
& required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamHeader
& type_ .~ SwaggerString))
& type_ ?~ SwaggerString))
, ("rangeUnit", (mempty :: Param)
& name .~ "Range-Unit"
& description ?~ "Limiting and Pagination"
& required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamHeader
& type_ .~ SwaggerString
& type_ ?~ SwaggerString
& default_ .~ decode "\"items\""))
, ("offset", (mempty :: Param)
& name .~ "offset"
@@ -161,14 +168,14 @@ makeParamDefs ti =
& required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery
& type_ .~ SwaggerString))
& type_ ?~ SwaggerString))
, ("limit", (mempty :: Param)
& name .~ "limit"
& description ?~ "Limiting and Pagination"
& required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery
& type_ .~ SwaggerString))
& type_ ?~ SwaggerString))
]
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) cs
| (t, cs, _) <- ti
@@ -190,7 +197,7 @@ makeRowFilter tn c =
& required ?~ False
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamQuery
& type_ .~ SwaggerString
& type_ ?~ SwaggerString
& format ?~ colType c))
makeRowFilters :: Text -> [Column] -> [(Text, Param)]
@@ -213,12 +220,12 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
& at 200 ?~ Inline ((mempty :: Response)
& description .~ "OK"
& schema ?~ Inline (mempty
& type_ .~ SwaggerArray
& type_ ?~ SwaggerArray
& items ?~ (SwaggerItemsObject $ Ref $ Reference $ tableName t)
)
)
postOp = tOp
& parameters .~ map ref ["body." <> tn, "preferReturn"]
& parameters .~ map ref ["body." <> tn, "select", "preferReturn"]
& at 201 ?~ "Created"
patchOp = tOp
& parameters .~ map ref (rs <> ["body." <> tn, "preferReturn"])
+9 -5
View File
@@ -30,6 +30,10 @@ pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
pRequestSelect selStr =
mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
pRequestOnConflict :: Text -> Either ApiRequestError [FieldName]
pRequestOnConflict oncStr =
mapError $ parse pColumns ("failed to parse on_conflict parameter (" <> toS oncStr <> ")") (toS oncStr)
pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
where
@@ -130,12 +134,12 @@ pRelationSelect :: Parser SelectItem
pRelationSelect = lexeme $ try ( do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField
relationDetail <- optionMaybe (
try ( char '+' *> pFieldName ) <|>
try ( char '.' *> pFieldName ) -- TODO deprecated, remove in next major version
hint <- optionMaybe (
try ( char '!' *> pFieldName) <|>
-- deprecated, remove in next major version
try ( char '.' *> pFieldName)
)
return (fld, Nothing, alias, relationDetail)
return (fld, Nothing, alias, hint)
)
pFieldSelect :: Parser SelectItem
+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
+205
View File
@@ -0,0 +1,205 @@
{-# LANGUAGE LambdaCase #-}
{-|
Module : PostgREST.Private.QueryFragment
Description : Helper functions for PostgREST.QueryBuilder.
Any function that outputs a SqlFragment should be in this module.
-}
module PostgREST.Private.QueryFragment where
import qualified Data.HashMap.Strict as HM
import Data.Maybe
import Data.Text (intercalate,
isInfixOf, replace,
toLower, unwords)
import qualified Data.Text as T (map, null,
takeWhile)
import PostgREST.Types
import Protolude hiding (cast,
intercalate, replace)
import Text.InterpolatedString.Perl6 (qc)
noLocationF :: SqlFragment
noLocationF = "array[]::text[]"
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
-- otherwise the query will err with a `could not determine data type of parameter $1`.
-- This happens because `unknown` relies on the context to determine the value type.
-- The error also happens on raw libpq used with C.
ignoredBody :: SqlFragment
ignoredBody = "pgrst_ignored_body AS (SELECT $1::text) "
-- |
-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads
-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays
-- We do this in SQL to avoid processing the JSON in application code
normalizedBody :: SqlFragment
normalizedBody =
unwords [
"pgrst_payload AS (SELECT $1::json AS json_data),",
"pgrst_body AS (",
"SELECT",
"CASE WHEN json_typeof(json_data) = 'array'",
"THEN json_data",
"ELSE json_build_array(json_data)",
"END AS val",
"FROM pgrst_payload)"]
selectBody :: SqlFragment
selectBody = "(SELECT val FROM pgrst_body)"
pgFmtLit :: SqlFragment -> SqlFragment
pgFmtLit x =
let trimmed = trimNullChars x
escaped = "'" <> replace "'" "''" trimmed <> "'"
slashed = replace "\\" "\\\\" escaped in
if "\\" `isInfixOf` escaped
then "E" <> slashed
else slashed
pgFmtIdent :: SqlFragment -> SqlFragment
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
asCsvF :: SqlFragment
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
where
asCsvHeaderF =
"(SELECT coalesce(string_agg(a.k, ','), '')" <>
" FROM (" <>
" SELECT json_object_keys(r)::TEXT as k" <>
" FROM ( " <>
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
" ) s" <>
" ) a" <>
")"
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
asJsonF :: SqlFragment
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
asBinaryF :: FieldName -> SqlFragment
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
locationF :: [Text] -> SqlFragment
locationF pKeys = [qc|(
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
FROM data CROSS JOIN json_each_text(data.row) AS json_data
{("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}
)|]
fromQi :: QualifiedIdentifier -> SqlFragment
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
where
n = qiName t
s = qiSchema t
emptyOnFalse :: Text -> Bool -> Text
emptyOnFalse val cond = if cond then "" else val
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
pgFmtColumn table "*" = fromQi table <> ".*"
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment
pgFmtOrderTerm qi ot = unwords [
toS . pgFmtField qi $ otTerm ot,
maybe "" show $ otDirection ot,
maybe "" show $ otNullOrder ot]
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
Op op val -> pgFmtFieldOp op <> " " <> case op of
"like" -> unknownLiteral (T.map star val)
"ilike" -> unknownLiteral (T.map star val)
"is" -> whiteList val
_ -> unknownLiteral val
In vals -> pgFmtField table fld <> " " <>
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
case (&&) (length vals == 1) . T.null <$> headMay vals of
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
Just True -> emptyValForIn
Nothing -> emptyValForIn
Fts op lang val ->
pgFmtFieldOp op
<> "("
<> maybe "" ((<> ", ") . pgFmtLit) lang
<> unknownLiteral val
<> ") "
where
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
sqlOperator o = HM.lookupDefault "=" o operators
notOp = if hasNot then "NOT" else ""
star c = if c == '*' then '%' else c
unknownLiteral = (<> "::unknown ") . pgFmtLit
whiteList :: Text -> SqlFragment
whiteList v = fromMaybe
(toS (pgFmtLit v) <> "::unknown ")
(find ((==) . toLower $ v) ["null","true","false"])
pgFmtJoinCondition :: JoinCondition -> SqlFragment
pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) =
pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 col2
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"
where notOp = if hasNot then "NOT" else ""
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt
pgFmtJsonPath :: JsonPath -> SqlFragment
pgFmtJsonPath = \case
[] -> ""
(JArrow x:xs) -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
(J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
where
pgFmtJsonOperand (JKey k) = pgFmtLit k
pgFmtJsonOperand (JIdx i) = pgFmtLit i <> "::int"
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment
pgFmtAs _ [] Nothing = ""
pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
Just (JKey key) -> " AS " <> pgFmtIdent key
Just (JIdx _) -> " AS " <> pgFmtIdent (fromMaybe fName lastKey)
-- We get the lastKey because on:
-- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ]
-- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ]
where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp)
Nothing -> ""
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0')
countF :: SqlQuery -> Bool -> (SqlFragment, SqlFragment)
countF countQuery shouldCount =
if shouldCount
then (
", pg_source_count AS (" <> countQuery <> ")"
, "(SELECT pg_catalog.count(*) FROM pg_source_count)" )
else (
mempty
, "null::bigint")
returningF :: QualifiedIdentifier -> [FieldName] -> SqlFragment
returningF qi returnings =
if null returnings
then "RETURNING 1" -- For mutation cases where there's no ?select, we return 1 to know how many rows were modified
else "RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)
responseHeadersF :: PgVersion -> SqlFragment
responseHeadersF pgVer =
if pgVer >= pgVersion96
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
else "'[]'" :: Text
+110 -440
View File
@@ -1,255 +1,41 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-|
Module : PostgREST.QueryBuilder
Description : PostgREST SQL generating functions.
Description : PostgREST SQL queries generating functions.
This module provides functions to consume data types that
represent database objects (e.g. Relation, Schema, SqlQuery)
and produces SQL Statements.
Any function that outputs a SQL fragment should be in this module.
represent database queries (e.g. ReadRequest, MutateRequest) and SqlFragment
to produce SqlQuery type outputs.
-}
module PostgREST.QueryBuilder (
callProc
, createReadStatement
, createWriteStatement
, pgFmtIdent
, pgFmtLit
, requestToQuery
, requestToCountQuery
, unquoted
, ResultsWithCount
, pgFmtSetLocal
, pgFmtSetLocalSearchPath
readRequestToQuery
, mutateRequestToQuery
, readRequestToCountQuery
, requestToCallProcQuery
, limitedQuery
, setLocalQuery
, setLocalSearchPathQuery
) where
import qualified Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS
import qualified Data.HashMap.Strict as HM
import qualified Data.Set as S
import qualified Data.Text as T (map, null, takeWhile)
import qualified Data.Text.Encoding as T
import qualified Hasql.Decoders as HD
import qualified Hasql.Encoders as HE
import qualified Hasql.Statement as H
import qualified Data.Set as S
import Data.Scientific (FPFormat (..), formatScientific,
isInteger)
import Data.Text (intercalate, isInfixOf, replace,
toLower, unwords)
import Data.Tree (Tree (..))
import Text.InterpolatedString.Perl6 (qc)
import Data.Text (intercalate, unwords)
import Data.Tree (Tree (..))
import Data.Maybe
import PostgREST.ApiRequest (PreferRepresentation (..))
import PostgREST.RangeQuery (allRange, rangeLimit, rangeOffset)
import PostgREST.Private.QueryFragment
import PostgREST.RangeQuery (allRange, rangeLimit,
rangeOffset)
import PostgREST.Types
import Protolude hiding (cast, intercalate, replace)
import Protolude hiding (cast, intercalate,
replace)
column :: HD.Value a -> HD.Row a
column = HD.column . HD.nonNullable
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
nullableColumn = HD.column . HD.nullable
element :: HD.Value a -> HD.Array a
element = HD.element . HD.nonNullable
param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable
{-| The generic query result format used by API responses. The location header
is represented as a list of strings containing variable bindings like
@"k1=eq.42"@, or the empty list if there is no location header.
-}
type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString)
standardRow :: HD.Row ResultsWithCount
standardRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
<*> column header <*> column HD.bytea
where
header = HD.array $ HD.dimension replicateM $ element HD.bytea
noLocationF :: Text
noLocationF = "array[]::text[]"
{-| Read and Write api requests use a similar response format which includes
various record counts and possible location header. This is the decoder
for that common type of query.
-}
decodeStandard :: HD.Result ResultsWithCount
decodeStandard =
HD.singleRow standardRow
decodeStandardMay :: HD.Result (Maybe ResultsWithCount)
decodeStandardMay =
HD.rowMaybe standardRow
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName ->
H.Statement () ResultsWithCount
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField =
unicodeStatement sql HE.noParams decodeStandard False
where
sql = [qc|
WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols}
FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]
countResultF = if countTotal then "("<>countQuery<>")" else "null"
cols = intercalate ", " [
countResultF <> " AS total_result_set",
"pg_catalog.count(_postgrest_t) AS page_total",
noLocationF <> " AS header",
bodyF <> " AS body"
]
bodyF
| asCsv = asCsvF
| isSingle = asJsonSingleF
| isJust binaryField = asBinaryF $ fromJust binaryField
| otherwise = asJsonF
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
PreferRepresentation -> [Text] ->
H.Statement ByteString (Maybe ResultsWithCount)
createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys =
unicodeStatement sql (param HE.unknown) decodeStandardMay True
where
sql = case rep of
None -> [qc|
WITH {sourceCTEName} AS ({mutateQuery})
SELECT '', 0, {noLocationF}, '' |]
HeadersOnly -> [qc|
WITH {sourceCTEName} AS ({mutateQuery})
SELECT {cols}
FROM (SELECT 1 FROM {sourceCTEName}) _postgrest_t |]
Full -> [qc|
WITH {sourceCTEName} AS ({mutateQuery})
SELECT {cols}
FROM ({selectQuery}) _postgrest_t |]
cols = intercalate ", " [
"'' AS total_result_set", -- when updateing it does not make sense
"pg_catalog.count(_postgrest_t) AS page_total",
if isInsert
then unwords [
"CASE",
"WHEN pg_catalog.count(_postgrest_t) = 1 THEN",
"coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")",
"ELSE " <> noLocationF,
"END AS header"]
else noLocationF <> "AS header",
if rep == Full
then bodyF <> " AS body"
else "''"
]
bodyF
| asCsv = asCsvF
| wantSingle = asJsonSingleF
| otherwise = asJsonF
type ProcResults = (Maybe Int64, Int64, ByteString, ByteString)
callProc :: QualifiedIdentifier -> [PgArg] -> Bool -> SqlQuery -> SqlQuery -> Bool ->
Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->
H.Statement ByteString (Maybe ProcResults)
callProc qi pgArgs returnsScalar selectQuery countQuery countTotal isSingle paramsAsSingleObject asCsv asBinary binaryField pgVer =
unicodeStatement sql (param HE.unknown) decodeProc True
where
sql =[qc|
WITH
{argsRecord},
{sourceCTEName} AS (
{sourceBody}
)
SELECT
{countResultF} AS total_result_set,
pg_catalog.count(_postgrest_t) AS page_total,
{bodyF} AS body,
{responseHeaders} AS response_headers
FROM ({selectQuery}) _postgrest_t;|]
(argsRecord, args)
| paramsAsSingleObject = ("_args_record AS (SELECT NULL)", "$1::json")
| null pgArgs = (ignoredBody, "")
| otherwise = (
unwords [
normalizedBody <> ",",
"_args_record AS (",
"SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <>
intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " " <> pgaType a) <$> pgArgs) <> ")",
")"]
, intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " := _args_record." <> pgFmtIdent (pgaName a)) <$> pgArgs))
sourceBody :: SqlFragment
sourceBody
| paramsAsSingleObject || null pgArgs =
if returnsScalar
then [qc| SELECT {fromQi qi}({args}) |]
else [qc| SELECT * FROM {fromQi qi}({args}) |]
| otherwise =
if returnsScalar
then [qc| SELECT {fromQi qi}({args}) FROM _args_record |]
else [qc| SELECT _.*
FROM _args_record,
LATERAL ( SELECT * FROM {fromQi qi}({args}) ) _ |]
bodyF
| returnsScalar = scalarBodyF
| isSingle = asJsonSingleF
| asCsv = asCsvF
| isJust binaryField = asBinaryF $ fromJust binaryField
| otherwise = asJsonF
scalarBodyF
| asBinary = asBinaryF _procName
| otherwise = unwords [
"CASE",
"WHEN pg_catalog.count(_postgrest_t) = 1",
"THEN (json_agg(_postgrest_t." <> pgFmtIdent _procName <> ")->0)::character varying",
"ELSE (json_agg(_postgrest_t." <> pgFmtIdent _procName <> "))::character varying",
"END"]
countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text
_procName = qiName qi
responseHeaders =
if pgVer >= pgVersion96
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
else "'[]'" :: Text
decodeProc = HD.rowMaybe procRow
procRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
<*> column HD.bytea <*> column HD.bytea
pgFmtIdent :: SqlFragment -> SqlFragment
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
pgFmtLit :: SqlFragment -> SqlFragment
pgFmtLit x =
let trimmed = trimNullChars x
escaped = "'" <> replace "'" "''" trimmed <> "'"
slashed = replace "\\" "\\\\" escaped in
if "\\" `isInfixOf` escaped
then "E" <> slashed
else slashed
requestToCountQuery :: Schema -> DbRequest -> SqlQuery
requestToCountQuery _ (DbMutate _) = witness
requestToCountQuery schema (DbRead (Node (Select{where_=logicForest}, (mainTbl, _, _, _, _)) _)) =
unwords [
"SELECT pg_catalog.count(*)",
"FROM ", fromQi qi,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest
]
where
qi = removeSourceCTESchema schema mainTbl
requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
requestToQuery schema isParent (DbRead (Node (Select colSelects tbl tblAlias implJoins logicForest joinConditions_ ordts range, _) forest)) =
readRequestToQuery :: ReadRequest -> SqlQuery
readRequestToQuery (Node (Select colSelects mainQi tblAlias implJoins logicForest joinConditions_ ordts range, _) forest) =
unwords [
"SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
"FROM " <> intercalate ", " (tabl : implJs),
@@ -257,51 +43,42 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbl tblAlias imp
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_))
`emptyOnFalse` (null logicForest && null joinConditions_),
("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts,
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (isParent || range == allRange) ]
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (range == allRange)
]
where
implJs = fromQi . QualifiedIdentifier schema <$> implJoins
mainQi = removeSourceCTESchema schema tbl
implJs = fromQi <$> implJoins
tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias
qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias
(joins, selects) = foldr getJoinsSelects ([],[]) forest
(joins, selects) = foldr getQueryParts ([],[]) forest
getJoinsSelects :: ReadRequest -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
getJoinsSelects rr@(Node (_, (name, Just Relation{relType=relTyp,relTable=Table{tableName=table}}, alias, _, _)) _) (j,s) =
let subquery = readRequestToQuery rr in
case relTyp of
M2O ->
let aliasOrName = fromMaybe name alias
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName
joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE " in
(joi:j,sel:s)
_ ->
let sel = "COALESCE (("
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias) in
(j,sel:s)
getJoinsSelects (Node (_, (_, Nothing, _, _, _)) _) _ = ([], [])
getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
where
sel = "COALESCE(("
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
where subquery = requestToQuery schema False (DbRead (Node n forst))
getQueryParts (Node n@(_, (name, Just Relation{relType=Parent,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (joi:j,sel:s)
where
aliasOrName = fromMaybe name alias
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName
joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE "
where subquery = requestToQuery schema True (DbRead (Node n forst))
getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
where
sel = "COALESCE (("
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
where subquery = requestToQuery schema False (DbRead (Node n forst))
--the following is just to remove the warning
--getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only
--posible relations are Child Parent Many
getQueryParts _ _ = witness
requestToQuery schema _ (DbMutate (Insert mainTbl iCols onConflct putConditions returnings)) =
mutateRequestToQuery :: MutateRequest -> SqlQuery
mutateRequestToQuery (Insert mainQi iCols onConflct putConditions returnings) =
unwords [
"WITH " <> normalizedBody,
"INSERT INTO ", fromQi qi, if S.null iCols then " " else "(" <> cols <> ")",
"INSERT INTO ", fromQi mainQi, if S.null iCols then " " else "(" <> cols <> ")",
unwords [
"SELECT " <> cols <> " FROM",
"json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ") _",
"json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ") _",
-- Only used for PUT
("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier "" "_") <$> putConditions)) `emptyOnFalse` null putConditions],
("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) `emptyOnFalse` null putConditions],
maybe "" (\(oncDo, oncCols) -> (
"ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
IgnoreDuplicates ->
@@ -311,201 +88,94 @@ requestToQuery schema _ (DbMutate (Insert mainTbl iCols onConflct putConditions
then "DO NOTHING"
else "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols)
) `emptyOnFalse` null oncCols) onConflct,
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings]
returningF mainQi returnings
]
where
qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " $ pgFmtIdent <$> S.toList iCols
requestToQuery schema _ (DbMutate (Update mainTbl uCols logicForest returnings)) =
mutateRequestToQuery (Update mainQi uCols logicForest returnings) =
if S.null uCols
then "WITH " <> ignoredBody <> "SELECT null WHERE false" -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
else
unwords [
"WITH " <> normalizedBody,
"UPDATE " <> fromQi qi <> " SET " <> cols,
"FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ")) _ ",
("WHERE " <> intercalate " AND " (pgFmtLogicTree qi <$> logicForest)) `emptyOnFalse` null logicForest,
("RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)) `emptyOnFalse` null returnings
"UPDATE " <> fromQi mainQi <> " SET " <> cols,
"FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ")) _ ",
("WHERE " <> intercalate " AND " (pgFmtLogicTree mainQi <$> logicForest)) `emptyOnFalse` null logicForest,
returningF mainQi returnings
]
where
qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)
requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) =
mutateRequestToQuery (Delete mainQi logicForest returnings) =
unwords [
"WITH " <> ignoredBody,
"DELETE FROM ", fromQi qi,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest,
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
"DELETE FROM ", fromQi mainQi,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree mainQi) logicForest)) `emptyOnFalse` null logicForest,
returningF mainQi returnings
]
where
qi = QualifiedIdentifier schema mainTbl
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
-- otherwise the query will err with a `could not determine data type of parameter $1`.
-- This happens because `unknown` relies on the context to determine the value type.
-- The error also happens on raw libpq used with C.
ignoredBody :: SqlFragment
ignoredBody = "ignored_body AS (SELECT $1::text) "
-- |
-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads
-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays
-- We do this in SQL to avoid processing the JSON in application code
normalizedBody :: SqlFragment
normalizedBody =
requestToCallProcQuery :: QualifiedIdentifier -> [PgArg] -> Bool -> Maybe PreferParameters -> SqlQuery
requestToCallProcQuery qi pgArgs returnsScalar preferParams =
unwords [
"pgrst_payload AS (SELECT $1::json AS json_data),",
"pgrst_body AS (",
"SELECT",
"CASE WHEN json_typeof(json_data) = 'array'",
"THEN json_data",
"ELSE json_build_array(json_data)",
"END AS val",
"FROM pgrst_payload)"]
selectBody :: SqlFragment
selectBody = "(SELECT val FROM pgrst_body)"
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
unquoted :: JSON.Value -> Text
unquoted (JSON.String t) = t
unquoted (JSON.Number n) =
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
unquoted (JSON.Bool b) = show b
unquoted v = toS $ JSON.encode v
-- private functions
asCsvF :: SqlFragment
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
"WITH",
argsCTE,
sourceBody ]
where
asCsvHeaderF =
"(SELECT coalesce(string_agg(a.k, ','), '')" <>
" FROM (" <>
" SELECT json_object_keys(r)::TEXT as k" <>
" FROM ( " <>
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
" ) s" <>
" ) a" <>
")"
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
paramsAsSingleObject = preferParams == Just SingleObject
paramsAsMulitpleObjects = preferParams == Just MultipleObjects
asJsonF :: SqlFragment
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
(argsCTE, args)
| null pgArgs = (ignoredBody, "")
| paramsAsSingleObject = ("pgrst_args AS (SELECT NULL)", "$1::json")
| otherwise = (
unwords [
normalizedBody <> ",",
"pgrst_args AS (",
"SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <> fmtArgs (\a -> " " <> pgaType a) <> ")",
")"]
, if paramsAsMulitpleObjects
then fmtArgs (\a -> " := pgrst_args." <> pgFmtIdent (pgaName a))
else fmtArgs (\a -> " := (SELECT " <> pgFmtIdent (pgaName a) <> " FROM pgrst_args LIMIT 1)")
)
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
fmtArgs :: (PgArg -> SqlFragment) -> SqlFragment
fmtArgs argFrag = intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> argFrag a) <$> pgArgs)
asBinaryF :: FieldName -> SqlFragment
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
sourceBody :: SqlFragment
sourceBody
| paramsAsMulitpleObjects =
if returnsScalar
then "SELECT " <> callIt <> " AS pgrst_scalar FROM pgrst_args"
else unwords [ "SELECT pgrst_lat_args.*"
, "FROM pgrst_args,"
, "LATERAL ( SELECT * FROM " <> callIt <> " ) pgrst_lat_args" ]
| otherwise =
if returnsScalar
then "SELECT " <> callIt <> " AS pgrst_scalar"
else "SELECT * FROM " <> callIt
locationF :: [Text] -> SqlFragment
locationF pKeys = [qc|(
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
FROM data CROSS JOIN json_each_text(data.row) AS json_data
{("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}
)|]
callIt :: SqlFragment
callIt = fromQi qi <> "(" <> args <> ")"
fromQi :: QualifiedIdentifier -> SqlFragment
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
where
n = qiName t
s = qiSchema t
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
unicodeStatement = H.Statement . T.encodeUtf8
-- | SQL query meant for COUNTing the root node of the Tree.
-- It only takes WHERE into account and doesn't include LIMIT/OFFSET because it would reduce the COUNT.
-- SELECT 1 is done instead of SELECT * to prevent doing expensive operations(like functions based on the columns)
-- inside the FROM target.
readRequestToCountQuery :: ReadRequest -> SqlQuery
readRequestToCountQuery (Node (Select{from=qi, where_=logicForest}, _) _) =
unwords [
"SELECT 1",
"FROM " <> fromQi qi,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest
]
emptyOnFalse :: Text -> Bool -> Text
emptyOnFalse val cond = if cond then "" else val
limitedQuery :: SqlQuery -> Maybe Integer -> SqlQuery
limitedQuery query maxRows = query <> maybe mempty (\x -> " LIMIT " <> show x) maxRows
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
pgFmtColumn table "*" = fromQi table <> ".*"
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment
pgFmtOrderTerm qi ot = unwords [
toS . pgFmtField qi $ otTerm ot,
maybe "" show $ otDirection ot,
maybe "" show $ otNullOrder ot]
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
Op op val -> pgFmtFieldOp op <> " " <> case op of
"like" -> unknownLiteral (T.map star val)
"ilike" -> unknownLiteral (T.map star val)
"is" -> whiteList val
_ -> unknownLiteral val
In vals -> pgFmtField table fld <> " " <>
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
case (&&) (length vals == 1) . T.null <$> headMay vals of
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
Just True -> emptyValForIn
Nothing -> emptyValForIn
Fts op lang val ->
pgFmtFieldOp op
<> "("
<> maybe "" ((<> ", ") . pgFmtLit) lang
<> unknownLiteral val
<> ") "
where
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
sqlOperator o = HM.lookupDefault "=" o operators
notOp = if hasNot then "NOT" else ""
star c = if c == '*' then '%' else c
unknownLiteral = (<> "::unknown ") . pgFmtLit
whiteList :: Text -> SqlFragment
whiteList v = fromMaybe
(toS (pgFmtLit v) <> "::unknown ")
(find ((==) . toLower $ v) ["null","true","false"])
pgFmtJoinCondition :: JoinCondition -> SqlFragment
pgFmtJoinCondition (JoinCondition (qi, col1) (QualifiedIdentifier schema fTable, col2)) =
pgFmtColumn qi col1 <> " = " <>
pgFmtColumn (removeSourceCTESchema schema fTable) col2
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"
where notOp = if hasNot then "NOT" else ""
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt
pgFmtJsonPath :: JsonPath -> SqlFragment
pgFmtJsonPath = \case
[] -> ""
(JArrow x:xs) -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
(J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
where
pgFmtJsonOperand (JKey k) = pgFmtLit k
pgFmtJsonOperand (JIdx i) = pgFmtLit i <> "::int"
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment
pgFmtAs _ [] Nothing = ""
pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
Just (JKey key) -> " AS " <> pgFmtIdent key
Just (JIdx _) -> " AS " <> pgFmtIdent (fromMaybe fName lastKey)
-- We get the lastKey because on:
-- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ]
-- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ]
where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp)
Nothing -> ""
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
pgFmtSetLocal :: Text -> (Text, Text) -> SqlFragment
pgFmtSetLocal prefix (k, v) =
setLocalQuery :: Text -> (Text, Text) -> SqlQuery
setLocalQuery prefix (k, v) =
"SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
pgFmtSetLocalSearchPath :: [Text] -> SqlFragment
pgFmtSetLocalSearchPath vals =
setLocalSearchPathQuery :: [Text] -> SqlQuery
setLocalSearchPathQuery vals =
"SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0')
+31 -1
View File
@@ -1,6 +1,6 @@
{-|
Module : PostgREST.RangeQuery
Description : Logic regarding the `Range` header and `limit`, `offset` querystring arguments.
Description : Logic regarding the `Range`/`Content-Range` headers and `limit`/`offset` querystring arguments.
-}
module PostgREST.RangeQuery (
rangeParse
@@ -11,6 +11,8 @@ module PostgREST.RangeQuery (
, rangeGeq
, allRange
, NonnegRange
, rangeStatusHeader
, contentRangeH
) where
import qualified Data.ByteString.Char8 as BS
@@ -22,6 +24,7 @@ import Control.Applicative
import Data.Ranged.Boundaries
import Data.Ranged.Ranges
import Network.HTTP.Types.Header
import Network.HTTP.Types.Status
import Protolude
@@ -70,3 +73,30 @@ allRange = rangeGeq 0
rangeLeq :: Integer -> NonnegRange
rangeLeq n =
Range BoundaryBelowAll (BoundaryAbove n)
rangeStatusHeader :: NonnegRange -> Int64 -> Maybe Int64 -> (Status, Header)
rangeStatusHeader topLevelRange queryTotal tableTotal =
let lower = rangeOffset topLevelRange
upper = lower + toInteger queryTotal - 1
contentRange = contentRangeH lower upper (toInteger <$> tableTotal)
status = rangeStatus lower upper (toInteger <$> tableTotal)
in (status, contentRange)
where
rangeStatus :: Integer -> Integer -> Maybe Integer -> Status
rangeStatus _ _ Nothing = status200
rangeStatus lower upper (Just total)
| lower > total = status416 -- 416 Range Not Satisfiable
| (1 + upper - lower) < total = status206 -- 206 Partial Content
| otherwise = status200 -- 200 OK
contentRangeH :: (Integral a, Show a) => a -> a -> Maybe a -> Header
contentRangeH lower upper total =
("Content-Range", headerValue)
where
headerValue = rangeString <> "/" <> totalString
rangeString
| totalNotZero && fromInRange = show lower <> "-" <> show upper
| otherwise = "*"
totalString = maybe "*" show total
totalNotZero = maybe True (0 /=) total
fromInRange = lower <= upper
+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
+145 -55
View File
@@ -2,10 +2,14 @@
Module : PostgREST.Types
Description : PostgREST common types and functions used by the rest of the modules
-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.Types where
import Control.Lens.Getter (view)
import Control.Lens.Tuple (_1)
import qualified Data.Aeson as JSON
import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BS (c2w)
@@ -24,7 +28,7 @@ import Protolude
-- | Enumeration of currently supported response content types
data ContentType = CTApplicationJSON | CTSingularJSON
| CTTextCSV | CTTextPlain | CTTextHtml
| CTTextCSV | CTTextPlain
| CTOpenAPI | CTOctetStream
| CTAny | CTOther ByteString deriving (Show, Eq)
@@ -37,7 +41,6 @@ toMime :: ContentType -> ByteString
toMime CTApplicationJSON = "application/json"
toMime CTTextCSV = "text/csv"
toMime CTTextPlain = "text/plain"
toMime CTTextHtml = "text/html"
toMime CTOpenAPI = "application/openapi+json"
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
toMime CTOctetStream = "application/octet-stream"
@@ -50,7 +53,6 @@ decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of
"application/json" -> CTApplicationJSON
"text/csv" -> CTTextCSV
"text/plain" -> CTTextPlain
"text/html" -> CTTextHtml
"application/openapi+json" -> CTOpenAPI
"application/vnd.pgrst.object+json" -> CTSingularJSON
"application/vnd.pgrst.object" -> CTSingularJSON
@@ -58,22 +60,53 @@ decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of
"*/*" -> CTAny
ct' -> CTOther ct'
-- | ContentTypes that can get a raw/unwrapped response
rawContentTypes :: [ContentType]
rawContentTypes = [CTOctetStream, CTTextPlain, CTTextHtml]
-- | A SQL query that can be executed independently
type SqlQuery = Text
-- | A part of a SQL query that cannot be executed independently
type SqlFragment = Text
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
instance Show PreferResolution where
show MergeDuplicates = "resolution=merge-duplicates"
show IgnoreDuplicates = "resolution=ignore-duplicates"
-- | How to return the mutated data. From https://tools.ietf.org/html/rfc7240#section-4.2
data PreferRepresentation = Full -- ^ Return the body plus the Location header(in case of POST).
| HeadersOnly -- ^ Return the Location header(in case of POST). This needs a SELECT privilege on the pk.
| None -- ^ Return nothing from the mutated data.
deriving Eq
instance Show PreferRepresentation where
show Full = "return=representation"
show None = "return=minimal"
show HeadersOnly = mempty
data PreferParameters
= SingleObject -- ^ Pass all parameters as a single json object to a stored procedure
| MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure
deriving Eq
instance Show PreferParameters where
show SingleObject = "params=single-object"
show MultipleObjects = "params=multiple-objects"
data PreferCount
= ExactCount -- ^ exact count(slower)
| PlannedCount -- ^ PostgreSQL query planner rows count guess. Done by using EXPLAIN {query}.
| EstimatedCount -- ^ use the query planner rows if the count is superior to max-rows, otherwise get the exact count.
deriving Eq
instance Show PreferCount where
show ExactCount = "count=exact"
show PlannedCount = "count=planned"
show EstimatedCount = "count=estimated"
data DbStructure = DbStructure {
dbTables :: [Table]
, dbColumns :: [Column]
, dbRelations :: [Relation]
, dbPrimaryKeys :: [PrimaryKey]
-- ProcDescription is a list because a function can be overloaded
, dbProcs :: M.HashMap Text [ProcDescription]
, dbProcs :: ProcsMap
, pgVersion :: PgVersion
} deriving (Show, Eq)
@@ -99,7 +132,8 @@ data ProcVolatility = Volatile | Stable | Immutable
deriving (Eq, Show, Ord)
data ProcDescription = ProcDescription {
pdName :: Text
pdSchema :: Schema
, pdName :: Text
, pdDescription :: Maybe Text
, pdArgs :: [PgArg]
, pdReturnType :: RetType
@@ -108,18 +142,23 @@ data ProcDescription = ProcDescription {
-- Order by least number of args in the case of overloaded functions
instance Ord ProcDescription where
ProcDescription name1 des1 args1 rt1 vol1 `compare` ProcDescription name2 des2 args2 rt2 vol2
| name1 == name2 && length args1 < length args2 = LT
| name1 == name2 && length args1 > length args2 = GT
| otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2)
ProcDescription schema1 name1 des1 args1 rt1 vol1 `compare` ProcDescription schema2 name2 des2 args2 rt2 vol2
| schema1 == schema2 && name1 == name2 && length args1 < length args2 = LT
| schema2 == schema2 && name1 == name2 && length args1 > length args2 = GT
| otherwise = (schema1, name1, des1, args1, rt1, vol1) `compare` (schema2, name2, des2, args2, rt2, vol2)
-- | A map of all procs, all of which can be overloaded(one entry will have more than one ProcDescription).
-- | It uses a HashMap for a faster lookup.
type ProcsMap = M.HashMap QualifiedIdentifier [ProcDescription]
{-|
Search a pg procedure by its parameters. Since a function can be overloaded, the name is not enough to find it.
An overloaded function can have a different volatility or even a different return type.
Ideally, handling overloaded functions should be left to pg itself. But we need to know certain proc attributes in advance.
-}
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> Maybe ProcDescription
findProc qi payloadKeys paramsAsSingleObject allProcs =
case M.lookup (qiName qi) allProcs of
case M.lookup qi allProcs of
Nothing -> Nothing
Just [proc] -> Just proc -- if it's not an overloaded function then immediately get the ProcDescription
Just procs -> find matches procs -- Handle overloaded functions case
@@ -141,10 +180,19 @@ specifiedProcArgs keys proc =
in
(\k -> fromMaybe (PgArg k "text" True) (find ((==) k . pgaName) args)) <$> S.toList keys
procReturnsScalar :: ProcDescription -> Bool
procReturnsScalar proc = case proc of
ProcDescription{pdReturnType = (Single (Scalar _))} -> True
_ -> False
procTableName :: ProcDescription -> Maybe TableName
procTableName proc = case pdReturnType proc of
SetOf (Composite qi) -> Just $ qiName qi
Single (Composite qi) -> Just $ qiName qi
_ -> Nothing
type Schema = Text
type TableName = Text
type SqlQuery = Text
type SqlFragment = Text
data Table = Table {
tableSchema :: Schema
@@ -156,6 +204,9 @@ data Table = Table {
instance Eq Table where
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
tableQi :: Table -> QualifiedIdentifier
tableQi Table{tableSchema=s, tableName=n} = QualifiedIdentifier s n
newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
data Column =
@@ -177,8 +228,8 @@ data Column =
instance Eq Column where
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
-- | A view column that refers to a table column
type Synonym = (Column, ViewColumn)
-- | The source table column a view column refers to
type SourceColumn = (Column, ViewColumn)
type ViewColumn = Column
data PrimaryKey = PrimaryKey {
@@ -209,33 +260,48 @@ data OrderTerm = OrderTerm {
data QualifiedIdentifier = QualifiedIdentifier {
qiSchema :: Schema
, qiName :: TableName
} deriving (Show, Eq, Ord)
} deriving (Show, Eq, Ord, Generic)
instance Hashable QualifiedIdentifier
-- | The relationship [cardinality](https://en.wikipedia.org/wiki/Cardinality_(data_modeling)).
-- | TODO: missing one-to-one
data Cardinality = O2M -- ^ one-to-many, previously known as Parent
| M2O -- ^ many-to-one, previously known as Child
| M2M -- ^ many-to-many, previously known as Many
deriving Eq
instance Show Cardinality where
show O2M = "o2m"
show M2O = "m2o"
show M2M = "m2m"
data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
type ConstraintName = Text
{-|
The name 'Relation' here is used with the meaning
"What is the relation between the current node and the parent node".
It has nothing to do with PostgreSQL referring to tables/views as relations.
The order of the relColumns and relFColumns should be maintained to get
the join conditions right.
"Relation"ship between two tables.
The order of the relColumns and relFColumns should be maintained to get the join conditions right.
TODO merge relColumns and relFColumns to a tuple or Data.Bimap
-}
data Relation = Relation {
relTable :: Table
, relColumns :: [Column]
, relFTable :: Table
, relFColumns :: [Column]
, relType :: RelationType
-- The Link attrs are used when RelationType == Many
, relLinkTable :: Maybe Table
, relLinkCols1 :: Maybe [Column]
, relLinkCols2 :: Maybe [Column]
relTable :: Table
, relColumns :: [Column]
, relConstraint :: Maybe ConstraintName -- ^ Just on O2M/M2O, Nothing on M2M
, relFTable :: Table
, relFColumns :: [Column]
, relType :: Cardinality
, relJunction :: Maybe Junction -- ^ Junction for M2M Cardinality
} deriving (Show, Eq)
isSelfJoin :: Relation -> Bool
isSelfJoin r = relType r /= Root && relTable r == relFTable r
-- | Junction table on an M2M relationship
data Junction = Junction {
junTable :: Table
, junConstraint1 :: Maybe ConstraintName
, junCols1 :: [Column]
, junConstraint2 :: Maybe ConstraintName
, junCols2 :: [Column]
} deriving (Show, Eq)
isSelfReference :: Relation -> Bool
isSelfReference r = relTable r == relFTable r
data PayloadJSON =
-- | Cached attributes of a JSON payload
@@ -287,7 +353,8 @@ ftsOperators :: M.HashMap Operator SqlFragment
ftsOperators = M.fromList [
("fts", "@@ to_tsquery"),
("plfts", "@@ plainto_tsquery"),
("phfts", "@@ phraseto_tsquery")
("phfts", "@@ phraseto_tsquery"),
("wfts", "@@ websearch_to_tsquery")
]
data OpExpr = OpExpr Bool Operation deriving (Eq, Show)
@@ -338,24 +405,33 @@ type RpcQParam = (Text, Text)
Custom guc header, it's obtained by parsing the json in a:
`SET LOCAL "response.headers" = '[{"Set-Cookie": ".."}]'
-}
newtype GucHeader = GucHeader (Text, Text)
newtype GucHeader = GucHeader (CI.CI ByteString, ByteString)
deriving (Show, Eq)
instance JSON.FromJSON GucHeader where
parseJSON (JSON.Object o) = case headMay (M.toList o) of
Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (k, s)
Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (CI.mk $ toS k, toS s)
| otherwise -> mzero
_ -> mzero
parseJSON _ = mzero
toHeaders :: [GucHeader] -> [Header]
toHeaders = map $ \(GucHeader (k, v)) -> (CI.mk $ toS k, toS v)
unwrapGucHeader :: GucHeader -> Header
unwrapGucHeader (GucHeader (k, v)) = (k, v)
-- | Add headers not already included to allow the user to override them instead of duplicating them
addHeadersIfNotIncluded :: [Header] -> [Header] -> [Header]
addHeadersIfNotIncluded newHeaders initialHeaders =
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
initialHeaders
{-|
This type will hold information about which particular 'Relation' between two tables to choose when there are multiple ones.
Specifically, it will contain the name of the foreign key or the join table in many to many relations.
-}
type RelationDetail = Text
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe RelationDetail)
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe EmbedHint)
-- | Disambiguates an embedding operation when there's multiple relationships between two tables.
-- | Can be the name of a foreign key constraint, column name or the junction in an m2m relationship.
type EmbedHint = Text
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]
type EmbedPath = [Text]
data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)
@@ -364,11 +440,11 @@ data JoinCondition = JoinCondition (QualifiedIdentifier, FieldName)
data ReadQuery = Select {
select :: [SelectItem]
, from :: TableName
, from :: QualifiedIdentifier
-- | A table alias is used in case of self joins
, fromAlias :: Maybe Alias
-- | Only used for Many to Many joins. Parent and Child joins use explicit joins.
, implicitJoins :: [TableName]
, implicitJoins :: [QualifiedIdentifier]
, where_ :: [LogicTree]
, joinConditions :: [JoinCondition]
, order :: [OrderTerm]
@@ -377,31 +453,35 @@ data ReadQuery = Select {
data MutateQuery =
Insert {
in_ :: TableName
in_ :: QualifiedIdentifier
, insCols :: S.Set FieldName
, onConflict :: Maybe (PreferResolution, [FieldName])
, where_ :: [LogicTree]
, returning :: [FieldName]
}|
Update {
in_ :: TableName
in_ :: QualifiedIdentifier
, updCols :: S.Set FieldName
, where_ :: [LogicTree]
, returning :: [FieldName]
}|
Delete {
in_ :: TableName
in_ :: QualifiedIdentifier
, where_ :: [LogicTree]
, returning :: [FieldName]
} deriving (Show, Eq)
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
type ReadRequest = Tree ReadNode
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail, Depth))
-- Depth of the ReadRequest tree
type Depth = Integer
type MutateRequest = MutateQuery
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe EmbedHint, Depth))
type Depth = Integer
-- First level FieldNames(e.g get a,b from /table?select=a,b,other(c,d))
fstFieldNames :: ReadRequest -> [FieldName]
fstFieldNames (Node (sel, _) _) =
fst . view _1 <$> select sel
data PgVersion = PgVersion {
pgvNum :: Int32
, pgvName :: Text
@@ -426,9 +506,21 @@ pgVersion96 = PgVersion 90600 "9.6"
pgVersion100 :: PgVersion
pgVersion100 = PgVersion 100000 "10"
pgVersion109 :: PgVersion
pgVersion109 = PgVersion 100009 "10.9"
pgVersion110 :: PgVersion
pgVersion110 = PgVersion 110000 "11.0"
pgVersion112 :: PgVersion
pgVersion112 = PgVersion 110002 "11.2"
pgVersion114 :: PgVersion
pgVersion114 = PgVersion 110004 "11.4"
pgVersion121 :: PgVersion
pgVersion121 = PgVersion 120001 "12.1"
sourceCTEName :: SqlFragment
sourceCTEName = "pg_source"
@@ -437,8 +529,6 @@ type JSPath = [JSPathExp]
-- | jspath expression, e.g. .property, .property[0] or ."property-dash"
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
-- | Current database connection status data ConnectionStatus
data ConnectionStatus
= NotConnected
+17 -15
View File
@@ -1,18 +1,20 @@
# stack.yaml is used for circle-ci tests. Profiling build fails on circleci
# with GHC 8.6, so we build with 8.4 for now.
resolver: lts-12.26
extra-deps:
- Ranged-sets-0.4.0
- configurator-pg-0.1.0.3
- http-types-0.12.3
- hasql-1.4
- hasql-pool-0.5.1
- hasql-transaction-0.7.2
- text-builder-0.6.5.1
- deferred-folds-0.9.10.1
- primitive-0.6.4.0
# stack is used for circleci and appveyor CI builds
resolver: lts-14.3
ghc-options:
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints
# -fno-spec-constr may help keep compile time memory use in check,
# see https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304
# -optP-Wno-nonportable-include-path
# prevents build failures on case-insensitive filesystems (macos),
# see https://github.com/commercialhaskell/stack/issues/3918
postgrest: -O2 -Werror -Wall -fwarn-identities
-fno-spec-constr -optP-Wno-nonportable-include-path
nix:
packages: [pcre, pkgconfig, postgresql, zlib]
# needed by stylish haskell, this only runs on ci
extra-deps:
- HsYAML-0.2.1.0@sha256:e4677daeba57f7a1e9a709a1f3022fe937336c91513e893166bd1f023f530d68,5311
- HsYAML-aeson-0.2.0.0@sha256:04796abfc01cffded83f37a10e6edba4f0c0a15d45bef44fc5bb4313d9c87757,1791
- configurator-pg-0.2.0@sha256:08dcfadbe31e9e505d0bed1ab034105e1735141499734ad52d1c5ac980cde4a6,2939
- hspec-wai-0.10.1@sha256:56dd9ec1d56f47ef1946f71f7cbf070e4c285f718cac1b158400ae5e7172ef47,2290
- hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
+47
View File
@@ -0,0 +1,47 @@
# This file was autogenerated by Stack.
# You should not edit this file by hand.
# For more information, please see the documentation at:
# https://docs.haskellstack.org/en/stable/lock_files
packages:
- completed:
hackage: HsYAML-0.2.1.0@sha256:e4677daeba57f7a1e9a709a1f3022fe937336c91513e893166bd1f023f530d68,5311
pantry-tree:
size: 1340
sha256: 21f61bf9cad31674126b106071dd9b852e408796aeffc90eec1792f784107eff
original:
hackage: HsYAML-0.2.1.0@sha256:e4677daeba57f7a1e9a709a1f3022fe937336c91513e893166bd1f023f530d68,5311
- completed:
hackage: HsYAML-aeson-0.2.0.0@sha256:04796abfc01cffded83f37a10e6edba4f0c0a15d45bef44fc5bb4313d9c87757,1791
pantry-tree:
size: 234
sha256: 67cc9ba17c79e71d3abdb465a3ee2825477856fff3b8b7d543cbbbefdae9a9d9
original:
hackage: HsYAML-aeson-0.2.0.0@sha256:04796abfc01cffded83f37a10e6edba4f0c0a15d45bef44fc5bb4313d9c87757,1791
- completed:
hackage: configurator-pg-0.2.0@sha256:08dcfadbe31e9e505d0bed1ab034105e1735141499734ad52d1c5ac980cde4a6,2939
pantry-tree:
size: 1748
sha256: 760eb12ee3d81b95b68ee10d5d85171b117826f44242b2749d48791efad6c891
original:
hackage: configurator-pg-0.2.0@sha256:08dcfadbe31e9e505d0bed1ab034105e1735141499734ad52d1c5ac980cde4a6,2939
- completed:
hackage: hspec-wai-0.10.1@sha256:56dd9ec1d56f47ef1946f71f7cbf070e4c285f718cac1b158400ae5e7172ef47,2290
pantry-tree:
size: 809
sha256: 17af1c2e709cd84bfda066b9ebb04cdde7f92660c51a1f7401a1e9f766524e93
original:
hackage: hspec-wai-0.10.1@sha256:56dd9ec1d56f47ef1946f71f7cbf070e4c285f718cac1b158400ae5e7172ef47,2290
- completed:
hackage: hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
pantry-tree:
size: 349
sha256: fb9e89b79cde3276baa484c860c6b9eeebdbc1a5c43301293351a25bc4c08e87
original:
hackage: hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
snapshots:
- completed:
size: 523878
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/14/3.yaml
sha256: 470c46c27746a48c7c50f829efc0cf00112787a7804ee4ac7a27754658f6d92c
original: lts-14.3
+1 -1
View File
@@ -4,7 +4,7 @@ ENV PATH /root/.local/bin:$PATH
RUN apt-get update \
&& apt-get install -y wget libpq-dev pkg-config libpcre3 libpcre3-dev \
postgresql-client debconf locales \
postgresql-client debconf locales build-essential libffi-dev libgmp-dev git \
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \
&& locale-gen \
+45 -4
View File
@@ -7,12 +7,12 @@ import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec =
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion =
describe "and/or params used for complex boolean logic" $ do
context "used with GET" $ do
context "or param" $ do
@@ -80,6 +80,19 @@ spec =
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
]|] { matchHeaders = [matchContentTypeJson] }
when (actualPgVersion >= pgVersion112) $
it "can handle wfts (websearch_to_tsquery)" $
get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"
`shouldRespondWith`
[json|[
{"text_search_vector": "'also':2 'fun':3 'possibl':8" },
{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
{"text_search_vector": "'art':4 'spass':5 'unmog':7" }
]|]
{ matchHeaders = [matchContentTypeJson] }
it "can handle cs and cd" $
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
@@ -114,6 +127,34 @@ spec =
get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle array operators" $ do
get "/entities?arr=eq.{1,2,3}&select=id" `shouldRespondWith`
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=neq.{1,2}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lt.{2,3}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lt.{2,0}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=gt.{1,1}&select=id" `shouldRespondWith`
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=gt.{3}&select=id" `shouldRespondWith`
[json|[]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lte.{2,1}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lte.{1,2,3}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=lte.{1,2}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=cs.{1,2}&select=id" `shouldRespondWith`
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=cd.{1,2,6}&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=ov.{3}&select=id" `shouldRespondWith`
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
get "/entities?arr=ov.{2,3}&select=id" `shouldRespondWith`
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
context "operators with not" $ do
it "eq, cs, like can be negated" $
get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
+1 -1
View File
@@ -11,7 +11,7 @@ import Protolude
import SpecHelper
-- }}}
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec = describe "server started with asymmetric JWK" $
-- this test will stop working 9999999999s after the UNIX EPOCH
+1 -1
View File
@@ -11,7 +11,7 @@ import Protolude hiding (get)
import SpecHelper
-- }}}
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec = describe "test handling of aud claims in JWT" $ do
-- this test will stop working 9999999999s after the UNIX EPOCH
+3 -3
View File
@@ -12,7 +12,7 @@ import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper
spec :: PgVersion -> SpecWith Application
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = describe "authorization" $ do
let single = ("Accept","application/vnd.pgrst.object+json")
@@ -117,11 +117,11 @@ spec actualPgVersion = describe "authorization" $ do
it "hides tables from users with invalid JWT" $ do
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
request methodGet "/authors_only" [auth] ""
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError \"expected 3 parts, got 2\")"} |]
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"} |]
{ matchStatus = 401
, matchHeaders = [
"WWW-Authenticate" <:>
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError \\\"expected 3 parts, got 2\\\")\""
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""
]
}
+1 -1
View File
@@ -11,7 +11,7 @@ import Protolude
import SpecHelper
-- }}}
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec = describe "server started with binary JWT secret" $
-- this test will stop working 9999999999s after the UNIX EPOCH
+6 -6
View File
@@ -19,9 +19,9 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get)
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "Queryiny in parallel" $
describe "Querying in parallel" $
it "should not raise 'transaction in progress' error" $
raceTest 10 $
get "/fakefake"
@@ -35,13 +35,13 @@ spec =
, matchHeaders = []
}
raceTest :: Int -> WaiExpectation -> WaiExpectation
raceTest :: Int -> WaiExpectation st -> WaiExpectation st
raceTest times = liftBaseDiscard go
where
go test = void $ mapConcurrently (const test) [1..times]
instance MonadBaseControl IO WaiSession where
type StM WaiSession a = StM Session a
instance MonadBaseControl IO (WaiSession st) where
type StM (WaiSession st) a = StM Session a
liftBaseWith f = WaiSession $
liftBaseWith $ \runInBase ->
f $ \k -> runInBase (unWaiSession k)
@@ -49,5 +49,5 @@ instance MonadBaseControl IO WaiSession where
{-# INLINE liftBaseWith #-}
{-# INLINE restoreM #-}
instance MonadBase IO WaiSession where
instance MonadBase IO (WaiSession st) where
liftBase = liftIO
+1 -1
View File
@@ -14,7 +14,7 @@ import Protolude
import SpecHelper
-- }}}
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "CORS" $ do
let preflightHeaders = [
+24 -2
View File
@@ -5,11 +5,12 @@ import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc
import Protolude hiding (get)
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "Deleting" $ do
context "existing record" $ do
@@ -36,7 +37,7 @@ spec =
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
it "can embed (parent) entities" $
request methodDelete "/tasks?id=eq.8&select=id,name,project(id)" [("Prefer", "return=representation")] ""
request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] ""
`shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
@@ -62,3 +63,24 @@ spec =
context "totally unknown route" $
it "fails with 404" $
request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
context "table with limited privileges" $ do
it "fails deleting the row when return=representation and selecting all the columns" $
request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty
`shouldRespondWith` 401
it "succeeds deleting the row when return=representation and selecting only the privileged columns" $
request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]
[json| { "password": "passxyz" } |]
`shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
it "suceeds deleting the row with no explicit select when using return=minimal" $
request methodDelete "/app_users?id=eq.2" [("Prefer", "return=minimal")] mempty
`shouldRespondWith` 204
it "suceeds deleting the row with no explicit select by default" $
request methodDelete "/app_users?id=eq.3" [] mempty
`shouldRespondWith` 204
+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 SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec = describe "extra search path" $ do
it "finds the ltree <@ operator on the public schema" $
+30
View File
@@ -0,0 +1,30 @@
module Feature.HtmlRawOutputSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Text.Heredoc
import Protolude hiding (get)
import SpecHelper (acceptHdrs)
spec :: SpecWith ((), Application)
spec = describe "When raw-media-types is set to \"text/html\"" $
it "can get raw output with Accept: text/html" $
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
`shouldRespondWith`
[str|
|<html>
| <head>
| <title>PostgREST</title>
| </head>
| <body>
| <h1>Welcome to PostgREST</h1>
| </body>
|</html>
|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/html; charset=utf-8"]
}
+128 -41
View File
@@ -20,7 +20,7 @@ import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper
spec :: PgVersion -> SpecWith Application
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = do
describe "Posting new record" $ do
context "disparate json types" $ do
@@ -148,15 +148,6 @@ spec actualPgVersion = do
simpleBody p `shouldBe` [json| [] |]
simpleStatus p `shouldBe` created201
it "can insert in tables with no select privileges" $ do
p <- request methodPost "/insertonly"
[("Prefer", "return=minimal")]
[json| { "v":"some value" } |]
liftIO $ do
simpleBody p `shouldBe` ""
simpleStatus p `shouldBe` created201
it "can post nulls" $ do
p <- request methodPost "/no_pk"
[("Prefer", "return=representation")]
@@ -260,36 +251,6 @@ spec actualPgVersion = do
, matchHeaders = []
}
context "table with limited privileges" $ do
it "succeeds if correct select is applied" $
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [str|[{"article_id":2,"user_id":1}]|]
{ matchStatus = 201
, matchHeaders = []
}
it "fails if more columns are selected" $
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
else
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
)
{ matchStatus = 401
, matchHeaders = []
}
it "fails if select is not specified" $
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
else
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
)
{ matchStatus = 401
, matchHeaders = []
}
context "POST with ?columns parameter" $ do
it "ignores json keys not included in ?columns" $ do
request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")]
@@ -537,7 +498,7 @@ spec actualPgVersion = do
matchHeaders = ["Content-Range" <:> "*/*"]
}
it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $
it "makes no updates and returns 200, when patching with an empty json object and return=rep" $
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
`shouldRespondWith` "[]"
{
@@ -588,3 +549,129 @@ spec actualPgVersion = do
liftIO $ do
simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|]
simpleStatus p2 `shouldBe` created201
context "tables with self reference foreign keys" $ do
it "embeds parent after insert" $
request methodPost "/web_content?select=id,name,parent_content:p_web_id(name)"
[("Prefer", "return=representation")]
[json|{"id":6, "name":"wot", "p_web_id":4}|]
`shouldRespondWith`
[json|[{"id":6,"name":"wot","parent_content":{"name":"wut"}}]|]
{ matchStatus = 201
, matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]
}
it "embeds childs after update" $
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"
[("Prefer", "return=representation")]
[json|{"name": "tardis-patched"}|]
`shouldRespondWith`
[json|
[ { "id": 0, "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|]
{ matchStatus = 200,
matchHeaders = [matchContentTypeJson]
}
it "embeds parent, childs and grandchilds after update" $
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name,web_content(name)),parent_content:p_web_id(name)"
[("Prefer", "return=representation")]
[json|{"name": "tardis-patched-2"}|]
`shouldRespondWith`
[json| [
{
"id": 0,
"name": "tardis-patched-2",
"parent_content": { "name": "wat" },
"web_content": [
{ "name": "fezz", "web_content": [ { "name": "wut" } ] },
{ "name": "foo", "web_content": [] },
{ "name": "bar", "web_content": [] }
]
}
] |]
{ matchStatus = 200,
matchHeaders = [matchContentTypeJson]
}
it "embeds childs after update without explicitly including the id in the ?select" $
request methodPatch "/web_content?id=eq.0&select=name,web_content(name)"
[("Prefer", "return=representation")]
[json|{"name": "tardis-patched"}|]
`shouldRespondWith`
[json|
[ { "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|]
{ matchStatus = 200,
matchHeaders = [matchContentTypeJson]
}
it "embeds an M2M relationship plus parent after update" $
request methodPatch "/users?id=eq.1&select=name,tasks(name,project:projects(name))"
[("Prefer", "return=representation")]
[json|{"name": "Kevin Malone"}|]
`shouldRespondWith`
[json|[
{
"name": "Kevin Malone",
"tasks": [
{ "name": "Design w7", "project": { "name": "Windows 7" } },
{ "name": "Code w7", "project": { "name": "Windows 7" } },
{ "name": "Design w10", "project": { "name": "Windows 10" } },
{ "name": "Code w10", "project": { "name": "Windows 10" } }
]
}
]|]
{ matchStatus = 200,
matchHeaders = [matchContentTypeJson]
}
context "table with limited privileges" $ do
it "succeeds inserting if correct select is applied" $
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [str|[{"article_id":2,"user_id":1}]|]
{ matchStatus = 201
, matchHeaders = []
}
it "fails inserting if more columns are selected" $
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
else
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
)
{ matchStatus = 401
, matchHeaders = []
}
it "fails inserting if select is not specified" $
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
else
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
)
{ matchStatus = 401
, matchHeaders = []
}
it "can insert in a table with no select and return=minimal" $ do
p <- request methodPost "/insertonly"
[("Prefer", "return=minimal")]
[json| { "v":"some value" } |]
liftIO $ do
simpleBody p `shouldBe` ""
simpleStatus p `shouldBe` created201
it "succeeds updating row and gives a 204 when using return=minimal" $
request methodPatch "/app_users?id=eq.1" [("Prefer", "return=minimal")]
[json| { "password": "passxyz" } |]
`shouldRespondWith` 204
it "can update without return=minimal and no explicit select" $
request methodPatch "/app_users?id=eq.1" []
[json| { "password": "passabc" } |]
`shouldRespondWith` 204
+8 -3
View File
@@ -7,11 +7,11 @@ import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import PostgREST.Types (PgVersion, pgVersion112)
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
import Protolude hiding (get)
import SpecHelper
spec :: PgVersion -> SpecWith Application
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = describe "json and jsonb operators" $ do
context "Shaping response with select parameter" $ do
it "obtains a json subfield one level with casting" $
@@ -26,7 +26,12 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
it "fails on bad casting (data of the wrong format)" $
get "/complex_items?select=settings->foo->>bar::integer"
`shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
`shouldRespondWith` (
if actualPgVersion >= pgVersion121 then
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]
else
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
)
{ matchStatus = 400 , matchHeaders = [] }
it "obtains a json subfield two levels (string)" $
+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
-- }}}
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec = describe "server started without JWT secret" $ do
-- 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)
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "Non existent api schema" $ do
it "succeeds when requesting root path" $
+1 -1
View File
@@ -9,7 +9,7 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec = describe "features supported on PostgreSQL 9.5" $
context "json array negative index" $ do
it "can select with negative indexes" $ do
+97 -3
View File
@@ -1,6 +1,8 @@
module Feature.PgVersion96Spec where
import Network.Wai (Application)
import Network.HTTP.Types
import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleHeaders))
import Test.Hspec
import Test.Hspec.Wai
@@ -9,10 +11,10 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "features supported on PostgreSQL 9.6" $ do
context "GUC headers" $ do
context "GUC headers on function calls" $ do
it "succeeds setting the headers" $ do
get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"
`shouldRespondWith` [json|[{"id": 2}]|]
@@ -67,6 +69,98 @@ spec =
"Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/",
"Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"]}
context "GUC headers on all other methods via pre-request" $ do
it "succeeds setting the headers on GET and HEAD" $ do
request methodGet "/items?id=eq.1" [("User-Agent", "MSIE 6.0")] mempty
`shouldRespondWith` [json|[{"id": 1}]|]
{matchHeaders = [
matchContentTypeJson,
"Cache-Control" <:> "no-cache, no-store, must-revalidate"]}
request methodHead "/items?id=eq.1" [("User-Agent", "MSIE 7.0")] mempty
`shouldRespondWith` ""
{matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"]}
request methodHead "/projects" [("Accept", "text/csv")] mempty
`shouldRespondWith` ""
{matchHeaders = ["Content-Disposition" <:> "attachment; filename=projects.csv"]}
it "succeeds setting the headers on POST" $
request methodPost "/items" [] [json|[{"id": 11111}]|]
`shouldRespondWith` ""
{ matchStatus = 201
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
}
it "succeeds setting the headers on PATCH" $
request methodPatch "/items?id=eq.11111" [] [json|[{"id": 11111}]|]
`shouldRespondWith` ""
{ matchStatus = 204
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
}
it "succeeds setting the headers on PUT" $
request methodPut "/items?id=eq.11111" [] [json|[{"id": 11111}]|]
`shouldRespondWith` ""
{ matchStatus = 204
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
}
it "succeeds setting the headers on DELETE" $
request methodDelete "/items?id=eq.11111" [] mempty
`shouldRespondWith` ""
{ matchStatus = 204
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
}
context "Override provided headers by using GUC headers" $ do
it "can override the Content-Type header" $ do
request methodHead "/clients?id=eq.1" [] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/geo+json"]
}
request methodHead "/rpc/getallprojects" [] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/geo+json"]
}
it "can override the Location header" $
request methodPost "/stuff" [] [json|[{"id": 1, "name": "stuff 1"}]|]
`shouldRespondWith` ""
{ matchStatus = 201
, matchHeaders = ["Location" <:> "/stuff?id=eq.1&overriden=true"]
}
-- On https://github.com/PostgREST/postgrest/issues/1427#issuecomment-595907535
-- it was reported that blank headers ` : ` where added and that cause proxies to fail the requests.
-- These tests are to ensure no blank headers are added.
context "Blank headers bug" $ do
it "shouldn't add blank headers on POST" $ do
r <- request methodPost "/loc_test" [] [json|{"id": "1", "c": "c1"}|]
liftIO $ do
let respHeaders = simpleHeaders r
respHeaders `shouldSatisfy` noBlankHeader
it "shouldn't add blank headers on PATCH" $ do
r <- request methodPatch "/loc_test?id=eq.1" [] [json|{"c": "c2"}|]
liftIO $ do
let respHeaders = simpleHeaders r
respHeaders `shouldSatisfy` noBlankHeader
it "shouldn't add blank headers on GET" $ do
r <- request methodGet "/loc_test" [] ""
liftIO $ do
let respHeaders = simpleHeaders r
respHeaders `shouldSatisfy` noBlankHeader
it "shouldn't add blank headers on DELETE" $ do
r <- request methodDelete "/loc_test?id=eq.1" [] ""
liftIO $ do
let respHeaders = simpleHeaders r
respHeaders `shouldSatisfy` noBlankHeader
context "Use of the phraseto_tsquery function" $ do
it "finds matches" $
get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`
+1 -1
View File
@@ -6,7 +6,7 @@ import Test.Hspec hiding (pendingWith)
import Protolude
import SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "GET / with proxy" $
it "returns a valid openapi spec with proxy" $
+34 -5
View File
@@ -11,9 +11,9 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "Requesting many items with server limits enabled" $ do
describe "Requesting many items with server limits(max-rows) enabled" $ do
it "restricts results" $
get "/items"
`shouldRespondWith` [json| [{"id":1},{"id":2}] |]
@@ -29,16 +29,45 @@ spec =
matchHeader "Content-Range" "0-0/*"
simpleStatus r `shouldBe` ok200
it "limit works on all levels" $
it "works on all levels" $
get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
`shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"]
}
it "limit is not applied to parent embeds" $
get "/tasks?select=id,project(id)&id=gt.5"
it "succeeds in getting parent embeds despite the limit, see #647" $
get "/tasks?select=id,project:projects(id)&id=gt.5"
`shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"]
}
it "can offset the parent embed, being consistent with the other embed types" $
get "/tasks?select=id,project:projects(id)&id=gt.5&project.offset=1"
`shouldRespondWith` [json|[{"id":6,"project":null}, {"id":7,"project":null}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"]
}
context "count=estimated" $ do
it "uses the query planner guess when query rows > maxRows" $
request methodHead "/getallprojects_view" [("Prefer", "count=estimated")] ""
`shouldRespondWith` ""
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-1/2019"]
}
it "gives exact count when query rows <= maxRows" $
request methodHead "/getallprojects_view?id=lt.3" [("Prefer", "count=estimated")] ""
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/2"]
}
it "only uses the query planner guess if it's indeed greater than the exact count" $
request methodHead "/get_projects_above_view" [("Prefer", "count=estimated")] ""
`shouldRespondWith` ""
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-1/3"]
}
+72 -266
View File
@@ -4,17 +4,18 @@ import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleHeaders))
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc
import Protolude hiding (get)
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec = do
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = do
describe "Querying a table with a column called count" $
it "should not confuse count column with pg_catalog.count aggregate" $
@@ -119,6 +120,29 @@ spec = do
[json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
{ matchHeaders = [matchContentTypeJson] }
when (actualPgVersion >= pgVersion112) $ do
it "finds matches with websearch_to_tsquery" $
get "/tsearch?text_search_vector=wfts.The%20Fat%20Rats" `shouldRespondWith`
[json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
{ matchHeaders = [matchContentTypeJson] }
it "can use boolean operators(and, or, -) in websearch_to_tsquery" $ do
get "/tsearch?text_search_vector=wfts.fun%20and%20possible"
`shouldRespondWith`
[json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/tsearch?text_search_vector=wfts.impossible%20or%20possible"
`shouldRespondWith`
[json| [
{"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
{"text_search_vector": "'also':2 'fun':3 'possibl':8"}]
|]
{ matchHeaders = [matchContentTypeJson] }
get "/tsearch?text_search_vector=wfts.fun%20and%20-possible"
`shouldRespondWith`
[json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "finds matches with different dictionaries" $ do
get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
@@ -127,6 +151,12 @@ spec = do
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
{ matchHeaders = [matchContentTypeJson] }
when (actualPgVersion >= pgVersion112) $
get "/tsearch?text_search_vector=wfts(french).amusant%20impossible"
`shouldRespondWith`
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
{ matchHeaders = [matchContentTypeJson] }
it "can be negated with not operator" $ do
get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`
[json| [
@@ -145,6 +175,13 @@ spec = do
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
{ matchHeaders = [matchContentTypeJson] }
when (actualPgVersion >= pgVersion112) $
get "/tsearch?text_search_vector=not.wfts(english).impossible%20or%20fat%20or%20fun"
`shouldRespondWith`
[json| [
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
{ matchHeaders = [matchContentTypeJson] }
it "matches with computed column" $
get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
@@ -161,7 +198,7 @@ spec = do
it "matches filtering nested items 2" $
get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*"
`shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities, No relation found between projects and tasks2"}|]
`shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities. No relationship found between projects and tasks2"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
@@ -228,19 +265,8 @@ spec = do
[json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "requesting parent without specifying primary key" $
get "/projects?select=name,client(name)" `shouldRespondWith`
[json|[
{"name":"Windows 7","client":{"name": "Microsoft"}},
{"name":"Windows 10","client":{"name": "Microsoft"}},
{"name":"IOS","client":{"name": "Apple"}},
{"name":"OSX","client":{"name": "Apple"}},
{"name":"Orphan","client":null}
]|]
{ matchHeaders = [matchContentTypeJson] }
it "requesting parent and renaming primary key" $
get "/projects?select=name,client(clientId:id,name)" `shouldRespondWith`
get "/projects?select=name,client:clients(clientId:id,name)" `shouldRespondWith`
[json|[
{"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},
{"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},
@@ -258,24 +284,11 @@ spec = do
[json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "embed data with two fk pointing to the same table" $
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`
[str|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
it "requesting parents and children while renaming them" $
get "/projects?id=eq.1&select=myId:id, name, project_client:client_id(*), project_tasks:tasks(id, name)" `shouldRespondWith`
get "/projects?id=eq.1&select=myId:id, name, project_client:clients(*), project_tasks:tasks(id, name)" `shouldRespondWith`
[json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "requesting parents two levels up while using FK to specify the link" $
get "/tasks?id=eq.1&select=id,name,project:project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
it "requesting parents two levels up while using FK to specify the link (with rename)" $
get "/tasks?id=eq.1&select=id,name,project:project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
it "requesting parents and filtering parent columns" $
get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
@@ -314,21 +327,6 @@ spec = do
[json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed by FK column name" $
get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed by FK column name and select the FK value at the same time, if aliased" $
get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can select by column name sans id" $
get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
{ matchHeaders = [matchContentTypeJson] }
describe "view embedding" $ do
it "can detect fk relations through views to tables in the public schema" $
get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200
@@ -336,8 +334,8 @@ spec = do
it "can detect fk relations through materialized views to tables in the public schema" $
get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200
it "can request parent without specifying primary key" $
get "/articleStars?select=createdAt,article(owner),user(name)&limit=1" `shouldRespondWith`
it "can request two parents" $
get "/articleStars?select=createdAt,article:articles(owner),user:users(name)&limit=1" `shouldRespondWith`
[json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]
{ matchHeaders = [matchContentTypeJson] }
@@ -357,16 +355,16 @@ spec = do
{ matchHeaders = [matchContentTypeJson] }
it "detects parent relations when having many views of a private table" $ do
get "/books?select=title,author(name)&id=eq.5" `shouldRespondWith`
get "/books?select=title,author:authors(name)&id=eq.5" `shouldRespondWith`
[json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]
{ matchHeaders = [matchContentTypeJson] }
get "/forties_books?select=title,author(name)&limit=1" `shouldRespondWith`
get "/forties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
[json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]
{ matchHeaders = [matchContentTypeJson] }
get "/fifties_books?select=title,author(name)&limit=1" `shouldRespondWith`
get "/fifties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
[json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|]
{ matchHeaders = [matchContentTypeJson] }
get "/sixties_books?select=title,author(name)&limit=1" `shouldRespondWith`
get "/sixties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
[json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|]
{ matchHeaders = [matchContentTypeJson] }
@@ -429,7 +427,7 @@ spec = do
{ matchHeaders = [matchContentTypeJson] }
it "can embed a view that has group by" $
get "/projects_count_grouped_by?select=number_of_projects,client(name)&order=number_of_projects" `shouldRespondWith`
get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`
[json|
[{"number_of_projects":1,"client":null},
{"number_of_projects":2,"client":{"name":"Microsoft"}},
@@ -441,48 +439,6 @@ spec = do
[json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
describe "path fixed" $ do
it "works when requesting children 2 levels" $
get "/clients?id=eq.1&select=id,projects:projects%2Bclient_id(id,tasks(id))" `shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with parent relation" $
get "/message?select=id,body,sender:person%2Bsender(name),recipient:person%2Brecipient(name)&id=lt.4" `shouldRespondWith`
[json|
[{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
{"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "fails with an unknown relation" $
get "/message?select=id,sender:person.space(name)&id=lt.4" `shouldRespondWith`
[json|{"message":"Could not find foreign keys between these entities, No relation found between message and person"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson] }
it "works with a parent view relation" $
get "/message?select=id,body,sender:person_detail%2Bsender(name,sent),recipient:person_detail%2Brecipient(name,received)&id=lt.4" `shouldRespondWith`
[json|
[{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
{"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
{"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "works with many<->many relation" $
get "/tasks?select=id,users:users%2Busers_tasks(id)" `shouldRespondWith`
[json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
{ matchHeaders = [matchContentTypeJson] }
describe "old dot '.' symbol, deprecated" $
it "still works" $ do
get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/tasks?select=id,users:users.users_tasks(id)" `shouldRespondWith`
[json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
{ matchHeaders = [matchContentTypeJson] }
describe "aliased embeds" $ do
it "works with child relation" $
get "/space?select=id,zones:zone(id,name),stores:zone(id,name)&zones.zone_type_id=eq.2&stores.zone_type_id=eq.3" `shouldRespondWith`
@@ -540,114 +496,6 @@ spec = do
{ "id":4,"childs":[]}
]|] { matchHeaders = [matchContentTypeJson] }
describe "tables with self reference foreign keys" $ do
context "one self reference foreign key" $ do
it "embeds parents recursively" $
get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`
[json|[
{ "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },
{ "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }
]|]
{ matchHeaders = [matchContentTypeJson] }
it "embeds childs recursively" $
get "/family_tree?id=eq.1&select=id,name, childs:family_tree%2Bparent(id,name,childs:family_tree%2Bparent(id,name))" `shouldRespondWith`
[json|[{
"id": "1", "name": "Parental Unit", "childs": [
{ "id": "2", "name": "Kid One", "childs": [ { "id": "4", "name": "Grandkid One" } ] },
{ "id": "3", "name": "Kid Two", "childs": [ { "id": "5", "name": "Grandkid Two" } ] }
]
}]|] { matchHeaders = [matchContentTypeJson] }
it "embeds parent and then embeds childs" $
get "/family_tree?id=eq.2&select=id,name,parent(id,name,childs:family_tree%2Bparent(id,name))" `shouldRespondWith`
[json|[{
"id": "2", "name": "Kid One", "parent": {
"id": "1", "name": "Parental Unit", "childs": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
}
}]|] { matchHeaders = [matchContentTypeJson] }
context "two self reference foreign keys" $ do
it "embeds parents" $
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
[json|[{
"id": 3, "name": "Acme",
"referee": {
"id": 1,
"name": "Referee Org"
},
"auditor": {
"id": 2,
"name": "Auditor Org"
}
}]|] { matchHeaders = [matchContentTypeJson] }
it "embeds childs" $ do
get "/organizations?select=id,name,refereeds:organizations%2Breferee(id,name)&id=eq.1" `shouldRespondWith`
[json|[{
"id": 1, "name": "Referee Org",
"refereeds": [
{
"id": 3,
"name": "Acme"
},
{
"id": 4,
"name": "Umbrella"
}
]
}]|] { matchHeaders = [matchContentTypeJson] }
get "/organizations?select=id,name,auditees:organizations%2Bauditor(id,name)&id=eq.2" `shouldRespondWith`
[json|[{
"id": 2, "name": "Auditor Org",
"auditees": [
{
"id": 3,
"name": "Acme"
},
{
"id": 4,
"name": "Umbrella"
}
]
}]|] { matchHeaders = [matchContentTypeJson] }
it "embeds other relations(manager) besides the self reference" $ do
get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`
[json|[{
"name":"Cyberdyne",
"manager":{"name":"Cyberdyne Manager"},
"referee":{
"name":"Acme",
"manager":{"name":"Acme Manager"},
"auditor":{
"name":"Auditor Org",
"manager":{"name":"Auditor Manager"}}},
"auditor":{
"name":"Umbrella",
"manager":{"name":"Umbrella Manager"},
"referee":{
"name":"Referee Org",
"manager":{"name":"Referee Manager"}}}
}]|] { matchHeaders = [matchContentTypeJson] }
get "/organizations?select=name,manager(name),auditees:organizations%2Bauditor(name,manager(name),refereeds:organizations%2Breferee(name,manager(name)))&id=eq.2" `shouldRespondWith`
[json|[{
"name":"Auditor Org",
"manager":{"name":"Auditor Manager"},
"auditees":[
{"name":"Acme",
"manager":{"name":"Acme Manager"},
"refereeds":[
{"name":"Cyberdyne",
"manager":{"name":"Cyberdyne Manager"}},
{"name":"Oscorp",
"manager":{"name":"Oscorp Manager"}}]},
{"name":"Umbrella",
"manager":{"name":"Umbrella Manager"},
"refereeds":[]}]
}]|] { matchHeaders = [matchContentTypeJson] }
describe "ordering response" $ do
it "by a column asc" $
get "/items?id=lte.2&order=id.asc"
@@ -743,10 +591,6 @@ spec = do
get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]
it "ordering embeded parents does not break things when using ducktape names" $
get "/projects?id=eq.1&select=id, name, client(id, name)&client.order=name.asc" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}]|]
context "order syntax errors" $ do
it "gives meaningful error messages when asc/desc/nulls{first,last} are misspelled" $ do
get "/items?order=id.ac" `shouldRespondWith`
@@ -876,11 +720,6 @@ spec = do
[json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "will embed using a column" $
get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "will select and filter a column that has spaces" $
get "/Server%20Today?select=Just%20A%20Server%20Model&Just%20A%20Server%20Model=like.*91*" `shouldRespondWith`
[json|[
@@ -913,7 +752,7 @@ spec = do
it "fails if a single column is not selected" $ do
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but a single column was not selected"} |]
[json| {"message":"application/octet-stream requested but more than one column was selected"} |]
{ matchStatus = 406
, matchHeaders = [matchContentTypeJson]
}
@@ -929,58 +768,6 @@ spec = do
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
}
describe "HTTP request env vars" $ do
it "custom header is set" $
request methodPost "/rpc/get_guc_value"
[("Custom-Header", "test")]
[json| { "name": "request.header.custom-header" } |]
`shouldRespondWith`
[str|"test"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "standard header is set" $
request methodPost "/rpc/get_guc_value"
[("Origin", "http://example.com")]
[json| { "name": "request.header.origin" } |]
`shouldRespondWith`
[str|"http://example.com"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "current role is available as GUC claim" $
request methodPost "/rpc/get_guc_value" []
[json| { "name": "request.jwt.claim.role" } |]
`shouldRespondWith`
[str|"postgrest_test_anonymous"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "single cookie ends up as claims" $
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
[json| {"name":"request.cookie.acookie"} |]
`shouldRespondWith`
[str|"cookievalue"|]
{ matchStatus = 200
, matchHeaders = []
}
it "multiple cookies ends up as claims" $
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
[json| {"name":"request.cookie.secondcookie"} |]
`shouldRespondWith`
[str|"anothervalue"|]
{ matchStatus = 200
, matchHeaders = []
}
it "app settings available" $
request methodPost "/rpc/get_guc_value" []
[json| { "name": "app.settings.app_host" } |]
`shouldRespondWith`
[str|"localhost"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
describe "values with quotes in IN and NOT IN" $ do
it "succeeds when only quoted values are present" $ do
get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`
@@ -1046,8 +833,12 @@ spec = do
it "only returns an empty result set if the in value is empty" $
get "/items_with_different_col_types?int_data=in.( ,3,4)"
`shouldRespondWith`
`shouldRespondWith` (
if actualPgVersion >= pgVersion121 then
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"\""} |]
else
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
)
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
@@ -1074,3 +865,18 @@ spec = do
it "cannot use ltree(in public schema) extension operators if no extra search path added" $
get "/ltree_sample?path=cd.Top.Science.Astronomy" `shouldRespondWith` 400
context "VIEW that has a source FK based on a UNIQUE key" $
it "can be embedded" $
get "/referrals?select=site,link:pages(url)" `shouldRespondWith`
[json| [
{"site":"github.com", "link":{"url":"http://postgrest.org/en/v6.0/api.html"}},
{"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}
]|]
{ matchHeaders = [matchContentTypeJson] }
it "shouldn't produce a Content-Profile header since only a single schema is exposed" $ do
r <- get "/items"
liftIO $ do
let respHeaders = simpleHeaders r
respHeaders `shouldSatisfy` noProfileHeader
+66 -2
View File
@@ -19,7 +19,7 @@ defaultRange = [json| { "min": 0, "max": 15 } |]
emptyRange :: BL.ByteString
emptyRange = [json| { "min": 2, "max": 2 } |]
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec = do
describe "POST /rpc/getitemrange" $ do
context "without range headers" $ do
@@ -155,12 +155,17 @@ spec = do
, matchHeaders = ["Content-Range" <:> "0-0/*"]
}
it "limit and offset works on first level" $
it "limit and offset works on first level" $ do
get "/items?select=id&order=id.asc&limit=3&offset=2"
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "2-4/*"]
}
request methodHead "/items?select=id&order=id.asc&limit=3&offset=2" [] mempty
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "2-4/*"]
}
it "succeeds if offset equals 0 as a no-op" $
get "/items?select=id&offset=0"
@@ -192,6 +197,65 @@ spec = do
, matchHeaders = [matchContentTypeJson]
}
context "when count=planned" $ do
it "obtains a filtered range" $ do
request methodGet "/items?select=id&id=gt.8" [("Prefer", "count=planned")] ""
`shouldRespondWith` [json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|]
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-6/8"]
}
request methodGet "/child_entities?select=id&id=gt.3" [("Prefer", "count=planned")] ""
`shouldRespondWith` [json|[{"id":4}, {"id":5}, {"id":6}]|]
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-2/4"]
}
request methodGet "/getallprojects_view?select=id&id=lt.3" [("Prefer", "count=planned")] ""
`shouldRespondWith` [json|[{"id":1}, {"id":2}]|]
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-1/673"]
}
it "obtains the full range" $ do
request methodHead "/items" [("Prefer", "count=planned")] ""
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-14/15"]
}
request methodHead "/child_entities" [("Prefer", "count=planned")] ""
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-5/6"]
}
request methodHead "/getallprojects_view" [("Prefer", "count=planned")] ""
`shouldRespondWith` ""
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-4/2019"]
}
it "ignores limit/offset on the planned count" $ do
request methodHead "/items?limit=2&offset=3" [("Prefer", "count=planned")] ""
`shouldRespondWith` ""
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "3-4/15"]
}
request methodHead "/child_entities?limit=2" [("Prefer", "count=planned")] ""
`shouldRespondWith` ""
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-1/6"]
}
request methodHead "/getallprojects_view?limit=2" [("Prefer", "count=planned")] ""
`shouldRespondWith` ""
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-1/2019"]
}
it "works with two levels" $
request methodHead "/child_entities?select=*,entities(*)" [("Prefer", "count=planned")] ""
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-5/6"]
}
context "with range headers" $ do
context "of acceptable range" $ do
it "succeeds with partial content" $ do
+33
View File
@@ -0,0 +1,33 @@
module Feature.RawOutputTypesSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude
import SpecHelper (acceptHdrs)
spec :: SpecWith ((), Application)
spec = describe "When raw-media-types config variable is missing or left empty" $ do
let firefoxAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
it "responds json to a GET request with Firefox Accept headers" $
request methodGet "/items?id=eq.1" firefoxAcceptHdrs ""
`shouldRespondWith` [json| [{"id":1}] |]
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
it "responds json to a GET request with Chrome Accept headers" $
request methodGet "/items?id=eq.1" chromeAcceptHdrs ""
`shouldRespondWith` [json| [{"id":1}] |]
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
it "responds json to a GET request to RPC with Firefox Accept headers" $
request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
it "responds json to a GET request to RPC with Chrome Accept headers" $
request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
+1 -1
View File
@@ -11,7 +11,7 @@ import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "root spec function" $ do
it "accepts application/openapi+json" $
+152 -52
View File
@@ -11,12 +11,13 @@ import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc
import PostgREST.Types (PgVersion, pgVersion100, pgVersion95,
pgVersion96)
import PostgREST.Types (PgVersion, pgVersion100, pgVersion109,
pgVersion110, pgVersion112, pgVersion114,
pgVersion95)
import Protolude hiding (get)
import SpecHelper
spec :: PgVersion -> SpecWith Application
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion =
describe "remote procedure call" $ do
context "a proc that returns a set" $ do
@@ -33,6 +34,12 @@ spec actualPgVersion =
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/*"]
}
request methodHead "/rpc/getitemrange?min=2&max=4"
(rangeHdrs (ByteRangeFromTo 0 0)) ""
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/*"]
}
it "includes total count if requested" $ do
request methodPost "/rpc/getitemrange"
@@ -48,6 +55,12 @@ spec actualPgVersion =
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-0/2"]
}
request methodHead "/rpc/getitemrange?min=2&max=4"
(rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""
`shouldRespondWith` ""
{ matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-0/2"]
}
it "returns proper json" $ do
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
@@ -71,6 +84,12 @@ spec actualPgVersion =
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
}
request methodHead "/rpc/getitemrange?min=2&max=4"
(acceptHdrs "text/csv") ""
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
}
context "unknown function" $ do
it "returns 404" $
@@ -139,10 +158,10 @@ spec actualPgVersion =
context "foreign entities embedding" $ do
it "can embed if related tables are in the exposed schema" $ do
post "/rpc/getproject?select=id,name,client(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
post "/rpc/getproject?select=id,name,client:clients(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/rpc/getproject?id=1&select=id,name,client(id),tasks(id)" `shouldRespondWith`
get "/rpc/getproject?id=1&select=id,name,client:clients(id),tasks(id)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
{ matchHeaders = [matchContentTypeJson] }
@@ -160,6 +179,29 @@ spec actualPgVersion =
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed an M2M relationship table" $
get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"
`shouldRespondWith` [json|[
{"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},
{"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}
]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed an M2M relationship table that has a parent relationship table" $
get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"
`shouldRespondWith` [json|[
{"name":"Michael Scott","tasks":[
{"name":"Design IOS","project":{"name":"IOS"}},
{"name":"Code IOS","project":{"name":"IOS"}},
{"name":"Design OSX","project":{"name":"OSX"}}
]},
{"name":"Dwight Schrute","tasks":[
{"name":"Design w7","project":{"name":"Windows 7"}},
{"name":"Design IOS","project":{"name":"IOS"}}
]}
]|]
{ matchHeaders = [matchContentTypeJson] }
context "a proc that returns an empty rowset" $
it "returns empty json array" $ do
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
@@ -256,38 +298,23 @@ spec actualPgVersion =
[json|"object"|]
{ matchHeaders = [matchContentTypeJson] }
when (actualPgVersion <= pgVersion96) $
it "parses quoted JSON arguments as JSON (Postgres <= 9.6)" $
when (actualPgVersion < pgVersion100) $
it "parses quoted JSON arguments as JSON (Postgres < 10)" $
post "/rpc/json_argument"
[json| { "arg": "{ \"key\": 3 }" } |]
`shouldRespondWith`
[json|"object"|]
{ matchHeaders = [matchContentTypeJson] }
when (actualPgVersion >= pgVersion100) $ do
it "parses quoted JSON arguments as JSON string (Postgres >= 10)" $ do
-- Postgres bug report:
-- https://www.postgresql.org/message-id/D6921B37-BD8E-4664-8D5F-DB3525765DCD%40vllmrt.net
-- * json_to_record fails (see following test)
-- * jsonb_to_record parses the embedded quoted JSON to a JSON string,
-- so that's probably the expected behavior for Postgres >= 10
pendingWith "Postgres >= 10 fails to parse quoted embedded JSON"
when ((actualPgVersion >= pgVersion109 && actualPgVersion < pgVersion110)
|| actualPgVersion >= pgVersion114) $
it "parses quoted JSON arguments as JSON string (from Postgres 10.9, 11.4)" $
post "/rpc/json_argument"
[json| { "arg": "{ \"key\": 3 }" } |]
`shouldRespondWith`
[json|"string"|]
{ matchHeaders = [matchContentTypeJson] }
it "fails to parse quoted JSON arguments (Postgres >= 10)" $
-- Confirming buggy Postgres behavior (see previous test)
post "/rpc/json_argument"
[json| { "arg": "{ \"key\": 3 }" } |]
`shouldRespondWith`
[json|{"hint":null,"details":"Token \"key\" is invalid.","code":"22P02","message":"invalid input syntax for type json"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "improper input" $ do
it "rejects unknown content type even if payload is good" $ do
request methodPost "/rpc/sayhello"
@@ -429,24 +456,34 @@ spec actualPgVersion =
it "ignores json keys not included in ?columns" $
post "/rpc/sayhello?columns=name"
[json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|] `shouldRespondWith`
[json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|]
`shouldRespondWith`
[json|"Hello, John"|]
{ matchHeaders = [matchContentTypeJson] }
context "bulk RPC" $ do
it "works with a scalar function an returns a json array" $
it "only takes the first object in case of array of objects payload" $
post "/rpc/add_them"
[json|[
{"a": 1, "b": 2},
{"a": 4, "b": 6},
{"a": 100, "b": 200}
]|] `shouldRespondWith`
{"a": 100, "b": 200} ]|]
`shouldRespondWith` "3"
{ matchHeaders = [matchContentTypeJson] }
context "bulk RPC with params=multiple-objects" $ do
it "works with a scalar function an returns a json array" $
request methodPost "/rpc/add_them" [("Prefer", "params=multiple-objects")]
[json|[
{"a": 1, "b": 2},
{"a": 4, "b": 6},
{"a": 100, "b": 200} ]|]
`shouldRespondWith`
[json|
[3, 10, 300]
|] { matchHeaders = [matchContentTypeJson] }
it "works with a scalar function an returns a json array when posting CSV" $
request methodPost "/rpc/add_them" [("Content-Type", "text/csv")]
request methodPost "/rpc/add_them" [("Content-Type", "text/csv"), ("Prefer", "params=multiple-objects")]
"a,b\n1,2\n4,6\n100,200"
`shouldRespondWith`
[json|
@@ -457,11 +494,11 @@ spec actualPgVersion =
}
it "works with a non-scalar result" $
post "/rpc/get_projects_below?select=id,name"
request methodPost "/rpc/get_projects_below?select=id,name" [("Prefer", "params=multiple-objects")]
[json|[
{"id": 1},
{"id": 5}
]|] `shouldRespondWith`
{"id": 5} ]|]
`shouldRespondWith`
[json|
[{"id":1,"name":"Windows 7"},
{"id":2,"name":"Windows 10"},
@@ -469,6 +506,82 @@ spec actualPgVersion =
{"id":4,"name":"OSX"}]
|] { matchHeaders = [matchContentTypeJson] }
context "HTTP request env vars" $ do
it "custom header is set" $
request methodPost "/rpc/get_guc_value"
[("Custom-Header", "test")]
[json| { "name": "request.header.custom-header" } |]
`shouldRespondWith`
[str|"test"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "standard header is set" $
request methodPost "/rpc/get_guc_value"
[("Origin", "http://example.com")]
[json| { "name": "request.header.origin" } |]
`shouldRespondWith`
[str|"http://example.com"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "current role is available as GUC claim" $
request methodPost "/rpc/get_guc_value" []
[json| { "name": "request.jwt.claim.role" } |]
`shouldRespondWith`
[str|"postgrest_test_anonymous"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "single cookie ends up as claims" $
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
[json| {"name":"request.cookie.acookie"} |]
`shouldRespondWith`
[str|"cookievalue"|]
{ matchStatus = 200
, matchHeaders = []
}
it "multiple cookies ends up as claims" $
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
[json| {"name":"request.cookie.secondcookie"} |]
`shouldRespondWith`
[str|"anothervalue"|]
{ matchStatus = 200
, matchHeaders = []
}
it "app settings available" $
request methodPost "/rpc/get_guc_value" []
[json| { "name": "app.settings.app_host" } |]
`shouldRespondWith`
[str|"localhost"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "gets the Authorization value" $
request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
[json| {"name":"request.header.authorization"} |]
`shouldRespondWith`
[str|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
{ matchStatus = 200
, matchHeaders = []
}
it "gets the http method" $
request methodPost "/rpc/get_guc_value" []
[json| {"name":"request.method"} |]
`shouldRespondWith`
[str|"POST"|]
{ matchStatus = 200
, matchHeaders = []
}
it "gets the http path" $
request methodPost "/rpc/get_guc_value" []
[json| {"name":"request.path"} |]
`shouldRespondWith`
[str|"/rpc/get_guc_value"|]
{ matchStatus = 200
, matchHeaders = []
}
context "binary output" $ do
context "Proc that returns scalar" $ do
it "can query without selecting column" $
@@ -478,23 +591,6 @@ spec actualPgVersion =
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
}
it "can get raw output with Accept: text/html" $
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
`shouldRespondWith`
[str|
|<html>
| <head>
| <title>PostgREST</title>
| </head>
| <body>
| <h1>Welcome to PostgREST</h1>
| </body>
|</html>
|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/html; charset=utf-8"]
}
it "can get raw output with Accept: text/plain" $
request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
`shouldRespondWith` "Welcome to PostgREST"
@@ -513,7 +609,7 @@ spec actualPgVersion =
it "fails if a single column is not selected" $
request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but a single column was not selected"} |]
[json| {"message":"application/octet-stream requested but more than one column was selected"} |]
{ matchStatus = 406
, matchHeaders = [matchContentTypeJson]
}
@@ -546,6 +642,10 @@ spec actualPgVersion =
get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`
[json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
{ matchHeaders = [matchContentTypeJson] }
when (actualPgVersion >= pgVersion112) $
get "/rpc/get_tsearch?text_search_vector=wfts.impossible" `shouldRespondWith`
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
{ matchHeaders = [matchContentTypeJson] }
it "should work with an argument of custom type in public schema" $
get "/rpc/test_arg?my_arg=something" `shouldRespondWith`
+85 -18
View File
@@ -13,7 +13,7 @@ import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "Requesting singular json object" $ do
let pgrstObj = "application/vnd.pgrst.object+json"
@@ -56,7 +56,7 @@ spec =
_ <- post "/addresses" [json| { id: 98, address: "xxx" } |]
_ <- post "/addresses" [json| { id: 99, address: "yyy" } |]
p <- request methodPatch "/addresses?id=gt.0"
[("Prefer", "return=representation"), singular]
[singular]
[json| { address: "zzz" } |]
liftIO $ do
simpleStatus p `shouldBe` notAcceptable406
@@ -65,7 +65,29 @@ spec =
-- the rows should not be updated, either
get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|]
it "raises an error for multiple rows with return=rep" $ do
_ <- post "/addresses" [json| { id: 100, address: "xxx" } |]
_ <- post "/addresses" [json| { id: 101, address: "yyy" } |]
p <- request methodPatch "/addresses?id=gt.0"
[("Prefer", "return=representation"), singular]
[json| { address: "zzz" } |]
liftIO $ do
simpleStatus p `shouldBe` notAcceptable406
isErrorFormat (simpleBody p) `shouldBe` True
-- the rows should not be updated, either
get "/addresses?id=eq.100" `shouldRespondWith` [str|[{"id":100,"address":"xxx"}]|]
it "raises an error for zero rows" $
request methodPatch "/items?id=gt.0&id=lt.0"
[singular] [json|{"id":1}|]
`shouldRespondWith`
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
it "raises an error for zero rows with return=rep" $
request methodPatch "/items?id=gt.0&id=lt.0"
[("Prefer", "return=representation"), singular] [json|{"id":1}|]
`shouldRespondWith`
@@ -79,20 +101,20 @@ spec =
p <- request methodPost
"/addresses"
[("Prefer", "return=representation"), singular]
[json| [ { id: 100, address: "xxx" } ] |]
liftIO $ simpleBody p `shouldBe` [str|{"id":100,"address":"xxx"}|]
[json| [ { id: 102, address: "xxx" } ] |]
liftIO $ simpleBody p `shouldBe` [str|{"id":102,"address":"xxx"}|]
it "works for one row even with return=minimal" $ do
request methodPost "/addresses"
[("Prefer", "return=minimal"), singular]
[json| [ { id: 101, address: "xxx" } ] |]
[json| [ { id: 103, address: "xxx" } ] |]
`shouldRespondWith` ""
{ matchStatus = 201
, matchHeaders = ["Content-Range" <:> "*/*"]
}
-- and the element should exist
get "/addresses?id=eq.101"
`shouldRespondWith` [str|[{"id":101,"address":"xxx"}]|]
get "/addresses?id=eq.103"
`shouldRespondWith` [str|[{"id":103,"address":"xxx"}]|]
{ matchStatus = 200
, matchHeaders = []
}
@@ -100,23 +122,47 @@ spec =
it "raises an error when attempting to create multiple entities" $ do
p <- request methodPost
"/addresses"
[("Prefer", "return=representation"), singular]
[singular]
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
liftIO $ simpleStatus p `shouldBe` notAcceptable406
-- the rows should not exist, either
get "/addresses?id=eq.200" `shouldRespondWith` "[]"
it "return=minimal allows request to create multiple elements" $
it "raises an error when attempting to create multiple entities with return=rep" $ do
p <- request methodPost
"/addresses"
[("Prefer", "return=representation"), singular]
[json| [ { id: 202, address: "xxx" }, { id: 203, address: "yyy" } ] |]
liftIO $ simpleStatus p `shouldBe` notAcceptable406
-- the rows should not exist, either
get "/addresses?id=eq.202" `shouldRespondWith` "[]"
it "raises an error regardless of return=minimal" $ do
request methodPost "/addresses"
[("Prefer", "return=minimal"), singular]
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
`shouldRespondWith` ""
{ matchStatus = 201
, matchHeaders = ["Content-Range" <:> "*/*"]
}
[("Prefer", "return=minimal"), singular]
[json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]
`shouldRespondWith`
[str|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
-- the rows should not exist, either
get "/addresses?id=eq.204" `shouldRespondWith` "[]"
it "raises an error when creating zero entities" $
request methodPost "/addresses"
[singular]
[json| [ ] |]
`shouldRespondWith`
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
it "raises an error when creating zero entities with return=rep" $
request methodPost "/addresses"
[("Prefer", "return=representation"), singular]
[json| [ ] |]
@@ -134,18 +180,39 @@ spec =
liftIO $ simpleBody p `shouldBe` [str|{"id":11}|]
it "raises an error when attempting to delete multiple entities" $ do
let firstItems = "/items?id=gt.0&id=lt.11"
let firstItems = "/items?id=gt.0&id=lt.6"
request methodDelete firstItems
[singular] ""
`shouldRespondWith` 406
get firstItems
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-4/*"]
}
it "raises an error when attempting to delete multiple entities with return=rep" $ do
let firstItems = "/items?id=gt.5&id=lt.11"
request methodDelete firstItems
[("Prefer", "return=representation"), singular] ""
`shouldRespondWith` 406
get firstItems
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
`shouldRespondWith` [json| [{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-9/*"]
, matchHeaders = ["Content-Range" <:> "0-4/*"]
}
it "raises an error when deleting zero entities" $
request methodDelete "/items?id=lt.0"
[singular] ""
`shouldRespondWith`
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
it "raises an error when deleting zero entities with return=rep" $
request methodDelete "/items?id=lt.0"
[("Prefer", "return=representation"), singular] ""
`shouldRespondWith`
+32 -3
View File
@@ -17,12 +17,14 @@ import PostgREST.Config (docsVersion)
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec = do
describe "OpenAPI" $ do
it "root path returns a valid openapi spec" $
it "root path returns a valid openapi spec" $ do
validateOpenApiResponse [("Accept", "application/openapi+json")]
request methodHead "/" (acceptHdrs "application/openapi+json") ""
`shouldRespondWith` "" { matchStatus = 200 }
it "should respond to openapi request on none root path with 415" $
request methodGet "/items"
@@ -45,6 +47,7 @@ spec = do
childGetSummary = r ^? method "get" . key "summary"
childGetDescription = r ^? method "get" . key "description"
getParameters = r ^? method "get" . key "parameters"
postParameters = r ^? method "post" . key "parameters"
postResponse = r ^? method "post" . key "responses" . key "201" . key "description"
patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"
deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"
@@ -79,6 +82,15 @@ spec = do
]
|]
postParameters `shouldBe` Just
[aesonQQ|
[
{ "$ref": "#/parameters/body.child_entities" },
{ "$ref": "#/parameters/select" },
{ "$ref": "#/parameters/preferReturn" }
]
|]
postResponse `shouldBe` Just "Created"
patchResponse `shouldBe` Just "No Content"
@@ -125,7 +137,7 @@ spec = do
"type": "integer"
},
"name": {
"description": "child_entities name comment",
"description": "child_entities name comment. Can be longer than sixty-three characters long",
"format": "text",
"type": "string"
},
@@ -219,6 +231,23 @@ spec = do
]
|]
describe "VIEW that has a source FK based on a UNIQUE key" $
it "includes fk description" $ do
r <- simpleBody <$> get "/"
let referralLink = r ^? key "definitions" . key "referrals" . key "properties" . key "link"
liftIO $
referralLink `shouldBe` Just
[aesonQQ|
{
"format": "integer",
"type": "integer",
"description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' column='link'/>"
}
|]
describe "PostgreSQL to Swagger Type Mapping" $ do
it "character varying to string" $ do
+1 -1
View File
@@ -10,7 +10,7 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "Reading and writing to unicode schema and table names" $
it "Can read and write values" $ do
+52 -1
View File
@@ -11,7 +11,7 @@ import Text.Heredoc
import Protolude hiding (get, put)
import SpecHelper
spec :: SpecWith Application
spec :: SpecWith ((), Application)
spec =
describe "UPSERT" $ do
context "with POST" $ do
@@ -49,6 +49,31 @@ spec =
[json|[]|] `shouldRespondWith`
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
it "INSERTs and UPDATEs rows on single unique key conflict" $
request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "unique_key": 1, "value": "B" },
{ "unique_key": 2, "value": "C" }
]|] `shouldRespondWith` [json| [
{ "unique_key": 1, "value": "B" },
{ "unique_key": 2, "value": "C" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
}
it "INSERTs and UPDATEs rows on compound unique keys conflict" $
request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "key1": 1, "key2": 1, "value": "B" },
{ "key1": 1, "key2": 2, "value": "C" }
]|] `shouldRespondWith` [json| [
{ "key1": 1, "key2": 1, "value": "B" },
{ "key1": 1, "key2": 2, "value": "C" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
}
context "when Prefer: resolution=ignore-duplicates is specified" $ do
it "INSERTs and ignores rows on pk conflict" $
@@ -75,6 +100,32 @@ spec =
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
}
it "INSERTs and ignores rows on single unique key conflict" $
request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json| [
{ "unique_key": 1, "value": "B" },
{ "unique_key": 2, "value": "C" },
{ "unique_key": 3, "value": "D" }
]|] `shouldRespondWith` [json| [
{ "unique_key": 3, "value": "D" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
}
it "INSERTs and UPDATEs rows on compound unique keys conflict" $
request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json| [
{ "key1": 1, "key2": 1, "value": "B" },
{ "key1": 1, "key2": 2, "value": "C" },
{ "key1": 1, "key2": 3, "value": "D" }
]|] `shouldRespondWith` [json| [
{ "key1": 1, "key2": 3, "value": "D" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
}
it "succeeds if the table has only PK cols and no other cols" $ do
request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]
+90 -50
View File
@@ -6,16 +6,17 @@ import qualified Hasql.Transaction.Sessions as HT
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
updateAction)
import Data.Function (id)
import Data.List.NonEmpty (toList)
import Data.Time.Clock (getCurrentTime)
import Data.IORef
import Test.Hspec
import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..))
import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Types (DbStructure (..), pgVersion95,
pgVersion96)
import Protolude
import PostgREST.Types (pgVersion95, pgVersion96)
import Protolude hiding (toList)
import SpecHelper
import qualified Feature.AndOrParamsSpec
@@ -26,9 +27,12 @@ import qualified Feature.BinaryJwtSecretSpec
import qualified Feature.ConcurrentSpec
import qualified Feature.CorsSpec
import qualified Feature.DeleteSpec
import qualified Feature.EmbedDisambiguationSpec
import qualified Feature.ExtraSearchPathSpec
import qualified Feature.HtmlRawOutputSpec
import qualified Feature.InsertSpec
import qualified Feature.JsonOperatorSpec
import qualified Feature.MultipleSchemaSpec
import qualified Feature.NoJwtSpec
import qualified Feature.NonexistentSchemaSpec
import qualified Feature.PgVersion95Spec
@@ -37,6 +41,7 @@ import qualified Feature.ProxySpec
import qualified Feature.QueryLimitedSpec
import qualified Feature.QuerySpec
import qualified Feature.RangeSpec
import qualified Feature.RawOutputTypesSpec
import qualified Feature.RootSpec
import qualified Feature.RpcSpec
import qualified Feature.SingularSpec
@@ -47,102 +52,137 @@ import qualified Feature.UpsertSpec
main :: IO ()
main = do
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"
setupDb testDbConn
pool <- P.acquire (3, 10, toS testDbConn)
result <- P.use pool $ do
ver <- getPgVersion
HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver
actualPgVersion <- either (panic.show) id <$> P.use pool getPgVersion
let dbStructure = either (panic.show) id result
refDbStructure <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ testCfg testDbConn) actualPgVersion
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
let
-- For tests that run with the same refDbStructure
app cfg = return ((), postgrest (cfg testDbConn) refDbStructure pool getTime $ pure ())
refDbStructure <- newIORef $ Just dbStructure
-- For tests that run with a different DbStructure(depends on configSchemas)
appDbs cfg = do
dbs <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ cfg testDbConn) actualPgVersion
return ((), postgrest (cfg testDbConn) dbs pool getTime $ pure ())
let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime $ pure ()
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime $ pure ()
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime $ pure ()
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime $ pure ()
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime $ pure ()
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime $ pure ()
audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool getTime $ pure ()
asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool getTime $ pure ()
asymJwkSetApp = return $ postgrest (testCfgAsymJWKSet testDbConn) refDbStructure pool getTime $ pure ()
nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool getTime $ pure ()
extraSearchPathApp = return $ postgrest (testCfgExtraSearchPath testDbConn) refDbStructure pool getTime $ pure ()
rootSpecApp = return $ postgrest (testCfgRootSpec testDbConn) refDbStructure pool getTime $ pure ()
let withApp = app testCfg
maxRowsApp = app testMaxRowsCfg
proxyApp = app testProxyCfg
noJwtApp = app testCfgNoJWT
binaryJwtApp = app testCfgBinaryJWT
audJwtApp = app testCfgAudienceJWT
asymJwkApp = app testCfgAsymJWK
asymJwkSetApp = app testCfgAsymJWKSet
extraSearchPathApp = app testCfgExtraSearchPath
rootSpecApp = app testCfgRootSpec
htmlRawOutputApp = app testCfgHtmlRawOutput
responseHeadersApp = app testCfgResponseHeaders
let reset :: IO ()
unicodeApp = appDbs testUnicodeCfg
nonexistentSchemaApp = appDbs testNonexistentSchemaCfg
multipleSchemaApp = appDbs testMultipleSchemaCfg
let reset, analyze :: IO ()
reset = resetDb testDbConn
analyze = do
analyzeTable testDbConn "items"
analyzeTable testDbConn "child_entities"
actualPgVersion = pgVersion dbStructure
extraSpecs =
[("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95] ++
[("Feature.PgVersion96Spec", Feature.PgVersion96Spec.spec) | actualPgVersion >= pgVersion96]
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95]
specs = uncurry describe <$> [
("Feature.AuthSpec" , Feature.AuthSpec.spec actualPgVersion)
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
, ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
, ("Feature.QuerySpec" , Feature.QuerySpec.spec)
, ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion)
, ("Feature.RangeSpec" , Feature.RangeSpec.spec)
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)
("Feature.AuthSpec" , Feature.AuthSpec.spec actualPgVersion)
, ("Feature.RawOutputTypesSpec" , Feature.RawOutputTypesSpec.spec)
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
, ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion)
, ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)
, ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion)
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec actualPgVersion)
] ++ extraSpecs
mutSpecs = uncurry describe <$> [
("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
, ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
]
hspec $ do
mapM_ (beforeAll_ reset . before withApp) specs
-- Only certain Specs need a database reset, this should be used with care as it slows down the whole test suite.
mapM_ (afterAll_ reset . before withApp) mutSpecs
mapM_ (before withApp) specs
-- we analyze to get accurate results from EXPLAIN
beforeAll_ analyze . before withApp $
describe "Feature.RangeSpec" Feature.RangeSpec.spec
-- this test runs with a raw-output-media-types set to text/html
before htmlRawOutputApp $
describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec
-- this test runs with a different server flag
beforeAll_ reset . before ltdApp $
before maxRowsApp $
describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec
-- this test runs with a different schema
beforeAll_ reset . before unicodeApp $
before unicodeApp $
describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec
-- this test runs with a proxy
beforeAll_ reset . before proxyApp $
before proxyApp $
describe "Feature.ProxySpec" Feature.ProxySpec.spec
-- this test runs without a JWT secret
beforeAll_ reset . before noJwtApp $
before noJwtApp $
describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec
-- this test runs with a binary JWT secret
beforeAll_ reset . before binaryJwtApp $
before binaryJwtApp $
describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec
-- this test runs with a binary JWT secret and an audience claim
beforeAll_ reset . before audJwtApp $
before audJwtApp $
describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec
-- this test runs with asymmetric JWK
beforeAll_ reset . before asymJwkApp $
before asymJwkApp $
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-- this test runs with asymmetric JWKSet
beforeAll_ reset . before asymJwkSetApp $
before asymJwkSetApp $
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-- this test runs with a nonexistent db-schema
beforeAll_ reset . before nonexistentSchemaApp $
before nonexistentSchemaApp $
describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
-- this test runs with an extra search path
beforeAll_ reset . before extraSearchPathApp $
before extraSearchPathApp $
describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec
-- this test runs with a root spec function override
when (actualPgVersion >= pgVersion96) $
beforeAll_ reset . before rootSpecApp $
when (actualPgVersion >= pgVersion96) $ do
before rootSpecApp $
describe "Feature.RootSpec" Feature.RootSpec.spec
before responseHeadersApp $
describe "Feature.PgVersion96Spec" Feature.PgVersion96Spec.spec
-- this test runs with multiple schemas
before multipleSchemaApp $
describe "Feature.MultipleSchemaSpec" $ Feature.MultipleSchemaSpec.spec actualPgVersion
where
setupDbStructure pool schemas ver =
either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ getDbStructure (toList schemas) ver)
+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
+34 -10
View File
@@ -11,6 +11,7 @@ import Control.Monad (void)
import Data.Aeson (Value (..), decode, encode)
import Data.CaseInsensitive (CI (..))
import Data.List (lookup)
import Data.List.NonEmpty (fromList)
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import System.Environment (getEnv)
import System.Process (readProcess)
@@ -23,7 +24,7 @@ import Test.Hspec.Wai
import Text.Heredoc
import PostgREST.Config (AppConfig (..))
import PostgREST.Types (JSPathExp (..), QualifiedIdentifier (..))
import PostgREST.Types (JSPathExp (..))
import Protolude
matchContentTypeJson :: MatchHeader
@@ -32,7 +33,7 @@ matchContentTypeJson = "Content-Type" <:> "application/json; charset=utf-8"
matchContentTypeSingular :: MatchHeader
matchContentTypeSingular = "Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"
validateOpenApiResponse :: [Header] -> WaiSession ()
validateOpenApiResponse :: [Header] -> WaiSession () ()
validateOpenApiResponse headers = do
r <- request methodGet "/" headers ""
liftIO $
@@ -63,9 +64,11 @@ getEnvVarWithDefault var def = toS <$>
_baseCfg :: AppConfig
_baseCfg = -- Connection Settings
AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000
AppConfig mempty "postgrest_test_anonymous" Nothing (fromList ["test"]) "localhost" 3000
-- No user configured Unix Socket
Nothing
-- No user configured Unix Socket file mode (defaults to 660)
(Right 432)
-- Jwt settings
(Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False Nothing
-- Connection Modifiers
@@ -81,6 +84,8 @@ _baseCfg = -- Connection Settings
[]
-- No root spec override
Nothing
-- Raw output media types
[]
testCfg :: Text -> AppConfig
testCfg testDbConn = _baseCfg { configDatabase = testDbConn }
@@ -89,13 +94,13 @@ testCfgNoJWT :: Text -> AppConfig
testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }
testUnicodeCfg :: Text -> AppConfig
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchema = "تست" }
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["تست"] }
testLtdRowsCfg :: Text -> AppConfig
testLtdRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
testMaxRowsCfg :: Text -> AppConfig
testMaxRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
testProxyCfg :: Text -> AppConfig
testProxyCfg testDbConn = (testCfg testDbConn) { configProxyUri = Just "https://postgrest.com/openapi.json" }
testProxyCfg testDbConn = (testCfg testDbConn) { configOpenAPIProxyUri = Just "https://postgrest.com/openapi.json" }
testCfgBinaryJWT :: Text -> AppConfig
testCfgBinaryJWT testDbConn = (testCfg testDbConn) {
@@ -123,13 +128,22 @@ testCfgAsymJWKSet testDbConn = (testCfg testDbConn) {
}
testNonexistentSchemaCfg :: Text -> AppConfig
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" }
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["nonexistent"] }
testCfgExtraSearchPath :: Text -> AppConfig
testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }
testCfgRootSpec :: Text -> AppConfig
testCfgRootSpec testDbConn = (testCfg testDbConn) { configRootSpec = Just $ QualifiedIdentifier "test" "root"}
testCfgRootSpec testDbConn = (testCfg testDbConn) { configRootSpec = Just "root"}
testCfgHtmlRawOutput :: Text -> AppConfig
testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = ["text/html"] }
testCfgResponseHeaders :: Text -> AppConfig
testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configReqCheck = Just "custom_headers" }
testMultipleSchemaCfg :: Text -> AppConfig
testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["v1", "v2"] }
setupDb :: Text -> IO ()
setupDb dbConn = do
@@ -144,9 +158,13 @@ setupDb dbConn = do
resetDb :: Text -> IO ()
resetDb dbConn = loadFixture dbConn "data"
analyzeTable :: Text -> Text -> IO ()
analyzeTable dbConn tableName =
void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-a", "-c", toS $ "ANALYZE test.\"" <> tableName <> "\""] []
loadFixture :: Text -> FilePath -> IO()
loadFixture dbConn name =
void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-a", "-f", "test/fixtures/" ++ name ++ ".sql"] []
void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-q", "-f", "test/fixtures/" ++ name ++ ".sql"] []
rangeHdrs :: ByteRange -> [Header]
rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
@@ -164,6 +182,12 @@ matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool
matchHeader name valRegex headers =
maybe False (=~ valRegex) $ lookup name headers
noBlankHeader :: [Header] -> Bool
noBlankHeader = notElem mempty
noProfileHeader :: [Header] -> Bool
noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers
authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header
authHeaderBasic u p =
(hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p))
+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;
INSERT INTO tiobe_pls VALUES ('Java', 1), ('C', 2), ('Python', 4);
TRUNCATE TABLE single_unique CASCADE;
INSERT INTO single_unique (unique_key, value) VALUES (1, 'A');
TRUNCATE TABLE compound_unique CASCADE;
INSERT INTO compound_unique (key1, key2, value) VALUES (1, 1, 'A');
TRUNCATE TABLE only_pk CASCADE;
INSERT INTO only_pk VALUES (1), (2);
@@ -499,3 +505,79 @@ COPY pgrst_reserved_chars ("*id*", ":arr->ow::cast", "(inside,parens)", "a.dotte
2 | arrow-2 | parens-2 | dotted-2 | space-2
3 | arrow-3 | parens-3 | dotted-3 | space-3
\.
TRUNCATE TABLE web_content CASCADE;
INSERT INTO web_content VALUES (5, 'wat', null);
INSERT INTO web_content VALUES (0, 'tardis', 5);
INSERT INTO web_content VALUES (1, 'fezz', 0);
INSERT INTO web_content VALUES (2, 'foo', 0);
INSERT INTO web_content VALUES (3, 'bar', 0);
INSERT INTO web_content VALUES (4, 'wut', 1);
TRUNCATE TABLE app_users CASCADE;
INSERT INTO app_users (id, email, "password") VALUES (1, 'test@123.com','pass');
INSERT INTO app_users (id, email, "password") VALUES (2, 'abc@123.com','pass');
INSERT INTO app_users (id, email, "password") VALUES (3, 'def@123.com','pass');
TRUNCATE TABLE private.pages CASCADE;
INSERT INTO private.pages VALUES (1, 'http://postgrest.org/en/v6.0/api.html');
INSERT INTO private.pages VALUES (2, 'http://postgrest.org/en/v6.0/admin.html');
TRUNCATE TABLE private.referrals CASCADE;
INSERT INTO private.referrals VALUES ('github.com', 1);
INSERT INTO private.referrals VALUES ('hub.docker.com', 2);
TRUNCATE TABLE big_projects CASCADE;
INSERT INTO big_projects (big_project_id, name)
VALUES (1, 'big project 1'),
(2, 'big project 2');
TRUNCATE TABLE sites CASCADE;
INSERT INTO sites (site_id, name, main_project_id)
VALUES (1, 'site 1', 1),
(2, 'site 2', null),
(3, 'site 3', 2),
(4, 'site 4', null);
TRUNCATE TABLE jobs CASCADE;
INSERT INTO jobs (job_id, name, site_id, big_project_id)
VALUES ('bc5d5362-b881-438f-b9f5-7417e08704ed', 'job 1-1', 1, 1),
('3bd52697-033b-4edd-8a28-46a9c04b7c1e', 'job 2-1', 2, 1),
('e6e67e4e-19b1-11e9-ab14-d663bd873d93', 'job 2-2', 2, 2);
TRUNCATE TABLE departments CASCADE;
TRUNCATE TABLE agents CASCADE;
INSERT INTO agents (id, name)
VALUES (1, 'agent 1'),
(2, 'agent 2'),
(3, 'agent 3'),
(4, 'agent 4');
INSERT INTO departments (id, name, head_id)
VALUES (1, 'dep 1', 1),
(2, 'dep 3', 3);
UPDATE agents SET department_id = 1 WHERE id in (1, 2);
UPDATE agents SET department_id = 2 WHERE id in (3, 4);
TRUNCATE TABLE schedules CASCADE;
INSERT INTO schedules VALUES(1, 'morning', '06:00:00', '11:59:00');
INSERT INTO schedules VALUES(2, 'afternoon', '12:00:00', '17:59:00');
INSERT INTO schedules VALUES(3, 'night', '18:00:00', '23:59:00');
INSERT INTO schedules VALUES(4, 'early morning', '00:00:00', '05:59:00');
TRUNCATE TABLE activities CASCADE;
INSERT INTO activities(id, schedule_id, car_id) VALUES(1, 1, 'CAR-349');
INSERT INTO activities(id, schedule_id, camera_id) VALUES(2, 3, 'CAM-123');
TRUNCATE TABLE unit_workdays CASCADE;
INSERT INTO unit_workdays VALUES(1, '2019-12-02', 1, 1, 2, 3);
TRUNCATE TABLE v1.parents CASCADE;
INSERT INTO v1.parents VALUES(1, 'parent v1-1'), (2, 'parent v1-2');
TRUNCATE TABLE v2.parents CASCADE;
INSERT INTO v2.parents VALUES(3, 'parent v2-3'), (4, 'parent v2-4');
TRUNCATE TABLE v2.another_table CASCADE;
INSERT INTO v2.another_table VALUES(5, 'value 5'), (6, 'value 6');
+1 -1
View File
@@ -1,3 +1,3 @@
set client_min_messages to warning;
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions CASCADE;
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions, v1, v2 CASCADE;
DROP TYPE IF EXISTS jwt_token CASCADE;
+34
View File
@@ -6,6 +6,8 @@ GRANT USAGE ON SCHEMA
, public
, "تست"
, extensions
, v1
, v2
TO postgrest_test_anonymous;
-- Schema test objects
@@ -66,6 +68,8 @@ GRANT ALL ON TABLE
, perf_articles
, employees
, tiobe_pls
, single_unique
, compound_unique
, only_pk
, family_tree
, managers
@@ -101,6 +105,30 @@ GRANT ALL ON TABLE
, pgrst_reserved_chars
, authors_w_entities
, openapi_types
, getallprojects_view
, get_projects_above_view
, web_content
, pages
, referrals
, big_projects
, sites
, jobs
, main_jobs
, whatev_projects
, whatev_sites
, whatev_jobs
, agents
, departments
, schedules
, activities
, unit_workdays
, stuff
, loc_test
, v1.parents
, v2.parents
, v2.another_table
, v1.childs
, v2.childs
TO postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
@@ -110,6 +138,8 @@ GRANT USAGE ON SEQUENCE
, items_id_seq
, callcounter_count
, leak_id_seq
, v1.childs_id_seq
, v2.childs_id_seq
TO postgrest_test_anonymous;
-- Privileges for non anonymous users
@@ -120,6 +150,10 @@ GRANT SELECT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_t
GRANT INSERT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
GRANT UPDATE (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
GRANT SELECT(id, email) ON TABLE app_users TO postgrest_test_anonymous;
GRANT INSERT, UPDATE ON TABLE app_users TO postgrest_test_anonymous;
GRANT DELETE ON TABLE app_users TO postgrest_test_anonymous;
REVOKE EXECUTE ON FUNCTION privileged_hello(text) FROM PUBLIC; -- All functions are available to every role(PUBLIC) by default
GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author;
+318 -311
View File
@@ -18,6 +18,8 @@ CREATE SCHEMA private;
CREATE SCHEMA test;
CREATE SCHEMA تست;
CREATE SCHEMA extensions;
CREATE SCHEMA v1;
CREATE SCHEMA v2;
--
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
@@ -103,15 +105,10 @@ SET default_tablespace = '';
SET default_with_oids = false;
--
-- Name: items; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE items (
id bigint NOT NULL
id bigserial primary key
);
CREATE FUNCTION always_true(test.items) RETURNS boolean
LANGUAGE sql STABLE
AS $$ SELECT true $$;
@@ -364,59 +361,8 @@ CREATE TABLE auth (
pass character(60) NOT NULL
);
SET search_path = private, pg_catalog;
--
-- Name: article_stars; Type: TABLE; Schema: private; Owner: -
--
CREATE TABLE article_stars (
article_id integer NOT NULL,
user_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL
);
--
-- Name: articles; Type: TABLE; Schema: private; Owner: -
--
CREATE TABLE articles (
id integer NOT NULL,
body text,
owner name NOT NULL
);
SET search_path = test, pg_catalog;
CREATE VIEW limited_article_stars AS
SELECT article_id, user_id, created_at FROM private.article_stars;
--
-- Name: articleStars; Type: VIEW; Schema: test; Owner: -
--
CREATE VIEW "articleStars" AS
SELECT article_stars.article_id AS "articleId",
article_stars.user_id AS "userId",
article_stars.created_at AS "createdAt"
FROM private.article_stars;
--
-- Name: articles; Type: VIEW; Schema: test; Owner: -
--
CREATE VIEW articles AS
SELECT articles.id,
articles.body,
articles.owner
FROM private.articles;
--
-- Name: authors_only; Type: TABLE; Schema: test; Owner: -
--
@@ -463,24 +409,10 @@ ALTER SEQUENCE auto_incrementing_pk_id_seq OWNED BY auto_incrementing_pk.id;
--
CREATE TABLE clients (
id integer NOT NULL,
id integer primary key,
name text NOT NULL
);
--
-- Name: comments; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE comments (
id integer NOT NULL,
commenter_id integer NOT NULL,
user_id integer NOT NULL,
task_id integer NOT NULL,
content text NOT NULL
);
--
-- Name: complex_items; Type: TABLE; Schema: test; Owner: -
--
@@ -574,26 +506,6 @@ CREATE VIEW insertable_view_with_join AS
FROM (has_fk
JOIN auto_incrementing_pk USING (id));
--
-- Name: items_id_seq; Type: SEQUENCE; Schema: test; Owner: -
--
CREATE SEQUENCE items_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: items_id_seq; Type: SEQUENCE OWNED BY; Schema: test; Owner: -
--
ALTER SEQUENCE items_id_seq OWNED BY items.id;
--
-- Name: json; Type: TABLE; Schema: test; Owner: -
--
@@ -663,11 +575,11 @@ CREATE TABLE insertonly (
--
CREATE TABLE projects (
id integer NOT NULL,
id integer primary key,
name text NOT NULL,
client_id integer
client_id integer REFERENCES clients(id)
);
alter table projects rename constraint projects_client_id_fkey to client;
--
-- Name: projects_view; Type: VIEW; Schema: test; Owner: -
@@ -695,25 +607,23 @@ CREATE TABLE simple_pk (
extra character varying NOT NULL
);
--
-- Name: users_projects; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE users (
id integer primary key,
name text NOT NULL
);
CREATE TABLE users_projects (
user_id integer NOT NULL,
project_id integer NOT NULL
user_id integer NOT NULL REFERENCES users(id),
project_id integer NOT NULL REFERENCES projects(id),
PRIMARY KEY (project_id, user_id)
);
--
-- Name: tasks; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE tasks (
id integer NOT NULL,
id integer primary key,
name text NOT NULL,
project_id integer
project_id integer REFERENCES projects(id)
);
alter table tasks rename constraint tasks_project_id_fkey to project;
CREATE OR REPLACE VIEW filtered_tasks AS
SELECT id AS "myId", name, project_id AS "projectID"
@@ -725,6 +635,53 @@ project_id IN (
SELECT project_id FROM users_projects WHERE user_id = 1
);
CREATE TABLE users_tasks (
user_id integer NOT NULL REFERENCES users(id),
task_id integer NOT NULL REFERENCES tasks(id),
primary key (task_id, user_id)
);
CREATE TABLE comments (
id integer primary key,
commenter_id integer NOT NULL,
user_id integer NOT NULL,
task_id integer NOT NULL,
content text NOT NULL
);
alter table only comments
add constraint "user" foreign key (commenter_id) references users(id),
add constraint comments_task_id_fkey foreign key (task_id, user_id) references users_tasks(task_id, user_id);
create table private.articles (
id integer primary key,
body text,
owner name not null
);
create table private.article_stars (
article_id integer not null,
user_id integer not null,
created_at timestamp without time zone default now() not null,
primary key (article_id, user_id)
);
alter table only private.article_stars
add constraint article foreign key (article_id) references private.articles(id),
add constraint "user" foreign key (user_id) references test.users(id);
CREATE VIEW limited_article_stars AS
SELECT article_id, user_id, created_at FROM private.article_stars;
CREATE VIEW "articleStars" AS
SELECT article_stars.article_id AS "articleId",
article_stars.user_id AS "userId",
article_stars.created_at AS "createdAt"
FROM private.article_stars;
CREATE VIEW articles AS
SELECT articles.id,
articles.body,
articles.owner
FROM private.articles;
--
-- Name: tsearch; Type: TABLE; Schema: test; Owner: -
@@ -734,28 +691,6 @@ CREATE TABLE tsearch (
text_search_vector tsvector
);
--
-- Name: users; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE users (
id integer NOT NULL,
name text NOT NULL
);
--
-- Name: users_tasks; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE users_tasks (
user_id integer NOT NULL,
task_id integer NOT NULL
);
CREATE TABLE "Escap3e;" (
"so6meIdColumn" integer primary key
);
@@ -773,7 +708,6 @@ CREATE TABLE clashing_column (
t text
);
--
-- Name: id; Type: DEFAULT; Schema: test; Owner: -
--
@@ -788,13 +722,6 @@ ALTER TABLE ONLY auto_incrementing_pk ALTER COLUMN id SET DEFAULT nextval('auto_
ALTER TABLE ONLY has_fk ALTER COLUMN id SET DEFAULT nextval('has_fk_id_seq'::regclass);
--
-- Name: id; Type: DEFAULT; Schema: test; Owner: -
--
ALTER TABLE ONLY items ALTER COLUMN id SET DEFAULT nextval('items_id_seq'::regclass);
SET search_path = postgrest, pg_catalog;
--
@@ -805,24 +732,6 @@ ALTER TABLE ONLY auth
ADD CONSTRAINT auth_pkey PRIMARY KEY (id);
SET search_path = private, pg_catalog;
--
-- Name: articles_pkey; Type: CONSTRAINT; Schema: private; Owner: -
--
ALTER TABLE ONLY articles
ADD CONSTRAINT articles_pkey PRIMARY KEY (id);
--
-- Name: user_article; Type: CONSTRAINT; Schema: private; Owner: -
--
ALTER TABLE ONLY article_stars
ADD CONSTRAINT user_article PRIMARY KEY (article_id, user_id);
SET search_path = test, pg_catalog;
--
@@ -840,23 +749,6 @@ ALTER TABLE ONLY authors_only
ALTER TABLE ONLY auto_incrementing_pk
ADD CONSTRAINT auto_incrementing_pk_pkey PRIMARY KEY (id);
--
-- Name: clients_pkey; Type: CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY clients
ADD CONSTRAINT clients_pkey PRIMARY KEY (id);
--
-- Name: comments_pkey; Type: CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY comments
ADD CONSTRAINT comments_pkey PRIMARY KEY (id);
--
-- Name: complex_items_pkey; Type: CONSTRAINT; Schema: test; Owner: -
--
@@ -888,15 +780,6 @@ ALTER TABLE ONLY simple_pk
ALTER TABLE ONLY has_fk
ADD CONSTRAINT has_fk_pkey PRIMARY KEY (id);
--
-- Name: items_pkey; Type: CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY items
ADD CONSTRAINT items_pkey PRIMARY KEY (id);
--
-- Name: menagerie_pkey; Type: CONSTRAINT; Schema: test; Owner: -
--
@@ -905,46 +788,6 @@ ALTER TABLE ONLY menagerie
ADD CONSTRAINT menagerie_pkey PRIMARY KEY ("integer");
--
-- Name: project_user; Type: CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY users_projects
ADD CONSTRAINT project_user PRIMARY KEY (project_id, user_id);
--
-- Name: projects_pkey; Type: CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY projects
ADD CONSTRAINT projects_pkey PRIMARY KEY (id);
--
-- Name: task_user; Type: CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY users_tasks
ADD CONSTRAINT task_user PRIMARY KEY (task_id, user_id);
--
-- Name: tasks_pkey; Type: CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY tasks
ADD CONSTRAINT tasks_pkey PRIMARY KEY (id);
--
-- Name: users_pkey; Type: CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
SET search_path = postgrest, pg_catalog;
--
@@ -971,43 +814,8 @@ SET search_path = test, pg_catalog;
CREATE TRIGGER secrets_owner_track BEFORE INSERT OR UPDATE ON authors_only FOR EACH ROW EXECUTE PROCEDURE postgrest.set_authors_only_owner();
SET search_path = private, pg_catalog;
--
-- Name: article_stars_article_id_fkey; Type: FK CONSTRAINT; Schema: private; Owner: -
--
ALTER TABLE ONLY article_stars
ADD CONSTRAINT article_stars_article_id_fkey FOREIGN KEY (article_id) REFERENCES articles(id);
--
-- Name: article_stars_user_id_fkey; Type: FK CONSTRAINT; Schema: private; Owner: -
--
ALTER TABLE ONLY article_stars
ADD CONSTRAINT article_stars_user_id_fkey FOREIGN KEY (user_id) REFERENCES test.users(id);
SET search_path = test, pg_catalog;
--
-- Name: comments_commenter_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY comments
ADD CONSTRAINT comments_commenter_id_fkey FOREIGN KEY (commenter_id) REFERENCES users(id);
--
-- Name: comments_task_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY comments
ADD CONSTRAINT comments_task_id_fkey FOREIGN KEY (task_id, user_id) REFERENCES users_tasks(task_id, user_id);
--
-- Name: has_fk_fk_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
--
@@ -1023,55 +831,6 @@ ALTER TABLE ONLY has_fk
ALTER TABLE ONLY has_fk
ADD CONSTRAINT has_fk_simple_fk_fkey FOREIGN KEY (simple_fk) REFERENCES simple_pk(k);
--
-- Name: projects_client_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY projects
ADD CONSTRAINT projects_client_id_fkey FOREIGN KEY (client_id) REFERENCES clients(id);
--
-- Name: tasks_project_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY tasks
ADD CONSTRAINT tasks_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id);
--
-- Name: users_projects_project_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY users_projects
ADD CONSTRAINT users_projects_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id);
--
-- Name: users_projects_user_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY users_projects
ADD CONSTRAINT users_projects_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
--
-- Name: users_tasks_task_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY users_tasks
ADD CONSTRAINT users_tasks_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id);
--
-- Name: users_tasks_user_id_fkey; Type: FK CONSTRAINT; Schema: test; Owner: -
--
ALTER TABLE ONLY users_tasks
ADD CONSTRAINT users_tasks_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
create table addresses (
id int not null unique,
address text not null
@@ -1083,6 +842,8 @@ create table orders (
billing_address_id int references addresses(id),
shipping_address_id int references addresses(id)
);
alter table orders rename constraint orders_billing_address_id_fkey to billing;
alter table orders rename constraint orders_shipping_address_id_fkey to shipping;
CREATE FUNCTION getproject(id int) RETURNS SETOF projects
LANGUAGE sql
@@ -1096,11 +857,17 @@ CREATE FUNCTION get_projects_below(id int) RETURNS SETOF projects
SELECT * FROM test.projects WHERE id < $1;
$_$;
CREATE FUNCTION get_projects_above(id int) RETURNS SETOF projects
LANGUAGE sql
AS $_$
SELECT * FROM test.projects WHERE id > $1;
$_$ ROWS 1;
CREATE FUNCTION getallprojects() RETURNS SETOF projects
LANGUAGE sql
AS $_$
SELECT * FROM test.projects;
$_$;
$_$ ROWS 2019;
CREATE FUNCTION setprojects(id_l int, id_h int, name text) RETURNS SETOF projects
LANGUAGE sql
@@ -1226,7 +993,7 @@ create table ranges (
comment on table child_entities is 'child_entities comment';
comment on column child_entities.id is 'child_entities id comment';
comment on column child_entities.name is 'child_entities name comment';
comment on column child_entities.name is 'child_entities name comment. Can be longer than sixty-three characters long';
comment on table grandchild_entities is
$$grandchild_entities summary
@@ -1373,6 +1140,18 @@ create table test.tiobe_pls(
rank smallint
);
create table test.single_unique(
unique_key integer unique not null,
value text
);
create table test.compound_unique(
key1 integer not null,
key2 integer not null,
value text,
unique(key1, key2)
);
create table test.family_tree (
id text not null primary key,
name text not null,
@@ -1388,12 +1167,11 @@ create table test.managers (
create table test.organizations (
id integer primary key,
name text,
referee integer,
auditor integer,
referee integer references organizations(id),
auditor integer references organizations(id),
manager_id integer references managers(id)
);
alter table only test.organizations add constraint pptr1 foreign key (referee) references test.organizations(id);
alter table only test.organizations add constraint pptr2 foreign key (auditor) references test.organizations(id);
alter table only test.organizations rename constraint organizations_manager_id_fkey to manager;
create table private.authors(
id integer primary key,
@@ -1730,3 +1508,232 @@ select $$
</html>
$$::text;
$_$ language sql;
create view getallprojects_view as
select * from getallprojects();
create view get_projects_above_view as
select * from get_projects_above(1);
CREATE TABLE web_content (
id integer,
name text,
p_web_id integer references web_content(id),
primary key (id)
);
CREATE FUNCTION getallusers() RETURNS SETOF users AS $$
SELECT * FROM test.users;
$$ LANGUAGE sql;
create table app_users (
id integer primary key,
email text unique not null,
password text not null
);
create table private.pages (
link int not null unique
, url text
);
create table private.referrals (
site text
, link int references private.pages(link) not null
);
create view test.pages as select * from private.pages;
create view test.referrals as select * from private.referrals;
create table big_projects (
big_project_id serial primary key,
name text
);
create table sites (
site_id serial primary key
, name text
, main_project_id int null references big_projects (big_project_id)
);
alter table sites rename constraint sites_main_project_id_fkey to main_project;
create table jobs (
job_id uuid primary key
, name text
, site_id int not null references sites (site_id)
, big_project_id int not null references big_projects (big_project_id)
);
create view main_jobs as
select * from jobs
where site_id in (select site_id from sites where main_project_id is not null);
-- junction in a private schema, just to make sure we don't leak it on resource embedding
-- if it leaks it would show on the disambiguation error tests
create view private.priv_jobs as
select * from jobs;
-- tables to show our limitation when trying to do an m2m embed
-- with a junction table that has more than two foreign keys
create table whatev_projects (
id serial primary key,
name text
);
create table whatev_sites (
id serial primary key
, name text
);
create table whatev_jobs (
job_id uuid primary key
, name text
, site_id_1 int not null references whatev_sites (id)
, project_id_1 int not null references whatev_projects (id)
, site_id_2 int not null references whatev_sites (id)
, project_id_2 int not null references whatev_projects (id)
);
-- circular reference
create table agents (
id int primary key
, name text
, department_id int
);
create table departments (
id int primary key
, name text
, head_id int references agents(id)
);
ALTER TABLE agents
ADD CONSTRAINT agents_department_id_fkey foreign key (department_id) REFERENCES departments(id);
-- composite key disambiguation
create table schedules (
id int primary key
, name text
, start_at timetz
, end_at timetz
);
create table activities (
id int
, schedule_id int
, car_id text
, camera_id text
, primary key (id, schedule_id)
);
alter table activities
add constraint schedule foreign key (schedule_id)
references schedules (id);
create table unit_workdays (
unit_id int
, day date
, fst_shift_activity_id int
, fst_shift_schedule_id int
, snd_shift_activity_id int
, snd_shift_schedule_id int
, primary key (unit_id, day)
);
alter table unit_workdays
add constraint fst_shift foreign key (fst_shift_activity_id, fst_shift_schedule_id)
references activities (id, schedule_id),
add constraint snd_shift foreign key (snd_shift_activity_id, snd_shift_schedule_id)
references activities (id, schedule_id);
-- for a pre-request function
create or replace function custom_headers() returns void as $$
declare
user_agent text := current_setting('request.header.user-agent', true);
req_path text := current_setting('request.path', true);
req_accept text := current_setting('request.header.accept', true);
begin
if user_agent similar to 'MSIE (6.0|7.0)' then
perform set_config('response.headers',
'[{"Cache-Control": "no-cache, no-store, must-revalidate"}]', false);
elsif req_path similar to '/(items|projects)' and req_accept = 'text/csv' then
perform set_config('response.headers',
format('[{"Content-Disposition": "attachment; filename=%s.csv"}]', trim('/' from req_path)), false);
elsif req_path similar to '/(clients|rpc/getallprojects)' then
perform set_config('response.headers',
'[{"Content-Type": "application/geo+json"}]', false);
else
perform set_config('response.headers',
'[{"X-Custom-Header": "mykey=myval"}]', false);
end if;
end; $$ language plpgsql;
create table private.stuff(
id integer primary key
, name text
);
create view test.stuff as select * from private.stuff;
create or replace function location_for_stuff() returns trigger
as $$
begin
insert into private.stuff values (new.id, new.name);
if new.id is not null
then
perform set_config(
'response.headers'
, format('[{"Location": "/%s?id=eq.%s&overriden=true"}]', tg_table_name, new.id)
, true
);
end if;
return new;
end
$$ language plpgsql security definer;
create trigger location_for_stuff instead of insert on test.stuff for each row execute procedure test.location_for_stuff();
create table loc_test (
id int primary key
, c text
);
-- tables to test multi schema access in one instance
create table v1.parents (
id int primary key
, name text
);
create table v1.childs (
id serial primary key
, name text
, parent_id int
, constraint parent foreign key(parent_id)
references v1.parents(id)
);
create function v1.get_parents_below(id int)
returns setof v1.parents as $$
select * from v1.parents where id < $1;
$$ language sql;
create table v2.parents (
id int primary key
, name text
);
create table v2.childs (
id serial primary key
, name text
, parent_id int
, constraint parent foreign key(parent_id)
references v2.parents(id)
);
create table v2.another_table (
id int primary key
, another_value text
);
create function v2.get_parents_below(id int)
returns setof v2.parents as $$
select * from v2.parents where id < $1;
$$ language sql;
+14 -14
View File
@@ -7,9 +7,9 @@ ko(){ result 'not ok' "- $1"; failedTests=$(( $failedTests + 1 )); }
pgrPort=49421
pgrStopAll(){ pkill -f "$(stack path --local-install-root)/bin/postgrest"; }
pgrStopAll(){ pkill -f "$(stack path --profile --local-install-root)/bin/postgrest"; }
pgrStart(){ stack exec -- postgrest test/memory-tests/config +RTS -p -h >/dev/null & pgrPID="$!"; }
pgrStart(){ stack exec --profile -- postgrest test/memory-tests/config +RTS -p -h >/dev/null & pgrPID="$!"; }
pgrStop(){ kill "$pgrPID" 2>/dev/null; }
setUp(){ pgrStopAll; }
@@ -94,21 +94,21 @@ setUp
echo "Running memory usage tests.."
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "12M"
jsonKeyTest "1M" "POST" "/leak?columns=blob" "12M"
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "12M"
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "13M"
jsonKeyTest "1M" "POST" "/leak?columns=blob" "13M"
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "13M"
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "40M"
jsonKeyTest "10M" "POST" "/leak?columns=blob" "40M"
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "40M"
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "41M"
jsonKeyTest "10M" "POST" "/leak?columns=blob" "41M"
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "41M"
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "170M"
jsonKeyTest "50M" "POST" "/leak?columns=blob" "170M"
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "170M"
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "171M"
jsonKeyTest "50M" "POST" "/leak?columns=blob" "171M"
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "171M"
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "10M"
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "10M"
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "20M"
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "11M"
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "11M"
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "21M"
cleanUp