Compare commits

...
89 Commits
Author SHA1 Message Date
steve-chavez 1e732ac94a Bump to v5.2.0 2018-12-12 18:59:10 -05:00
steve-chavez 6b2778749f Add ability to quote column names on filters 2018-12-12 12:07:30 -05:00
steve-chavez 36f86827ee Fix #1008, Handle columns that contain spaces 2018-12-12 12:07:30 -05:00
steve-chavez d78410473e Add test for embedding on a view with group by 2018-12-12 12:07:30 -05:00
Steve ChávezandGitHub 501edc718d Add db-extra-search-path config (#1218)
For adding schemas to the search_path, solves issues related to extensions created in the public schema.
2018-12-08 11:39:31 -05:00
周丰andSteve Chávez 0d6d112b38 Read db-uri configuration from a separate file. (#1215)
* Read dburi configuration from a separate file.

* Add changelog entry.
2018-12-04 13:58:29 -05:00
Russell DaviesandSteve Chávez 473ac70789 Add support for parsing JSON Web Key Sets 2018-11-13 13:58:17 -05:00
steve-chavez dadfe965b9 Do hlint 2.1 hints 2018-11-06 11:54:10 -05:00
steve-chavez 63ead89470 Fix #1181, correct qualify of function argument type 2018-11-06 11:54:10 -05:00
steve-chavez ab23ed7999 Add CHANGELOG entry for allowing CORS on PUT 2018-10-21 13:38:34 -05:00
Kyle RussellandSteve Chávez 2da6bd6d1c Config: apply CorsResourcePolicy to PUT requests
PUT requests are part of the public upsert API, so if postgrest
runs at a different origin, these requests will fail because they
don't pass CORS.
2018-10-21 13:30:52 -05:00
steve-chavez 5bfb68b982 Fix #1180, embedding on views with composite pks
Also add CHANGELOG entries for previous fixes.
2018-10-12 09:24:22 -05:00
steve-chavez dc834572d6 Fix #1182, fix subselect view embedding for pg10 2018-10-12 09:24:22 -05:00
steve-chavez b48824bddd Fix #1065, correct test for pg10
Also add pg10 testing job on CircleCI
2018-10-12 09:24:22 -05:00
steve-chavez 6d326fe341 Update README.md 2018-10-12 09:22:58 -05:00
Roger LAMandSteve Chávez d94cf2ed72 tiny typo? 2018-10-11 09:00:27 -05:00
steve-chavez 27ca6b4e90 Pin hasql versions in cabal and stack 2018-09-28 08:57:26 -05:00
Ben GamariandSteve Chávez e0cc4d1571 Update hasql
Move to hasql 1.3.
2018-09-28 08:57:26 -05:00
steve-chavez 3cef4b70b0 Bump to v5.1.0 2018-08-31 22:09:03 -05:00
steve-chavez 6f97c34a86 Increase memory usage limit for tests
Sometimes there's more memory usage when running the tests on CircleCI
2018-08-31 22:09:03 -05:00
steve-chavez bdac90491d Add CHANGELOG and better categories to cabal file
Also update appveyor.yml
2018-08-31 22:09:03 -05:00
steve-chavez 5961f7a116 Update appveyor.yml 2018-08-31 09:09:17 -05:00
steve-chavez 17cd2725fd Pin hasql, protolude and jose versions in cabal 2018-08-31 09:09:17 -05:00
steve-chavez 5e7606134a Restrict base package version and cabal additions
There are issues with ghc 8.2 and 8.4,
see https://github.com/PostgREST/postgrest/issues/1109
2018-08-31 09:09:17 -05:00
mdr1384andSteve Chávez dfa9055c34 Close #1158, Add summary to OpenAPI doc for RPC functions (#1170) 2018-08-27 10:22:21 -05:00
steve-chavez 6907e7f979 Go back to *4 host for docker
127.0.0.1 cause issues on macos/windows
2018-08-23 13:03:12 -05:00
steve-chavez 8cf68c63d9 Fix segmentation fault/access violation on windows 10
This was reported in https://gitter.im/begriffs/postgrest?at=5b7431eb5b07ae730ac083af
and according to https://ghc.haskell.org/trac/ghc/ticket/13112#comment:25
this is an issue with linking in postgresql-libpq. The 0.9.4.1 version
contains a patch(https://github.com/lpsmith/postgresql-libpq/pull/45) that fixes the issue.
2018-08-21 11:27:44 -05:00
Steve ChávezandGitHub 30b5859b28 Fix #1141, app.settings resetting on pool timeout (#1169)
* Add test for ensuring app.settings don't reset
2018-08-20 11:02:03 -05:00
Steve ChávezandGitHub 0a1d83ce8f Fix JWTIssuedAtFuture for valid iat claim (#1166)
* Add test for ensuring "iat" works with time cache
2018-08-16 12:29:32 -05:00
steve-chavez d7511a2637 Add SIGUSR1 as another signal for schema reloading 2018-08-16 11:15:09 -05:00
steve-chavez 2066220244 Change server-host default of *4 to 127.0.0.1 2018-08-16 11:15:09 -05:00
steve-chavez 93f10adb3c Fix #905, intermittent empty replies 2018-08-09 08:59:19 -05:00
mdr1384andSteve Chávez 56bd5d5f91 Quote identifier names in RPC function arguments (#1153) 2018-07-30 09:11:09 -05:00
Alexey AlekhinandSteve Chávez 70e95649fd OpenAPI: Change GET response type to array (#1149) 2018-07-24 12:26:45 -05:00
Alexey AlekhinandSteve Chávez 2b46afe1ec OpenAPI: add required section for the non-nullable columns (#1148) 2018-07-23 11:11:45 -05:00
steve-chavez fa1e92fdf2 Add Lead Backer. Thank you Michel. 2018-07-19 11:05:46 -05:00
steve-chavez b1a8bd2391 Close #709, add test for embedding a view with CTE
Also add test for embedding a view with subselect in FROM clause
2018-07-18 09:43:42 -05:00
steve-chavez 69a76a627f Close #1145, OpenAPI materialized view columns 2018-07-18 09:43:42 -05:00
steve-chavez 105671e51a Fix #1129, view embedding on capitalized table
- Now also works on camelCase fk column
2018-07-18 09:43:42 -05:00
steve-chavez ecf0e9213f Remove array slice in allSynonyms query
This is for ensuring compatibility with pg 9.4 which doesn't support
omitting the array slice upper bound, [2:] gives an error.

The query still works fine because the array slice was to avoid having
a "({" result but this gets removed in the later join.
2018-07-18 09:43:42 -05:00
steve-chavez 1c6ded16d1 Add improved query for allSynonyms
This query works with views with subselects, and it's a lot faster,
on a complex schema the previous query was taking around 8 mins,
this query takes less than half a second(403.072 ms).

Also reorder view embedding tests
2018-07-18 09:43:42 -05:00
steve-chavez 6fc9d5191a Move create/destroy db scripts to bash
There was a "[[: not found" error with sh
2018-06-22 10:48:54 -05:00
steve-chavez 80f09780cc Add logo and db uri example to heroku button 2018-06-21 11:04:31 -05:00
steve-chavez 9e3454129f Add tests for jsonb equality filtering 2018-06-19 11:17:59 -05:00
steve-chavez d34afe861a Allow finishing a json path with single arrow ->
Now a json can be obtained without resorting to casting

- Previously: `/json_arr?select=data->>2::json`
- Now: `/json_arr?select=data->2`
2018-06-19 11:17:59 -05:00
steve-chavez 30dfadec7b Add support for getting json by array index
* Also support json negative array index
2018-06-19 11:17:59 -05:00
steve-chavez 2513c00039 Refactor Field type,move tests to JsonOperatorSpec 2018-06-19 11:17:59 -05:00
steve-chavez 100bf494ac Move database creation/destruction scripts to sh
Bash is not available on /bin/bash on some linux distros like NixOS
2018-06-19 11:17:59 -05:00
steve-chavez e8188b0d41 Add Lead Backer. Thank you Daniel. 2018-06-18 11:49:15 -05:00
steve-chavez 3958ebbb05 Add CHANGELOG note about stored procedures 2018-05-31 11:57:42 -05:00
steve-chavez 37e7398a85 Fix UPSERT bug on camelCase PK column 2018-05-31 11:57:42 -05:00
Felix YanandSteve Chávez 9ea7529f30 Use protolude 0.2.2, replace undefined with witness
protolude provides headMay since 0.2.2, it can be used instead of safe.
2018-05-28 13:08:27 -05:00
ilovezfsandSteve Chávez 384767708b postgrest.cabal: fix constraints on protolude and hasql-transaction 2018-05-27 08:54:46 -05:00
steve-chavez 6bcbb124d2 Add PGRST_ROLE_CLAIM_KEY to ENV 2018-05-25 10:27:09 -05:00
DominikandSteve Chávez f6c1ff810e Add role-claim-key to docker postgrest.conf
With #1091, the role-claim-key was added, but it is not configurable in docker containers as the template config doesn't allow it.
This patch adds the necessary line to the postgrest.conf of the docker container.
2018-05-25 09:26:44 -05:00
steve-chavez f80cfbf165 Add BACKERS.md 2018-05-21 13:11:24 -05:00
steve-chavez d8896be2c1 Add more details to issue template 2018-05-16 09:25:24 -05:00
steve-chavez 903a8d5f5a Bump to v0.5.0.0 2018-05-14 19:08:51 -05:00
steve-chavez ca76a8e6be Update links, add patreon badge 2018-05-14 18:44:52 -05:00
steve-chavez 28845e0f43 Fix accepting misspellings in order syntax 2018-05-14 07:34:15 -05:00
steve-chavez 30cf1d100a Add foreign tables to OpenAPI output 2018-05-07 10:14:26 -05:00
Joe NelsonandGitHub 05180f6539 Remove consulting prompt in issue template 2018-05-04 16:55:07 -05:00
steve-chavez b00f57ac34 Fix appveyor build
Appveyor failed on latest release https://ci.appveyor.com/project/begriffs/postgrest/build/1.0.5
according to https://github.com/commercialhaskell/stack/issues/2617#issuecomment-271966495
this can be fixed by adding `-j1`.
2018-05-02 13:06:44 -05:00
steve-chavez e9aaf05335 Remove support for non url safe operators 2018-05-02 13:06:44 -05:00
steve-chavez 79a7ce49f2 Remove support for {} embed and "in" op w/o parens 2018-05-02 13:06:44 -05:00
steve-chavez 3a1213f53b Add CHANGELOG entry for role-claim-key 2018-04-30 11:38:56 -05:00
steve-chavez f033c2c4b5 Add role-claim-key config value 2018-04-30 11:31:06 -05:00
steve-chavez 5c87fe2704 Add getCurrentTime cache for jwt validation 2018-04-30 11:31:06 -05:00
Joe Nelson 58f4b4bc33 Issue template 2018-04-12 21:28:11 -05:00
steve-chavez 32c7e32bdf Allow embeds alias to be used in filters, fix #821 2018-04-02 11:09:45 -05:00
steve-chavez 062a5581f5 Allow specifying source column in embed, fix #1078 2018-04-02 11:09:45 -05:00
steve-chavez 50512e1117 Refactor addRelations and findRelation functions
Just reordering and renaming variables to reduce indentation, logic remains the same.
2018-04-02 11:09:45 -05:00
steve-chavez edae60f8c1 Only alias tables on self join cases 2018-04-02 11:09:45 -05:00
steve-chavez 243e692192 Refactor some functions to use concatMap 2018-03-17 07:53:41 -05:00
steve-chavez 349a5ae076 Fix Parent Relation when having many views(#1044) 2018-03-17 07:53:41 -05:00
steve-chavez ff709a65e5 Add FROM targets aliasing to avoid conflict in embeds 2018-02-21 09:23:08 -05:00
steve-chavez 8e2a0e05ea Refactor QueryBuilder and DbRequestBuilder
- Refactor ORDER BY and LIMIT query fragments
- Move ReadRequest building to DbRequestBuilder
- Clarify Relation link table attributes
- Change Join Operation to JoinCond
2018-02-21 09:23:08 -05:00
steve-chavez 108f3cd651 Add Preference-Applied header for POST upsert
- Ensure creating nothing on ignore-duplicates succeeds
- Refactor locationF query
2018-02-21 07:33:54 -05:00
steve-chavez 6675821c64 Allow PUT method: UPSERT of a single row 2018-02-21 07:33:54 -05:00
steve-chavez 85b1dc0eb4 Add UPSERT for POST with Prefer:resoultion=merge/ignore-duplicates 2018-02-21 07:33:54 -05:00
Joe Nelson 102392e4ab Move canadaduane's changelog entry to Added 2018-02-19 21:55:03 -06:00
Duane JohnsonandJoe Nelson a46b6f5020 Pass custom settings to the DB's SESSION (#1063)
- allows queries to refer to current_setting('app.settings.foo') to retrieve variables
- useful for 12-factor apps (app data can be in environment)
- provides workaround for AWS Relational Database Service (RDS) not
  allowing `ALTER DATABASE SET 'app.[KEY]' TO '[VALUE]'` on database.
2018-02-19 12:41:22 -06:00
steve-chavez 70ce1b9329 Reduce memory usage of uniform json array restriction 2018-01-27 13:55:20 -05:00
steve-chavez 516976e32f Add memory usage tests 2018-01-10 11:43:16 -05:00
steve-chavez f7e7834a1c Handle overloaded function case
* Add test for params=single-object on GET

* Add tests for procs with DEFAULT args

* Add tests for overloaded functions

* Add test for PATCHing with an empty json array, this previously
  gave a "Something is wrong" error
2018-01-10 11:43:16 -05:00
steve-chavez 38f3bcf4a6 Reduce memory usage by avoiding Aeson encode
It was detected that Aeson encoding had high memory usage when
the json payload was large, around x60 the payload size.
With this change we get around x10 payload size memory usage.

The encodeUtf8(when doing a Text -> ByteString with `toS`) function
on a large payload also contributed to the high memory usage.

Main idea to reduce the memory usage was to let the ByteString coming
from the request body go to the database unchanged.
2018-01-10 11:43:16 -05:00
steve-chavez f159233de8 Change UPDATE query to a parametrized query 2018-01-10 11:43:16 -05:00
steve-chavez 02a286a4b1 Change callProc query to a parametrized query 2018-01-10 11:43:16 -05:00
steve-chavez 85d9feeeab Add set schema to middleware
Fix #828, breaking change: computed columns now only work if they are on
the config schema.

Fix #835, tests now not depend on the search_path of the
postgrest_test_authenticator.
2018-01-10 09:50:20 -05:00
66 changed files with 3433 additions and 1332 deletions
+98 -6
View File
@@ -52,7 +52,7 @@ build-distro-bin: &build-distro-bin
key: v1-{{ .Environment.CIRCLE_JOB }}-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }} key: v1-{{ .Environment.CIRCLE_JOB }}-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
jobs: jobs:
build-test: build-test-9.4:
docker: docker:
- image: circleci/buildpack-deps:trusty - image: circleci/buildpack-deps:trusty
environment: environment:
@@ -138,6 +138,82 @@ jobs:
name: run tests name: run tests
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
build-test-10:
docker:
- image: circleci/buildpack-deps:trusty
environment:
- PGHOST=localhost
- image: circleci/postgres:10.5
environment:
- POSTGRES_USER=circleci
- POSTGRES_DB=circleci
steps:
- checkout
- restore_cache:
keys:
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
- run:
name: install stack & dependencies
command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.1.2/stack-1.1.2-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.1.2-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 src and tests
command: |
stack build --fast -j1
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
build-prof-test:
docker:
- image: circleci/buildpack-deps:trusty
environment:
- PGHOST=localhost
- TERM=xterm
- image: circleci/postgres:9.6.2
environment:
- POSTGRES_USER=circleci
- POSTGRES_DB=circleci
steps:
- checkout
- restore_cache:
keys:
- v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
- run:
name: install stack & dependencies
command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.1.2/stack-1.1.2-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.1.2-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 with profiling enabled
command: |
stack build --profile -j1
- run:
name: run memory usage tests
command: |
test/create_test_db "postgres://circleci@localhost" postgrest_test
psql "postgres:///postgrest_test" -f test/fixtures/database.sql
psql "postgres:///postgrest_test" -f test/fixtures/roles.sql
psql "postgres:///postgrest_test" -f test/fixtures/schema.sql
psql "postgres:///postgrest_test" -f test/fixtures/jwt.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: centos6:
<<: *build-distro-bin <<: *build-distro-bin
@@ -180,7 +256,7 @@ workflows:
version: 2 version: 2
build-test-release: build-test-release:
jobs: jobs:
- build-test: - build-test-9.4:
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
@@ -188,10 +264,20 @@ workflows:
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
- build-test-10:
filters:
tags:
only: /v[0-9]+(\.[0-9]+)*/
- build-prof-test:
filters:
tags:
only: /v[0-9]+(\.[0-9]+)*/
- centos6: - centos6:
requires: requires:
- build-test - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10
- build-prof-test
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
@@ -199,8 +285,10 @@ workflows:
ignore: /.*/ ignore: /.*/
- centos7: - centos7:
requires: requires:
- build-test - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10
- build-prof-test
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
@@ -208,8 +296,10 @@ workflows:
ignore: /.*/ ignore: /.*/
- ubuntu: - ubuntu:
requires: requires:
- build-test - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10
- build-prof-test
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
@@ -217,8 +307,10 @@ workflows:
ignore: /.*/ ignore: /.*/
- ubuntui386: - ubuntui386:
requires: requires:
- build-test - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10
- build-prof-test
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
+1 -12
View File
@@ -51,18 +51,7 @@ your contributions.
source](https://postgrest.com/en/stable/install.html#build-from-source) source](https://postgrest.com/en/stable/install.html#build-from-source)
wiki page. wiki page.
## Maintenance ### Running Tests
### Schedule
Currently I (@begriffs) am the sole maintainer, and while I am
overjoyed to help resolve issues I also have to balance this with
my other obligations. If you don't get a response right away
don't worry, I will definitely get to it. Also you can join the
Gitter [chat room](https://gitter.im/begriffs/postgrest) to
discuss issues you are having.
## Running Tests
For instructions on running tests, see the official docs hosted here: For instructions on running tests, see the official docs hosted here:
+11
View File
@@ -0,0 +1,11 @@
### Environment
* PostgreSQL version: (if using docker, specify the image)
* PostgREST version: (if using docker, specify the image)
* Operating system:
### Description of issue
(Expected behavior vs actual behavior)
(Steps to reproduce: Include a minimal SQL definition plus how you make the request to PostgREST and the response body)
+14
View File
@@ -0,0 +1,14 @@
# Sponsors & Backers
PostgREST ongoing development is only possible thanks to our Sponsors and Backers, listed below. If you'd like to join them, you can do so by supporting the PostgREST organization on [Patreon](https://www.patreon.com/postgrest).
## Lead Backers
- [Christiaan Westerbeek](https://devotis.nl)
- [Daniel Babiak](https://github.com/d-babiak)
- [Michel Pelletier](https://github.com/michelp/)
## Backers
- Tsingson Qin
- Jay Hannah
+79
View File
@@ -9,6 +9,85 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed ### Fixed
## [5.2.0] - 2018-12-12
### Added
- #1205, Add support for parsing JSON Web Key Sets - @russelldavies
- #1203, Add support for reading db-uri from a separate file - @zhoufeng1989
- #1200, Add db-extra-search-path config for adding schemas to the search_path, solves issues related to extensions created on the public schema - @steve-chavez
- #1219, Add ability to quote column names on filters - @steve-chavez
### Fixed
- #1182, Fix embedding on views with composite pks - @steve-chavez
- #1180, Fix embedding on views with subselects in pg10 - @steve-chavez
- #1197, Allow CORS for PUT - @bkylerussell
- #1181, Correctly qualify function argument of custom type in public schema - @steve-chavez
- #1008, Allow columns that contain spaces in filters - @steve-chavez
## [5.1.0] - 2018-08-31
### Added
- #1099, Add support for getting json/jsonb by array index - @steve-chavez
- #1145, Add materialized view columns to OpenAPI output - @steve-chavez
- #709, Allow embedding on views with subselects/CTE - @steve-chavez
- #1148, OpenAPI: add `required` section for the non-nullable columns - @laughedelic
- #1158, Add summary to OpenAPI doc for RPC functions - @mdr1384
### Fixed
- #1113, Fix UPSERT failing when having a camel case PK column - @steve-chavez
- #945, Fix slow start-up time on big schemas - @steve-chavez
- #1129, Fix view embedding when table is capitalized - @steve-chavez
- #1149, OpenAPI: Change `GET` response type to array - @laughedelic
- #1152, Fix RPC failing when having arguments with reserved or uppercase keywords - @mdr1384
- #905, Fix intermittent empty replies - @steve-chavez
- #1139, Fix JWTIssuedAtFuture failure for valid iat claim - @steve-chavez
- #1141, Fix app.settings resetting on pool timeout - @steve-chavez
### Changed
- #1099, Numbers in json path `?select=data->1->>key` now get treated as json array indexes instead of keys - @steve-chavez
- #1128, Allow finishing a json path with a single arrow `->`. Now a json can be obtained without resorting to casting, Previously: `/json_arr?select=data->>2::json`, now: `/json_arr?select=data->2` - @steve-chavez
- #724, Change server-host default of *4 to 127.0.0.1
### Deprecated
- #724, SIGHUP deprecated, SIGUSR1 should be used instead
## [0.5.0.0] - 2018-05-14
### Added
- The configuration (e.g. `postgrest.conf`) now accepts arbitrary settings that will be passed through as session-local database settings. This can be used to pass in secret keys directly as strings, or via OS environment variables. For instance: `app.settings.jwt_secret = "$(MYAPP_JWT_SECRET)"` will take `MYAPP_JWT_SECRET` from the environment and make it available to postgresql functions as `current_setting('app.settings.jwt_secret')`. Only `app.settings.*` values in the configuration file are treated in this way. - @canadaduane
- #256, Add support for bulk UPSERT with POST and single UPSERT with PUT - @steve-chavez
- #1078, Add ability to specify source column in embed - @steve-chavez
- #821, Allow embeds alias to be used in filters - @steve-chavez
- #906, Add jspath configurable `role-claim-key` - @steve-chavez
- #1061, Add foreign tables to OpenAPI output - @rhyamada
### Fixed
- #828, Fix computed column only working in public schema - @steve-chavez
- #925, Fix RPC high memory usage by using parametrized query and avoiding json encoding - @steve-chavez
- #987, Fix embedding with self-reference foreign key - @steve-chavez
- #1044, Fix view parent embedding when having many views - @steve-chavez
- #781, Fix accepting misspelled desc/asc ordering modificators - @onporat, @steve-chavez
### Changed
- #828, A `SET SCHEMA <db-schema>` is done on each request, this has the following implications:
- Computed columns now only work if they belong to the db-schema
- Stored procedures might require a `search_path` to work properly, for further details see https://postgrest.org/en/v5.0/api.html#explicit-qualification
- To use RPC now the `json_to_record/json_to_recordset` functions are needed, these are available starting from PostgreSQL 9.4 - @steve-chavez
- Overloaded functions now depend on the `dbStructure`, restart/sighup may be needed for their correct functioning - @steve-chavez
- #1098, Removed support for:
+ curly braces `{}` in embeds, i.e. `/clients?select=*,projects{*}` can no longer be used, from now on parens `()` should be used `/clients?select=*,projects(*)` - @steve-chavez
+ "in" operator without parens, i.e. `/clients?id=in.1,2,3` no longer supported, `/clients?id=in.(1,2,3)` should be used - @steve-chavez
+ "@@", "@>" and "<@" operators, from now on their mnemonic equivalents should be used "fts", "cs" and "cd" respectively - @steve-chavez
## [0.4.4.0] - 2018-01-08 ## [0.4.4.0] - 2018-01-08
### Added ### Added
+31 -21
View File
@@ -1,20 +1,23 @@
![Logo](static/logo.png "Logo") ![Logo](static/logo.png "Logo")
[![Build Status](https://circleci.com/gh/begriffs/postgrest.png?style=shield&circle-token=f723c01686abf0364de1e2eaae5aff1f68bd3ff2)](https://circleci.com/gh/begriffs/postgrest/tree/master) [![Donate](https://img.shields.io/badge/Donate-Patreon-orange.svg?colorB=F96854)](https://www.patreon.com/postgrest)
<a href="https://heroku.com/deploy?template=https://github.com/begriffs/postgrest"> [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/postgrest)
<a href="https://heroku.com/deploy?template=https://github.com/PostgREST/postgrest">
<img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy"> <img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy">
</a> </a>
[![Join the chat at https://gitter.im/begriffs/postgrest](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/begriffs/postgrest) [![Join the chat at https://gitter.im/begriffs/postgrest](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/begriffs/postgrest)
[![Docs](https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat)](http://postgrest.com) [![Docs](https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat)](http://postgrest.org)
[![Docker Stars](https://img.shields.io/docker/pulls/postgrest/postgrest.svg)](https://hub.docker.com/r/postgrest/postgrest/) [![Docker Stars](https://img.shields.io/docker/pulls/postgrest/postgrest.svg)](https://hub.docker.com/r/postgrest/postgrest/)
[![Build Status](https://circleci.com/gh/PostgREST/postgrest/tree/master.svg?style=shield)](https://circleci.com/gh/PostgREST/postgrest/tree/master)
[![Hackage docs](https://img.shields.io/hackage/v/postgrest.svg?label=hackage)](http://hackage.haskell.org/package/postgrest)
PostgREST serves a fully RESTful API from any existing PostgreSQL PostgREST serves a fully RESTful API from any existing PostgreSQL
database. It provides a cleaner, more standards-compliant, faster database. It provides a cleaner, more standards-compliant, faster
API than you are likely to write from scratch. API than you are likely to write from scratch.
### Usage ## Usage
1. Download the binary ([latest release](https://github.com/begriffs/postgrest/releases/latest)) 1. Download the binary ([latest release](https://github.com/PostgREST/postgrest/releases/latest))
for your platform. for your platform.
2. Invoke for help: 2. Invoke for help:
@@ -22,7 +25,7 @@ API than you are likely to write from scratch.
postgrest --help postgrest --help
``` ```
### Performance ## Performance
TLDR; subsecond response times for up to 2000 requests/sec on Heroku TLDR; subsecond response times for up to 2000 requests/sec on Heroku
free tier. If you're used to servers written in interpreted languages free tier. If you're used to servers written in interpreted languages
@@ -50,13 +53,10 @@ by
* Using the PostgreSQL binary protocol * Using the PostgreSQL binary protocol
* Being stateless to allow horizontal scaling * Being stateless to allow horizontal scaling
Other optimizations are possible, and some are outlined in the ## Security
[Future Features](#future-features).
### Security
PostgREST [handles PostgREST [handles
authentication](http://postgrest.com/en/stable/auth.html) (via JSON Web authentication](http://postgrest.org/en/stable/auth.html) (via JSON Web
Tokens) and delegates authorization to the role information defined in Tokens) and delegates authorization to the role information defined in
the database. This ensures there is a single declarative source of truth the database. This ensures there is a single declarative source of truth
for security. When dealing with the database the server assumes the for security. When dealing with the database the server assumes the
@@ -74,7 +74,7 @@ are limited to certain templates using
functions, the trigger workaround does not compromise row-level functions, the trigger workaround does not compromise row-level
security. security.
### Versioning ## Versioning
A robust long-lived API needs the freedom to exist in multiple A robust long-lived API needs the freedom to exist in multiple
versions. PostgREST does versioning through database schemas. This versions. PostgREST does versioning through database schemas. This
@@ -82,7 +82,7 @@ allows you to expose tables and views without making the app brittle.
Underlying tables can be superseded and hidden behind public facing Underlying tables can be superseded and hidden behind public facing
views. views.
### Self-documentation ## Self-documentation
PostgREST uses the [OpenAPI](https://openapis.org/) standard to PostgREST uses the [OpenAPI](https://openapis.org/) standard to
generate up-to-date documentation for APIs. You can use a tool like generate up-to-date documentation for APIs. You can use a tool like
@@ -94,7 +94,7 @@ instance the number of rows returned by an endpoint is reported by -
and limited with - range headers. More about and limited with - range headers. More about
[that](http://begriffs.com/posts/2014-03-06-beyond-http-header-links.html). [that](http://begriffs.com/posts/2014-03-06-beyond-http-header-links.html).
### Data Integrity ## Data Integrity
Rather than relying on an Object Relational Mapper and custom Rather than relying on an Object Relational Mapper and custom
imperative coding, this system requires you put declarative constraints imperative coding, this system requires you put declarative constraints
@@ -106,14 +106,24 @@ surprises, such as enforcing idempotent PUT requests.
See examples of [PostgreSQL See examples of [PostgreSQL
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm) constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
and the [API guide](http://postgrest.com/en/stable/api.html). and the [API guide](http://postgrest.org/en/stable/api.html).
### Thanks ## Supporting development
I'm grateful to the generous project You can help PostgREST ongoing maintenance and development by:
[contributors](https://github.com/begriffs/postgrest/graphs/contributors)
who have improved PostgREST immensely with their code and good - Making a regular donation through Patreon https://www.patreon.com/postgrest
judgement. See more details in the
[changelog](https://github.com/begriffs/postgrest/blob/master/CHANGELOG.md). - Alternatively, you can make a one-time donation via Paypal https://www.paypal.me/postgrest
Every donation will be spent on making PostgREST better for the whole community.
## Thanks
The PostgREST organization is grateful to:
- The project [sponsors and backers](https://github.com/PostgREST/postgrest/blob/master/BACKERS.md) who support PostgREST's development.
- The project [contributors](https://github.com/PostgREST/postgrest/graphs/contributors) who have improved PostgREST immensely with their code
and good judgement. See more details in the [changelog](https://github.com/PostgREST/postgrest/blob/master/CHANGELOG.md).
The cool logo came from [Mikey Casalaina](https://github.com/casalaina). The cool logo came from [Mikey Casalaina](https://github.com/casalaina).
+5 -5
View File
@@ -1,19 +1,19 @@
{ {
"name": "PostgREST", "name": "PostgREST",
"description": "RESTful API for any PostgreSQL database.", "description": "RESTful API for any PostgreSQL database.",
"logo": "https://halcyon.sh/logo.svg", "logo": "https://avatars2.githubusercontent.com/u/15115011",
"repository": "https://github.com/begriffs/postgrest", "repository": "https://github.com/PostgREST/postgrest",
"env": { "env": {
"BUILDPACK_URL": { "BUILDPACK_URL": {
"description": "Heroku buildpack for deploying Haskell applications", "description": "Heroku buildpack for deploying Haskell applications",
"value": "https://github.com/begriffs/postgrest-heroku" "value": "https://github.com/PostgREST/postgrest-heroku"
}, },
"POSTGREST_VER": { "POSTGREST_VER": {
"description": "Version of PostgREST to deploy", "description": "Version of PostgREST to deploy",
"value": "0.4.4.0" "value": "5.2.0"
}, },
"DB_URI": { "DB_URI": {
"description": "Database connection string", "description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
"required": true "required": true
}, },
"DB_SCHEMA": { "DB_SCHEMA": {
+1 -1
View File
@@ -28,7 +28,7 @@ install:
build_script: build_script:
- stack setup --no-terminal > nul - stack setup --no-terminal > nul
- stack build --copy-bins --local-bin-path . - stack build -j1 --copy-bins --local-bin-path .
artifacts: artifacts:
- path: postgrest.exe - path: postgrest.exe
+3 -2
View File
@@ -13,7 +13,7 @@ RUN BUILD_DEPS="curl ca-certificates xz-utils" && \
apt-get -qq update && \ apt-get -qq update && \
apt-get -qq install -y --no-install-recommends $BUILD_DEPS && \ apt-get -qq install -y --no-install-recommends $BUILD_DEPS && \
cd /tmp && \ cd /tmp && \
curl -SLO https://github.com/begriffs/postgrest/releases/download/${POSTGREST_VERSION}/postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \ curl -SLO https://github.com/PostgREST/postgrest/releases/download/${POSTGREST_VERSION}/postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
tar -xJvf postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \ tar -xJvf postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
mv postgrest /usr/local/bin/postgrest && \ mv postgrest /usr/local/bin/postgrest && \
cd / && \ cd / && \
@@ -35,7 +35,8 @@ ENV PGRST_DB_URI= \
PGRST_SECRET_IS_BASE64=false \ PGRST_SECRET_IS_BASE64=false \
PGRST_JWT_AUD= \ PGRST_JWT_AUD= \
PGRST_MAX_ROWS= \ PGRST_MAX_ROWS= \
PGRST_PRE_REQUEST= PGRST_PRE_REQUEST= \
PGRST_ROLE_CLAIM_KEY=".role"
# PostgREST reads /etc/postgrest.conf so map the configuration # PostgREST reads /etc/postgrest.conf so map the configuration
# file in when you run this container # file in when you run this container
+1
View File
@@ -10,6 +10,7 @@ server-proxy-uri = "$(PGRST_SERVER_PROXY_URI)"
jwt-secret = "$(PGRST_JWT_SECRET)" jwt-secret = "$(PGRST_JWT_SECRET)"
secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)" secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)"
jwt-aud = "$(PGRST_JWT_AUD)" jwt-aud = "$(PGRST_JWT_AUD)"
role-claim-key = "$(PGRST_ROLE_CLAIM_KEY)"
max-rows = "$(PGRST_MAX_ROWS)" max-rows = "$(PGRST_MAX_ROWS)"
pre-request = "$(PGRST_PRE_REQUEST)" pre-request = "$(PGRST_PRE_REQUEST)"
+72 -44
View File
@@ -3,35 +3,39 @@
module Main where module Main where
import PostgREST.App (postgrest) import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..), import PostgREST.Config (AppConfig (..),
minimumPgVersion, prettyVersion, readOptions)
prettyVersion, readOptions) import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.DbStructure (getDbStructure, getPgVersion) import PostgREST.Error (encodeError)
import PostgREST.Error (encodeError) import PostgREST.OpenAPI (isMalformedProxyUri)
import PostgREST.OpenAPI (isMalformedProxyUri) import PostgREST.Types (DbStructure, Schema, PgVersion(..), minimumPgVersion)
import PostgREST.Types (DbStructure, Schema, PgVersion(..)) import Protolude hiding (hPutStrLn, replace)
import Protolude hiding (hPutStrLn, replace)
import Control.AutoUpdate (defaultUpdateSettings,
mkAutoUpdate, updateAction)
import Control.Retry (RetryStatus, capDelay,
exponentialBackoff,
retrying, rsPreviousDelay)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64
import Data.IORef (IORef, atomicWriteIORef,
newIORef, readIORef)
import Data.String (IsString (..))
import Data.Text (pack, replace, stripPrefix, strip)
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.IO (hPutStrLn, readFile)
import Data.Time.Clock (getCurrentTime)
import qualified Hasql.Pool as P
import qualified Hasql.Session as H
import qualified Hasql.Transaction.Sessions as HT
import Network.Wai.Handler.Warp (defaultSettings,
runSettings, setHost,
setPort, setServerName)
import System.IO (BufferMode (..),
hSetBuffering)
import Control.Retry (RetryStatus, capDelay,
exponentialBackoff,
retrying, rsPreviousDelay)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64
import Data.IORef (IORef, atomicWriteIORef,
newIORef, readIORef)
import Data.String (IsString (..))
import Data.Text (pack, replace, stripPrefix, strip)
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.IO (hPutStrLn)
import qualified Hasql.Pool as P
import qualified Hasql.Session as H
import Network.Wai.Handler.Warp (defaultSettings,
runSettings, setHost,
setPort, setServerName,
setTimeout)
import System.IO (BufferMode (..),
hSetBuffering)
#ifndef mingw32_HOST_OS #ifndef mingw32_HOST_OS
import System.Posix.Signals import System.Posix.Signals
#endif #endif
@@ -79,7 +83,7 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
("Cannot run in this PostgreSQL version, PostgREST needs at least " ("Cannot run in this PostgreSQL version, PostgREST needs at least "
<> pgvName minimumPgVersion) <> pgvName minimumPgVersion)
killThread mainTid killThread mainTid
dbStructure <- getDbStructure schema actualPgVersion dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
case result of case result of
Left e -> do Left e -> do
@@ -135,23 +139,27 @@ main = do
-- --
-- readOptions builds the 'AppConfig' from the config file specified on the -- readOptions builds the 'AppConfig' from the config file specified on the
-- command line -- command line
conf <- loadSecretFile =<< readOptions conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
let host = configHost conf let host = configHost conf
port = configPort conf port = configPort conf
proxy = configProxyUri conf proxy = configProxyUri conf
pgSettings = toS (configDatabase conf) -- is the db-uri pgSettings = toS (configDatabase conf) -- is the db-uri
roleClaimKey = configRoleClaimKey conf
appSettings = appSettings =
setHost ((fromString . toS) host) -- Warp settings setHost ((fromString . toS) host) -- Warp settings
. setPort port . setPort port
. setServerName (toS $ "postgrest/" <> prettyVersion) . setServerName (toS $ "postgrest/" <> prettyVersion) $
. setTimeout 3600 $
defaultSettings defaultSettings
--
-- Checks that the provided proxy uri is formated correctly, -- Checks that the provided proxy uri is formated correctly
-- does not test if it works here.
when (isMalformedProxyUri $ toS <$> proxy) $ when (isMalformedProxyUri $ toS <$> proxy) $
panic panic
"Malformed proxy uri, a correct example: https://example.com:8443/basePath" "Malformed proxy uri, a correct example: https://example.com:8443/basePath"
-- Checks that the provided jspath is valid
when (isLeft roleClaimKey) $
panic $ show roleClaimKey
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf) putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
-- --
-- create connection pool with the provided settings, returns either -- create connection pool with the provided settings, returns either
@@ -190,23 +198,28 @@ main = do
throwTo mainTid UserInterrupt throwTo mainTid UserInterrupt
) Nothing ) Nothing
void $ installHandler sigHUP ( forM_ [sigHUP, sigUSR1] $ \sig ->
Catch $ connectionWorker void $ installHandler sig (
mainTid Catch $ connectionWorker
pool mainTid
(configSchema conf) pool
refDbStructure (configSchema conf)
refIsWorkerOn refDbStructure
) Nothing refIsWorkerOn
) Nothing
#endif #endif
--
-- ask for the OS time at most once per second
getTime <- mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}
-- run the postgrest application -- run the postgrest application
runSettings appSettings $ runSettings appSettings $
postgrest postgrest
conf conf
refDbStructure refDbStructure
pool pool
getTime
(connectionWorker (connectionWorker
mainTid mainTid
pool pool
@@ -265,3 +278,18 @@ loadSecretFile conf = extractAndTransform mSecret
-- replace: Replace every occurrence of one substring with another -- replace: Replace every occurrence of one substring with another
replaceUrlChars = replaceUrlChars =
replace "_" "/" . replace "-" "+" . replace "." "=" replace "_" "/" . replace "-" "+" . replace "." "="
{-
Load database uri from a separate file if `db-uri` is a filepath.
-}
loadDbUriFile :: AppConfig -> IO AppConfig
loadDbUriFile conf = extractDbUri mDbUri
where
mDbUri = configDatabase conf
extractDbUri :: Text -> IO AppConfig
extractDbUri dbUri =
fmap setDbUri $
case stripPrefix "@" dbUri of
Nothing -> return dbUri
Just filename -> strip <$> readFile (toS filename)
setDbUri dbUri = conf {configDatabase = dbUri}
+33 -20
View File
@@ -2,19 +2,21 @@ name: postgrest
description: Reads the schema of a PostgreSQL database and creates RESTful routes description: Reads the schema of a PostgreSQL database and creates RESTful routes
for the tables and views, supporting all HTTP verbs that security for the tables and views, supporting all HTTP verbs that security
permits. permits.
version: 0.4.4.0 version: 5.2.0
synopsis: REST API for any Postgres database synopsis: REST API for any Postgres database
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
author: Joe Nelson, Adam Baker author: Joe Nelson, Adam Baker
homepage: https://github.com/begriffs/postgrest homepage: https://postgrest.org
maintainer: cred+github@begriffs.com maintainer: Steve Chávez <stevechavezast@gmail.com>
category: Web bug-reports: https://github.com/PostgREST/postgrest/issues
category: Executable, PostgreSQL, Network APIs
extra-source-files: CHANGELOG.md
build-type: Simple build-type: Simple
cabal-version: >=1.10 cabal-version: >=1.10
source-repository head source-repository head
type: git type: git
location: git://github.com/begriffs/postgrest.git location: git://github.com/PostgREST/postgrest.git
Flag CI Flag CI
Description: No warnings allowed in continuous integration Description: No warnings allowed in continuous integration
@@ -29,12 +31,15 @@ executable postgrest
-rtsopts -rtsopts
"-with-rtsopts=-N -I2" "-with-rtsopts=-N -I2"
default-language: Haskell2010 default-language: Haskell2010
build-depends: base build-depends: auto-update
, hasql , base >= 4.8 && < 4.10
, hasql-pool , hasql >= 1.3 && < 1.4
, hasql-pool >= 0.5 && < 0.6
, hasql-transaction >= 0.7 && < 0.8
, postgrest , postgrest
, protolude , protolude == 0.2.2
, text , text
, time
, warp , warp
, bytestring , bytestring
, base64-bytestring , base64-bytestring
@@ -49,7 +54,7 @@ library
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude
build-depends: aeson build-depends: aeson
, ansi-wl-pprint , ansi-wl-pprint
, base >= 4.8 && < 6 , base >= 4.8 && < 4.10
, base64-bytestring , base64-bytestring
, bytestring , bytestring
, case-insensitive , case-insensitive
@@ -57,29 +62,30 @@ library
, configurator-ng == 0.0.0.1 , configurator-ng == 0.0.0.1
, containers , containers
, contravariant , contravariant
, contravariant-extras
, either , either
, gitrev , gitrev
, hasql , hasql >= 1.3 && < 1.4
, hasql-pool == 0.4.1 , hasql-pool >= 0.5 && < 0.6
, hasql-transaction == 0.5 , hasql-transaction >= 0.7 && < 0.8
, heredoc , heredoc
, HTTP , HTTP
, http-types , http-types
, insert-ordered-containers , insert-ordered-containers
, interpolatedstring-perl6 , interpolatedstring-perl6
, jose >= 0.6 , jose == 0.7.0.0
, lens , lens
, lens-aeson , lens-aeson
, network-uri , network-uri
, optparse-applicative >= 0.13 && < 0.15 , optparse-applicative >= 0.13 && < 0.15
, parsec , parsec
, protolude >= 0.2 , protolude == 0.2.2
, Ranged-sets == 0.3.0 , Ranged-sets == 0.3.0
, regex-tdfa , regex-tdfa
, safe
, scientific , scientific
, swagger2 , swagger2
, text , text
, time
, unordered-containers , unordered-containers
, vector , vector
, wai , wai
@@ -118,8 +124,11 @@ Test-Suite spec
, Feature.ConcurrentSpec , Feature.ConcurrentSpec
, Feature.CorsSpec , Feature.CorsSpec
, Feature.DeleteSpec , Feature.DeleteSpec
, Feature.ExtraSearchPathSpec
, Feature.InsertSpec , Feature.InsertSpec
, Feature.JsonOperatorSpec
, Feature.NoJwtSpec , Feature.NoJwtSpec
, Feature.PgVersion95Spec
, Feature.PgVersion96Spec , Feature.PgVersion96Spec
, Feature.ProxySpec , Feature.ProxySpec
, Feature.QueryLimitedSpec , Feature.QueryLimitedSpec
@@ -131,20 +140,23 @@ Test-Suite spec
, Feature.AndOrParamsSpec , Feature.AndOrParamsSpec
, Feature.RpcSpec , Feature.RpcSpec
, Feature.NonexistentSchemaSpec , Feature.NonexistentSchemaSpec
, Feature.UpsertSpec
, SpecHelper , SpecHelper
, TestTypes , TestTypes
Build-Depends: aeson Build-Depends: aeson
, aeson-qq , aeson-qq
, async , async
, base , auto-update
, base >= 4.8 && < 4.10
, bytestring , bytestring
, base64-bytestring , base64-bytestring
, case-insensitive , case-insensitive
, cassava , cassava
, containers , containers
, contravariant , contravariant
, hasql , hasql >= 1.3 && < 1.4
, hasql-pool , hasql-pool >= 0.5 && < 0.6
, hasql-transaction >= 0.7 && < 0.8
, heredoc , heredoc
, hjsonschema == 1.5.0.1 , hjsonschema == 1.5.0.1
, hspec , hspec
@@ -156,8 +168,9 @@ Test-Suite spec
, monad-control , monad-control
, postgrest , postgrest
, process , process
, protolude , protolude == 0.2.2
, regex-tdfa , regex-tdfa
, time
, transformers-base , transformers-base
, wai , wai
, wai-extra , wai-extra
+58 -61
View File
@@ -1,3 +1,4 @@
{-# LANGUAGE LambdaCase #-}
{-| {-|
Module : PostgREST.ApiRequest Module : PostgREST.ApiRequest
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest. Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
@@ -13,7 +14,7 @@ module PostgREST.ApiRequest ( ApiRequest(..)
import Protolude import Protolude
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import Data.Aeson.Types (emptyObject) import Data.Aeson.Types (emptyObject, emptyArray)
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BS (c2w) import qualified Data.ByteString.Internal as BS (c2w)
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
@@ -33,14 +34,7 @@ import Network.Wai (Request (..))
import Network.Wai.Parse (parseHttpAccept) import Network.Wai.Parse (parseHttpAccept)
import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset) import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset)
import Data.Ranged.Boundaries import Data.Ranged.Boundaries
import PostgREST.Types ( QualifiedIdentifier (..) import PostgREST.Types
, Schema
, PayloadJSON(..)
, ContentType(..)
, ApiRequestError(..)
, toMime
, operators
, ftsOperators)
import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange) import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange)
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
import Web.Cookie (parseCookiesText) import Web.Cookie (parseCookiesText)
@@ -48,10 +42,10 @@ import Web.Cookie (parseCookiesText)
type RequestBody = BL.ByteString type RequestBody = BL.ByteString
-- | Types of things a user wants to do to tables/views/procs -- | Types of things a user wants to do to tables/views/procs
data Action = ActionCreate | ActionRead data Action = ActionCreate | ActionRead
| ActionUpdate | ActionDelete | ActionUpdate | ActionDelete
| ActionInfo | ActionInvoke{isReadOnly :: Bool} | ActionInfo | ActionInvoke{isReadOnly :: Bool}
| ActionInspect | ActionInspect | ActionSingleUpsert
deriving Eq deriving Eq
-- | The target db object of a user action -- | The target db object of a user action
data Target = TargetIdent QualifiedIdentifier data Target = TargetIdent QualifiedIdentifier
@@ -61,7 +55,7 @@ data Target = TargetIdent QualifiedIdentifier
deriving Eq deriving Eq
-- | How to return the inserted data -- | How to return the inserted data
data PreferRepresentation = Full | HeadersOnly | None deriving Eq data PreferRepresentation = Full | HeadersOnly | None deriving Eq
--
{-| {-|
Describes what the user wants to do. This data type is a Describes what the user wants to do. This data type is a
translation of the raw elements of an HTTP request into domain translation of the raw elements of an HTTP request into domain
@@ -86,6 +80,8 @@ data ApiRequest = ApiRequest {
, iPreferSingleObjectParameter :: Bool , iPreferSingleObjectParameter :: Bool
-- | Whether the client wants a result count (slower) -- | Whether the client wants a result count (slower)
, iPreferCount :: Bool , iPreferCount :: Bool
-- | Whether the client wants to UPSERT or ignore records on PK conflict
, iPreferResolution :: Maybe PreferResolution
-- | Filters on the result ("id", "eq.10") -- | Filters on the result ("id", "eq.10")
, iFilters :: [(Text, Text)] , iFilters :: [(Text, Text)]
-- | &and and &or parameters used for complex boolean logic -- | &and and &or parameters used for complex boolean logic
@@ -102,8 +98,6 @@ data ApiRequest = ApiRequest {
, iHeaders :: [(Text, Text)] , iHeaders :: [(Text, Text)]
-- | Request Cookies -- | Request Cookies
, iCookies :: [(Text, Text)] , iCookies :: [(Text, Text)]
-- | Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
, iRpcQParams :: [(Text, Text)]
} }
-- | Examines HTTP request and translates it into user intent. -- | Examines HTTP request and translates it into user intent.
@@ -111,19 +105,20 @@ userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError Api
userApiRequest schema req reqBody userApiRequest schema req reqBody
| isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate | isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate
| topLevelRange == emptyRange = Left InvalidRange | topLevelRange == emptyRange = Left InvalidRange
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) undefined payload | shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
| otherwise = Right ApiRequest { | otherwise = Right ApiRequest {
iAction = action iAction = action
, iTarget = target , iTarget = target
, iRange = ranges , iRange = ranges
, iAccepts = fromMaybe [CTAny] $ , iAccepts = maybe [CTAny] (map decodeContentType . parseHttpAccept) $ lookupHeader "accept"
map decodeContentType . parseHttpAccept <$> lookupHeader "accept"
, iPayload = relevantPayload , iPayload = relevantPayload
, iPreferRepresentation = representation , iPreferRepresentation = representation
, iPreferSingleObjectParameter = singleObject , iPreferSingleObjectParameter = singleObject
, iPreferCount = hasPrefer "count=exact" , iPreferCount = hasPrefer "count=exact"
, iPreferResolution = if hasPrefer (show MergeDuplicates) then Just MergeDuplicates
else if hasPrefer (show IgnoreDuplicates) then Just IgnoreDuplicates
else Nothing
, iFilters = filters , iFilters = filters
, iRpcQParams = rpcQParams
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ] , iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
, iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams , iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ] , iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
@@ -134,9 +129,10 @@ userApiRequest schema req reqBody
$ rawQueryString req $ rawQueryString req
, iJWT = tokenStr , iJWT = tokenStr
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie] , iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie]
, iCookies = fromMaybe [] $ parseCookiesText <$> lookupHeader "Cookie" , iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
} }
where where
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
(filters, rpcQParams) = (filters, rpcQParams) =
case action of case action of
ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
@@ -146,22 +142,24 @@ userApiRequest schema req reqBody
((<> ".") <$> "not":M.keys operators) ++ ((<> ".") <$> "not":M.keys operators) ++
((<> "(") <$> M.keys ftsOperators) ((<> "(") <$> M.keys ftsOperators)
isEmbedPath = T.isInfixOf "." isEmbedPath = T.isInfixOf "."
isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path isTargetingProc = (== Just "rpc") $ listToMaybe path
payload = payload =
case decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" of case (decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type", action) of
CTApplicationJSON -> (_, ActionInvoke{isReadOnly=True}) ->
note "All object keys must match" . ensureUniform . pluralize Right $ PayloadJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) PJObject (S.fromList $ fst <$> rpcQParams)
(CTApplicationJSON, _) ->
note "All object keys must match" . payloadAttributes reqBody
=<< if BL.null reqBody && isTargetingProc =<< if BL.null reqBody && isTargetingProc
then Right emptyObject then Right emptyObject
else JSON.eitherDecode reqBody else JSON.eitherDecode reqBody
CTTextCSV -> (CTTextCSV, _) -> do
note "All lines must have same number of fields" . ensureUniform . csvToJson json <- csvToJson <$> CSV.decodeByName reqBody
=<< CSV.decodeByName reqBody note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
CTOther "application/x-www-form-urlencoded" -> (CTOther "application/x-www-form-urlencoded", _) ->
Right . PayloadJSON . V.singleton . M.fromList let json = M.fromList . map (toS *** JSON.String . toS) . parseSimpleQuery $ toS reqBody
. map (toS *** JSON.String . toS) . parseSimpleQuery keys = S.fromList $ M.keys json in
$ toS reqBody Right $ PayloadJSON (JSON.encode json) PJObject keys
ct -> (ct, _) ->
Left $ toS $ "Content-Type not acceptable: " <> toMime ct Left $ toS $ "Content-Type not acceptable: " <> toMime ct
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges
action = action =
@@ -174,6 +172,7 @@ userApiRequest schema req reqBody
then ActionInvoke{isReadOnly=False} then ActionInvoke{isReadOnly=False}
else ActionCreate else ActionCreate
"PATCH" -> ActionUpdate "PATCH" -> ActionUpdate
"PUT" -> ActionSingleUpsert
"DELETE" -> ActionDelete "DELETE" -> ActionDelete
"OPTIONS" -> ActionInfo "OPTIONS" -> ActionInfo
_ -> ActionInspect _ -> ActionInspect
@@ -184,9 +183,8 @@ userApiRequest schema req reqBody
["rpc", proc] -> TargetProc ["rpc", proc] -> TargetProc
$ QualifiedIdentifier schema proc $ QualifiedIdentifier schema proc
other -> TargetUnknown other other -> TargetUnknown other
shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionInvoke{isReadOnly=False}] shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke{isReadOnly=False}, ActionInvoke{isReadOnly=True}]
relevantPayload | action == ActionInvoke{isReadOnly=True} = Nothing relevantPayload | shouldParsePayload = rightToMaybe payload
| shouldParsePayload = rightToMaybe payload
| otherwise = Nothing | otherwise = Nothing
path = pathInfo req path = pathInfo req
method = requestMethod req method = requestMethod req
@@ -216,7 +214,7 @@ userApiRequest schema req reqBody
limitParams :: M.HashMap ByteString NonnegRange limitParams :: M.HashMap ByteString NonnegRange
limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k] limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]
offsetParams :: M.HashMap ByteString NonnegRange offsetParams :: M.HashMap ByteString NonnegRange
offsetParams = M.fromList [(toS (replaceLast "limit" k), fromMaybe allRange (rangeGeq <$> (readMaybe =<< (toS <$> v)))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k] offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe =<< (toS <$> v))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]
urlRange = M.unionWith f limitParams offsetParams urlRange = M.unionWith f limitParams offsetParams
where where
@@ -270,9 +268,9 @@ type CsvData = V.Vector (M.HashMap Text BL.ByteString)
The reason for its odd signature is so that it can compose The reason for its odd signature is so that it can compose
directly with CSV.decodeByName directly with CSV.decodeByName
-} -}
csvToJson :: (CSV.Header, CsvData) -> JSON.Array csvToJson :: (CSV.Header, CsvData) -> JSON.Value
csvToJson (_, vals) = csvToJson (_, vals) =
V.map rowToJsonObj vals JSON.Array $ V.map rowToJsonObj vals
where where
rowToJsonObj = JSON.Object . rowToJsonObj = JSON.Object .
M.map (\str -> M.map (\str ->
@@ -281,27 +279,26 @@ csvToJson (_, vals) =
else JSON.String $ toS str else JSON.String $ toS str
) )
-- | Convert {foo} to [{foo}], leave arrays unchanged payloadAttributes :: RequestBody -> JSON.Value -> Maybe PayloadJSON
-- and truncate everything else to an empty array. payloadAttributes raw json =
pluralize :: JSON.Value -> JSON.Array -- Test that Array contains only Objects having the same keys
pluralize obj@(JSON.Object _) = V.singleton obj case json of
pluralize (JSON.Array arr) = arr JSON.Array arr ->
pluralize _ = V.empty case arr V.!? 0 of
Just (JSON.Object o) ->
let canonicalKeys = S.fromList $ M.keys o
areKeysUniform = all (\case
JSON.Object x -> S.fromList (M.keys x) == canonicalKeys
_ -> False) arr in
if areKeysUniform
then Just $ PayloadJSON raw (PJArray $ V.length arr) canonicalKeys
else Nothing
Just _ -> Nothing
Nothing -> Just emptyPJArray
-- | Test that Array contains only Objects having the same keys JSON.Object o -> Just $ PayloadJSON raw PJObject (S.fromList $ M.keys o)
-- and if so mark it as PayloadJSON
ensureUniform :: JSON.Array -> Maybe PayloadJSON
ensureUniform arr =
let objs :: V.Vector JSON.Object
objs = foldr -- filter non-objects, map to raw objects
(\val result -> case val of
JSON.Object o -> V.cons o result
_ -> result)
V.empty arr
keysPerObj = V.map (S.fromList . M.keys) objs
canonicalKeys = fromMaybe S.empty $ keysPerObj V.!? 0
areKeysUniform = all (==canonicalKeys) keysPerObj in
if (V.length objs == V.length arr) && areKeysUniform -- truncate everything else to an empty array.
then Just (PayloadJSON objs) _ -> Just emptyPJArray
else Nothing where
emptyPJArray = PayloadJSON (JSON.encode emptyArray) (PJArray 0) S.empty
+111 -78
View File
@@ -1,16 +1,19 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.App ( module PostgREST.App (
postgrest postgrest
) where ) where
import Control.Applicative import Control.Applicative
import Data.Aeson (toJSON, eitherDecode) import Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import Data.Maybe import Data.Maybe
import Data.IORef (IORef, readIORef) import Data.IORef (IORef, readIORef)
import Data.Text (intercalate) import Data.Text (intercalate)
import Data.Time.Clock (UTCTime)
import qualified Data.Set as S
import qualified Hasql.Pool as P import qualified Hasql.Pool as P
import qualified Hasql.Transaction as HT import qualified Hasql.Transaction as HT
@@ -22,7 +25,6 @@ import Network.HTTP.Types.URI (renderSimpleQuery)
import Network.Wai import Network.Wai
import Network.Wai.Middleware.RequestLogger (logStdout) import Network.Wai.Middleware.RequestLogger (logStdout)
import qualified Data.Vector as V
import qualified Hasql.Transaction as H import qualified Hasql.Transaction as H
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
@@ -33,12 +35,11 @@ import PostgREST.ApiRequest ( ApiRequest(..), ContentType(..)
, mutuallyAgreeable , mutuallyAgreeable
, userApiRequest , userApiRequest
) )
import PostgREST.Auth (jwtClaims, containsRole, parseJWK) import PostgREST.Auth (jwtClaims, containsRole, parseSecret)
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.DbStructure import PostgREST.DbStructure
import PostgREST.DbRequestBuilder( readRequest import PostgREST.DbRequestBuilder( readRequest
, mutateRequest , mutateRequest
, readRpcRequest
, fieldNames , fieldNames
) )
import PostgREST.Error ( simpleError, pgError import PostgREST.Error ( simpleError, pgError
@@ -60,14 +61,14 @@ import PostgREST.OpenAPI
import Data.Function (id) import Data.Function (id)
import Protolude hiding (intercalate, Proxy) import Protolude hiding (intercalate, Proxy)
import Safe (headMay)
postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO () -> Application postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> IO () -> Application
postgrest conf refDbStructure pool worker = postgrest conf refDbStructure pool getTime worker =
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle let middle = (if configQuiet conf then id else logStdout) . defaultMiddle
jwtSecret = parseJWK <$> configJwtSecret conf in jwtSecret = parseSecret <$> configJwtSecret conf in
middle $ \ req respond -> do middle $ \ req respond -> do
time <- getTime
body <- strictRequestBody req body <- strictRequestBody req
maybeDbStructure <- readIORef refDbStructure maybeDbStructure <- readIORef refDbStructure
case maybeDbStructure of case maybeDbStructure of
@@ -76,38 +77,47 @@ postgrest conf refDbStructure pool worker =
response <- case userApiRequest (configSchema conf) req body of response <- case userApiRequest (configSchema conf) req body of
Left err -> return $ apiRequestError err Left err -> return $ apiRequestError err
Right apiRequest -> do Right apiRequest -> do
eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf)
let authed = containsRole eClaims let authed = containsRole eClaims
handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest proc = case (iTarget apiRequest, iPayload apiRequest, iPreferSingleObjectParameter apiRequest) of
txMode = transactionMode dbStructure (TargetProc qi, Just PayloadJSON{pjKeys}, s) -> findProc qi pjKeys s $ dbProcs dbStructure
(iTarget apiRequest) (iAction apiRequest) _ -> Nothing
handleReq = runWithClaims conf eClaims (app dbStructure proc conf) apiRequest
txMode = transactionMode proc (iAction apiRequest)
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq
return $ either (pgError authed) identity response return $ either (pgError authed) identity response
when (responseStatus response == status503) worker when (responseStatus response == status503) worker
respond response respond response
transactionMode :: DbStructure -> Target -> Action -> H.Mode findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
transactionMode structure target action = findProc qi payloadKeys paramsAsSingleObject allProcs =
let procs = M.lookup (qiName qi) allProcs in
-- Handle overloaded functions case
join $ (case length <$> procs of
Just 1 -> headMay -- if it's not an overloaded function then immediatly get the ProcDescription
_ -> find (\x ->
if paramsAsSingleObject
then length (pdArgs x) == 1 -- if the arg is not of json type let the db give the err
else payloadKeys `S.isSubsetOf` S.fromList (pgaName <$> pdArgs x))
) <$> procs
transactionMode :: Maybe ProcDescription -> Action -> HT.Mode
transactionMode proc action =
case action of case action of
ActionRead -> HT.Read ActionRead -> HT.Read
ActionInfo -> HT.Read ActionInfo -> HT.Read
ActionInspect -> HT.Read ActionInspect -> HT.Read
ActionInvoke{isReadOnly=False} -> ActionInvoke{isReadOnly=False} ->
let proc = let v = maybe Volatile pdVolatility proc in
case target of
(TargetProc qi) -> M.lookup (qiName qi) $
dbProcs structure
_ -> Nothing
v = fromMaybe Volatile $ pdVolatility <$> proc in
if v == Stable || v == Immutable if v == Stable || v == Immutable
then HT.Read then HT.Read
else HT.Write else HT.Write
ActionInvoke{isReadOnly=True} -> HT.Read ActionInvoke{isReadOnly=True} -> HT.Read
_ -> HT.Write _ -> HT.Write
app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response app :: DbStructure -> Maybe ProcDescription -> AppConfig -> ApiRequest -> H.Transaction Response
app dbStructure conf apiRequest = app dbStructure proc conf apiRequest =
case responseContentTypeOrError (iAccepts apiRequest) (iAction apiRequest) of case responseContentTypeOrError (iAccepts apiRequest) (iAction apiRequest) of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right contentType -> Right contentType ->
@@ -121,7 +131,7 @@ app dbStructure conf apiRequest =
Right ((q, cq), bField) -> do Right ((q, cq), bField) -> do
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount
(contentType == CTTextCSV) bField (contentType == CTTextCSV) bField
row <- H.query () stm row <- H.statement () stm
let (tableTotal, queryTotal, _ , body) = row let (tableTotal, queryTotal, _ , body) = row
(status, contentRange) = rangeHeader queryTotal tableTotal (status, contentRange) = rangeHeader queryTotal tableTotal
canonical = iCanonicalQS apiRequest canonical = iCanonicalQS apiRequest
@@ -136,48 +146,52 @@ app dbStructure conf apiRequest =
) )
] (toS body) ] (toS body)
(ActionCreate, TargetIdent (QualifiedIdentifier _ table), Just payload@(PayloadJSON rows)) -> (ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just PayloadJSON{pjRaw, pjType}) ->
case mutateSqlParts of case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right (sq, mq) -> do Right (sq, mq) -> do
let isSingle = (==1) $ V.length rows let (isSingle, nRows) = case pjType of
PJArray len -> (len == 1, len)
PJObject -> (True, 1)
if contentType == CTSingularJSON if contentType == CTSingularJSON
&& not isSingle && not isSingle
&& iPreferRepresentation apiRequest == Full && iPreferRepresentation apiRequest == Full
then return $ singularityError (toInteger $ V.length rows) then return $ singularityError (toInteger nRows)
else do else do
let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself? let pkCols = tablePKCols dbStructure tSchema tName
stm = createWriteStatement sq mq stm = createWriteStatement sq mq
(contentType == CTSingularJSON) isSingle (contentType == CTSingularJSON) isSingle
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) (contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols
pKeys row <- H.statement (toS pjRaw) stm
row <- H.query payload stm
let (_, _, fs, body) = extractQueryResult row let (_, _, fs, body) = extractQueryResult row
headers = catMaybes [ headers = catMaybes [
if null fs if null fs
then Nothing then Nothing
else Just (hLocation, "/" <> toS table <> renderLocationFields fs) else Just (hLocation, "/" <> toS tName <> renderLocationFields fs)
, if iPreferRepresentation apiRequest == Full , if iPreferRepresentation apiRequest == Full
then Just $ toHeader contentType then Just $ toHeader contentType
else Nothing else Nothing
, Just . contentRangeH 1 0 $ , Just . contentRangeH 1 0 $
toInteger <$> if shouldCount then Just (V.length rows) else Nothing toInteger <$> if shouldCount then Just nRows else Nothing
, if null pkCols
then Nothing
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
] ]
return . responseLBS status201 headers $ return . responseLBS status201 headers $
if iPreferRepresentation apiRequest == Full if iPreferRepresentation apiRequest == Full
then toS body else "" then toS body else ""
(ActionUpdate, TargetIdent _, Just payload@(PayloadJSON rows)) -> (ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just p@PayloadJSON{pjRaw}) ->
case (mutateSqlParts, null <$> rows V.!? 0, iPreferRepresentation apiRequest == Full) of case (mutateSqlParts tSchema tName, pjIsEmpty p, iPreferRepresentation apiRequest == Full) of
(Left errorResponse, _, _) -> return errorResponse (Left errorResponse, _, _) -> return errorResponse
(_, Just True, True) -> return $ responseLBS status200 [contentRangeH 1 0 Nothing] "[]" (_, True, True) -> return $ responseLBS status200 [contentRangeH 1 0 Nothing] "[]"
(_, Just True, False) -> return $ responseLBS status204 [contentRangeH 1 0 Nothing] "" (_, True, False) -> return $ responseLBS status204 [contentRangeH 1 0 Nothing] ""
(Right (sq, mq), _, _) -> do (Right (sq, mq), _, _) -> do
let stm = createWriteStatement sq mq let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTTextCSV) (contentType == CTSingularJSON) False (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) [] (iPreferRepresentation apiRequest) []
row <- H.query payload stm row <- H.statement (toS pjRaw) stm
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body) = extractQueryResult row
if contentType == CTSingularJSON if contentType == CTSingularJSON
&& queryTotal /= 1 && queryTotal /= 1
@@ -195,16 +209,46 @@ app dbStructure conf apiRequest =
then responseLBS s [toHeader contentType, r] (toS body) then responseLBS s [toHeader contentType, r] (toS body)
else responseLBS s [r] "" else responseLBS s [r] ""
(ActionDelete, TargetIdent _, Nothing) -> (ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just PayloadJSON{pjRaw, pjType, pjKeys}) ->
case mutateSqlParts of case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right (sq, mq) -> do Right (sq, mq) -> do
let emptyPayload = PayloadJSON V.empty let isSingle = case pjType of
stm = createWriteStatement sq mq PJArray len -> len == 1
PJObject -> True
colNames = colName <$> tableCols dbStructure tSchema tName
if topLevelRange /= allRange
then return $ simpleError status400 [] "Range header and limit/offset querystring parameters are not allowed for PUT"
else if not isSingle
then return $ simpleError status400 [] "PUT payload must contain a single row"
else if S.fromList colNames /= pjKeys
then return $ simpleError status400 [] "You must specify all columns in the payload when using PUT"
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 $ simpleError status400 [] "Payload values do not match URL in primary key column(s)"
else
return $ if iPreferRepresentation apiRequest == Full
then responseLBS status200 [toHeader contentType] (toS body)
else responseLBS status204 [] ""
(ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right (sq, mq) -> do
let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTSingularJSON) False
(contentType == CTTextCSV) (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) [] (iPreferRepresentation apiRequest) []
row <- H.query emptyPayload stm row <- H.statement mempty stm
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body) = extractQueryResult row
r = contentRangeH 1 0 $ r = contentRangeH 1 0 $
toInteger <$> if shouldCount then Just queryTotal else Nothing toInteger <$> if shouldCount then Just queryTotal else Nothing
@@ -227,33 +271,32 @@ app dbStructure conf apiRequest =
let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
return $ responseLBS status200 [allOrigins, acceptH] "" return $ responseLBS status200 [allOrigins, acceptH] ""
(ActionInvoke _isReadOnly, TargetProc qi, payload) -> (ActionInvoke _, TargetProc qi, Just PayloadJSON{pjRaw, pjType, pjKeys}) ->
let proc = M.lookup (qiName qi) allProcs let returnsScalar = case proc of
returnsScalar = case proc of
Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True
_ -> False _ -> False
rpcBinaryField = if returnsScalar rpcBinaryField = if returnsScalar
then Right Nothing then Right Nothing
else binaryField contentType =<< fldNames else binaryField contentType =<< fldNames
parts = (,,) <$> readSqlParts <*> rpcBinaryField <*> rpcQParams in parts = (,) <$> readSqlParts <*> rpcBinaryField in
case parts of case parts of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right ((q, cq), bField, params) -> do Right ((q, cq), bField) -> do
let prms = case payload of let isObject = case pjType of
Just (PayloadJSON pld) -> V.head pld PJObject -> True
Nothing -> M.fromList $ second toJSON <$> params -- toJSON is just for reusing the callProc function PJArray _ -> False
singular = contentType == CTSingularJSON singular = contentType == CTSingularJSON
paramsAsSingleObject = iPreferSingleObjectParameter apiRequest specifiedPgArgs = filter ((`S.member` pjKeys) . pgaName) $ maybe [] pdArgs proc
row <- H.query () $ row <- H.statement (toS pjRaw) $
callProc qi prms returnsScalar q cq shouldCount callProc qi specifiedPgArgs returnsScalar q cq shouldCount
singular paramsAsSingleObject singular (iPreferSingleObjectParameter apiRequest)
(contentType == CTTextCSV) (contentType == CTTextCSV)
(contentType == CTOctetStream) _isReadOnly bField (contentType == CTOctetStream) bField isObject
(pgVersion dbStructure) (pgVersion dbStructure)
let (tableTotal, queryTotal, body, jsonHeaders) = let (tableTotal, queryTotal, body, jsonHeaders) =
fromMaybe (Just 0, 0, "[]", "[]") row fromMaybe (Just 0, 0, "[]", "[]") row
(status, contentRange) = rangeHeader queryTotal tableTotal (status, contentRange) = rangeHeader queryTotal tableTotal
decodedHeaders = first toS $ eitherDecode $ toS jsonHeaders :: Either Text [GucHeader] decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]
case decodedHeaders of case decodedHeaders of
Left _ -> return gucHeadersError Left _ -> return gucHeadersError
Right hs -> Right hs ->
@@ -270,26 +313,16 @@ app dbStructure conf apiRequest =
uri Nothing = ("http", host, port, "/") uri Nothing = ("http", host, port, "/")
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b) uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
uri' = uri proxy uri' = uri proxy
encodeApi ti sd procs = encodeOpenAPI (M.elems procs) (toTableInfo ti) uri' sd (dbPrimaryKeys dbStructure) toTableInfo :: [Table] -> [(Table, [Column], [Text])]
body <- encodeApi <$> H.query schema accessibleTables <*> H.query schema schemaDescription <*> H.query schema accessibleProcs 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 return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
_ -> return notFound _ -> return notFound
where where
toTableInfo :: [Table] -> [(Table, [Column], [Text])]
toTableInfo = map (\t ->
let tSchema = tableSchema t
tTable = tableName t
cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure
pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys
in (t, cols, pkeys))
notFound = responseLBS status404 [] "" notFound = responseLBS status404 [] ""
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
filterCol :: Schema -> TableName -> Column -> Bool
filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb
allPrKeys = dbPrimaryKeys dbStructure
allProcs = dbProcs dbStructure
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
shouldCount = iPreferCount apiRequest shouldCount = iPreferCount apiRequest
schema = toS $ configSchema conf schema = toS $ configSchema conf
@@ -301,16 +334,15 @@ app dbStructure conf apiRequest =
status = rangeStatus lower upper (toInteger <$> tableTotal) status = rangeStatus lower upper (toInteger <$> tableTotal)
in (status, contentRange) in (status, contentRange)
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) allProcs apiRequest readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) proc apiRequest
fldNames = fieldNames <$> readReq fldNames = fieldNames <$> readReq
readDbRequest = DbRead <$> readReq readDbRequest = DbRead <$> readReq
mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< fldNames)
rpcQParams = readRpcRequest apiRequest
selectQuery = requestToQuery schema False <$> readDbRequest selectQuery = requestToQuery schema False <$> readDbRequest
mutateQuery = requestToQuery schema False <$> mutateDbRequest
countQuery = requestToCountQuery schema <$> readDbRequest countQuery = requestToCountQuery schema <$> readDbRequest
readSqlParts = (,) <$> selectQuery <*> countQuery readSqlParts = (,) <$> selectQuery <*> countQuery
mutateSqlParts = (,) <$> selectQuery <*> mutateQuery mutateSqlParts s t =
(,) <$> selectQuery
<*> (requestToQuery schema False . DbMutate <$> (mutateRequest apiRequest t (tablePKCols dbStructure s t) =<< fldNames))
responseContentTypeOrError :: [ContentType] -> Action -> Either Response ContentType responseContentTypeOrError :: [ContentType] -> Action -> Either Response ContentType
responseContentTypeOrError accepts action = serves contentTypesForRequest accepts responseContentTypeOrError accepts action = serves contentTypesForRequest accepts
@@ -324,6 +356,7 @@ responseContentTypeOrError accepts action = serves contentTypesForRequest accept
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream] ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream]
ActionInspect -> [CTOpenAPI, CTApplicationJSON] ActionInspect -> [CTOpenAPI, CTApplicationJSON]
ActionInfo -> [CTTextCSV] ActionInfo -> [CTTextCSV]
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
serves sProduces cAccepts = serves sProduces cAccepts =
case mutuallyAgreeable sProduces cAccepts of case mutuallyAgreeable sProduces cAccepts of
Nothing -> do Nothing -> do
@@ -364,8 +397,8 @@ contentRangeH lower upper total =
rangeString rangeString
| totalNotZero && fromInRange = show lower <> "-" <> show upper | totalNotZero && fromInRange = show lower <> "-" <> show upper
| otherwise = "*" | otherwise = "*"
totalString = fromMaybe "*" (show <$> total) totalString = maybe "*" show total
totalNotZero = fromMaybe True ((/=) 0 <$> total) totalNotZero = maybe True (0 /=) total
fromInRange = lower <= upper fromInRange = lower <= upper
extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
+51 -27
View File
@@ -1,4 +1,5 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-| {-|
Module : PostgREST.Auth Module : PostgREST.Auth
Description : PostgREST authorization functions. Description : PostgREST authorization functions.
@@ -15,12 +16,16 @@ module PostgREST.Auth (
containsRole containsRole
, jwtClaims , jwtClaims
, JWTAttempt(..) , JWTAttempt(..)
, parseJWK , parseSecret
) where ) where
import Control.Lens.Operators import Control.Lens.Operators
import Data.Aeson (Value (..), decode, toJSON) import Control.Lens (set)
import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import Data.Time.Clock (UTCTime)
import Data.Vector as V
import PostgREST.Types
import Protolude import Protolude
import qualified Crypto.JOSE.Types as JOSE.Types import qualified Crypto.JOSE.Types as JOSE.Types
@@ -31,26 +36,45 @@ import Crypto.JWT
-} -}
data JWTAttempt = JWTInvalid JWTError data JWTAttempt = JWTInvalid JWTError
| JWTMissingSecret | JWTMissingSecret
| JWTClaims (M.HashMap Text Value) | JWTClaims (M.HashMap Text JSON.Value)
deriving (Eq, Show) deriving (Eq, Show)
{-| {-|
Receives the JWT secret and audience (from config) and a JWT and returns a map Receives the JWT secret and audience (from config) and a JWT and returns a map
of JWT claims. of JWT claims.
-} -}
jwtClaims :: Maybe JWK -> Maybe StringOrURI -> LByteString -> IO JWTAttempt jwtClaims :: Maybe JWKSet -> Maybe StringOrURI -> LByteString -> UTCTime -> Maybe JSPath -> IO JWTAttempt
jwtClaims _ _ "" = return $ JWTClaims M.empty jwtClaims _ _ "" _ _ = return $ JWTClaims M.empty
jwtClaims secret audience payload = jwtClaims secret audience payload time jspath =
case secret of case secret of
Nothing -> return JWTMissingSecret Nothing -> return JWTMissingSecret
Just s -> do Just s -> do
let validation = defaultJWTValidationSettings (maybe (const True) (==) audience) let validation = set allowedSkew 1 $ defaultJWTValidationSettings (maybe (const True) (==) audience)
eJwt <- runExceptT $ do eJwt <- runExceptT $ do
jwt <- decodeCompact payload jwt <- decodeCompact payload
verifyClaims validation s jwt verifyClaimsAt validation s time jwt
return $ case eJwt of return $ case eJwt of
Left e -> JWTInvalid e Left e -> JWTInvalid e
Right jwt -> JWTClaims . claims2map $ jwt Right jwt -> JWTClaims $ claims2map jwt jspath
{-|
Turn JWT ClaimSet into something easier to work with,
also here the jspath is applied to put the "role" in the map
-}
claims2map :: ClaimsSet -> Maybe JSPath -> M.HashMap Text JSON.Value
claims2map claims jspath = (\case
val@(JSON.Object o) ->
let role = maybe M.empty (M.singleton "role") $
walkJSPath (Just val) =<< jspath in
M.delete "role" o `M.union` role -- mutating the map
_ -> M.empty
) $ JSON.toJSON claims
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
walkJSPath x [] = x
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (M.lookup key o) rest
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
walkJSPath _ _ = Nothing
{-| {-|
Whether a response from jwtClaims contains a role claim Whether a response from jwtClaims contains a role claim
@@ -60,28 +84,28 @@ containsRole (JWTClaims claims) = M.member "role" claims
containsRole _ = False containsRole _ = False
{-| {-|
Internal helper used to turn JWT ClaimSet into something Parse `jwt-secret` configuration option and turn into a JWKSet.
easier to work with
-}
claims2map :: ClaimsSet -> M.HashMap Text Value
claims2map = val2map . toJSON
where
val2map (Object o) = o
val2map _ = M.empty
parseJWK :: ByteString -> JWK There are three ways to specify `jwt-secret`: text secret, JSON Web Key
parseJWK str = (JWK), or JSON Web Key Set (JWKS). The first two are converted into a JWKSet
fromMaybe (hs256jwk str) (decode (toS str) :: Maybe JWK) with one key and the last is converted as is.
-}
parseSecret :: ByteString -> JWKSet
parseSecret str =
fromMaybe (maybe secret (\jwk' -> JWKSet [jwk']) maybeJWK)
maybeJWKSet
where
maybeJWKSet = JSON.decode (toS str) :: Maybe JWKSet
maybeJWK = JSON.decode (toS str) :: Maybe JWK
secret = JWKSet [jwkFromSecret str]
{-| {-|
Internal helper to generate HMAC-SHA256. When the jwt key in the Internal helper to generate a symmetric HMAC-SHA256 JWK from a text secret.
config file is a simple string rather than a JWK object, we'll
apply this function to it.
-} -}
hs256jwk :: ByteString -> JWK jwkFromSecret :: ByteString -> JWK
hs256jwk key = jwkFromSecret key =
fromKeyMaterial km fromKeyMaterial km
& jwkUse .~ Just Sig & jwkUse ?~ Sig
& jwkAlg .~ (Just $ JWSAlg HS256) & jwkAlg ?~ JWSAlg HS256
where where
km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key)) km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key))
+35 -18
View File
@@ -18,13 +18,10 @@ module PostgREST.Config ( prettyVersion
, docsVersion , docsVersion
, readOptions , readOptions
, corsPolicy , corsPolicy
, minimumPgVersion
, pgVersion96
, AppConfig (..) , AppConfig (..)
) )
where where
import PostgREST.Types (PgVersion(..))
import Control.Applicative import Control.Applicative
import Control.Monad (fail) import Control.Monad (fail)
import Control.Lens (preview) import Control.Lens (preview)
@@ -42,7 +39,7 @@ import Data.Scientific (floatingOrInteger)
import Data.String (String) import Data.String (String)
import Data.Text (dropAround, import Data.Text (dropAround,
intercalate, lines, intercalate, lines,
strip, take) strip, take, splitOn)
import Data.Text.Encoding (encodeUtf8) import Data.Text.Encoding (encodeUtf8)
import Data.Text.IO (hPutStrLn) import Data.Text.IO (hPutStrLn)
import Data.Version (versionBranch) import Data.Version (versionBranch)
@@ -51,6 +48,9 @@ import Network.Wai
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..)) import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
import Options.Applicative hiding (str) import Options.Applicative hiding (str)
import Paths_postgrest (version) import Paths_postgrest (version)
import PostgREST.Parsers (pRoleClaimKey)
import PostgREST.Types (ApiRequestError(..),
JSPath, JSPathExp(..))
import Protolude hiding (hPutStrLn, take, import Protolude hiding (hPutStrLn, take,
intercalate, (<>)) intercalate, (<>))
import System.IO (hPrint) import System.IO (hPrint)
@@ -76,11 +76,14 @@ data AppConfig = AppConfig {
, configMaxRows :: Maybe Integer , configMaxRows :: Maybe Integer
, configReqCheck :: Maybe Text , configReqCheck :: Maybe Text
, configQuiet :: Bool , configQuiet :: Bool
, configSettings :: [(Text, Text)]
, configRoleClaimKey :: Either ApiRequestError JSPath
, configExtraSearchPath :: [Text]
} }
defaultCorsPolicy :: CorsResourcePolicy defaultCorsPolicy :: CorsResourcePolicy
defaultCorsPolicy = CorsResourcePolicy Nothing defaultCorsPolicy = CorsResourcePolicy Nothing
["GET", "POST", "PATCH", "DELETE", "OPTIONS"] ["Authorization"] Nothing ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing
(Just $ 60*60*24) False False True (Just $ 60*60*24) False False True
-- | CORS policy to be used in by Wai Cors middleware -- | CORS policy to be used in by Wai Cors middleware
@@ -127,7 +130,7 @@ readOptions = do
<*> C.key "db-anon-role" <*> C.key "db-anon-role"
<*> (mfilter (/= "") <$> C.key "server-proxy-uri") <*> (mfilter (/= "") <$> C.key "server-proxy-uri")
<*> C.key "db-schema" <*> C.key "db-schema"
<*> (fromMaybe "*4" . mfilter (/= "") <$> C.key "server-host") <*> (fromMaybe "127.0.0.1" . mfilter (/= "") <$> C.key "server-host")
<*> (fromMaybe 3000 . join . fmap coerceInt <$> C.key "server-port") <*> (fromMaybe 3000 . join . fmap coerceInt <$> C.key "server-port")
<*> (fmap encodeUtf8 . mfilter (/= "") <$> C.key "jwt-secret") <*> (fmap encodeUtf8 . mfilter (/= "") <$> C.key "jwt-secret")
<*> (fromMaybe False . join . fmap coerceBool <$> C.key "secret-is-base64") <*> (fromMaybe False . join . fmap coerceBool <$> C.key "secret-is-base64")
@@ -136,6 +139,9 @@ readOptions = do
<*> (join . fmap coerceInt <$> C.key "max-rows") <*> (join . fmap coerceInt <$> C.key "max-rows")
<*> (mfilter (/= "") <$> C.key "pre-request") <*> (mfilter (/= "") <$> C.key "pre-request")
<*> pure False <*> pure False
<*> (fmap (fmap coerceText) <$> C.subassocs "app.settings")
<*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> C.key "role-claim-key")
<*> (maybe ["public"] splitExtraSearchPath <$> C.key "db-extra-search-path")
case mAppConf of case mAppConf of
Nothing -> do Nothing -> do
@@ -154,16 +160,28 @@ readOptions = do
(Just "") -> pure Nothing (Just "") -> pure Nothing
aud' -> pure aud' aud' -> pure aud'
coerceText :: Value -> Text
coerceText (String s) = s
coerceText v = show v
coerceInt :: (Read i, Integral i) => Value -> Maybe i coerceInt :: (Read i, Integral i) => Value -> Maybe i
coerceInt (Number x) = rightToMaybe $ floatingOrInteger x coerceInt (Number x) = rightToMaybe $ floatingOrInteger x
coerceInt (String x) = readMaybe $ toS x coerceInt (String x) = readMaybe $ toS x
coerceInt _ = Nothing coerceInt _ = Nothing
coerceBool :: Value -> Maybe Bool coerceBool :: Value -> Maybe Bool
coerceBool (Bool b) = Just b coerceBool (Bool b) = Just b
coerceBool (String x) = readMaybe $ toS x coerceBool (String b) = readMaybe $ toS b
coerceBool _ = Nothing coerceBool _ = Nothing
parseRoleClaimKey :: Value -> Either ApiRequestError JSPath
parseRoleClaimKey (String s) = pRoleClaimKey s
parseRoleClaimKey v = pRoleClaimKey $ show v
splitExtraSearchPath :: Value -> [Text]
splitExtraSearchPath (String s) = strip <$> splitOn "," s
splitExtraSearchPath _ = []
opts = info (helper <*> pathParser) $ opts = info (helper <*> pathParser) $
fullDesc fullDesc
<> progDesc ( <> progDesc (
@@ -187,17 +205,17 @@ readOptions = do
exampleCfg :: Doc exampleCfg :: Doc
exampleCfg = vsep . map (text . toS) . lines $ exampleCfg = vsep . map (text . toS) . lines $
[str|db-uri = "postgres://user:pass@localhost:5432/dbname" [str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|db-schema = "public" |db-schema = "public" # this schema gets added to the search_path of every request
|db-anon-role = "postgres" |db-anon-role = "postgres"
|db-pool = 10 |db-pool = 10
| |
|server-host = "*4" |server-host = "127.0.0.1"
|server-port = 3000 |server-port = 3000
| |
|## base url for swagger output |## base url for swagger output
|# server-proxy-uri = "" |# server-proxy-uri = ""
| |
|## choose a secret to enable JWT auth |## choose a secret, JSON Web Key (or set) to enable JWT auth
|## (use "@filename" to load from separate file) |## (use "@filename" to load from separate file)
|# jwt-secret = "foo" |# jwt-secret = "foo"
|# secret-is-base64 = false |# secret-is-base64 = false
@@ -208,6 +226,12 @@ readOptions = do
| |
|## stored proc to exec immediately after auth |## stored proc to exec immediately after auth
|# pre-request = "stored_proc_name" |# pre-request = "stored_proc_name"
|
|## jspath to the role claim key
|# role-claim-key = ".role"
|
|## extra schemas to add to the search_path of every request
|# db-extra-search-path = "extensions, util"
|] |]
pathParser :: Parser FilePath pathParser :: Parser FilePath
@@ -215,10 +239,3 @@ pathParser =
strArgument $ strArgument $
metavar "FILENAME" <> metavar "FILENAME" <>
help "Path to configuration file" help "Path to configuration file"
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
minimumPgVersion :: PgVersion
minimumPgVersion = PgVersion 90300 "9.3"
pgVersion96 :: PgVersion
pgVersion96 = PgVersion 90600 "9.6"
+179 -183
View File
@@ -1,9 +1,11 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields#-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.DbRequestBuilder ( module PostgREST.DbRequestBuilder (
readRequest readRequest
, mutateRequest , mutateRequest
, readRpcRequest
, fieldNames , fieldNames
) where ) where
@@ -14,6 +16,7 @@ import Control.Lens.Tuple (_1)
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import Data.List (delete) import Data.List (delete)
import Data.Maybe (fromJust) import Data.Maybe (fromJust)
import qualified Data.Set as S
import Data.Text (isInfixOf) import Data.Text (isInfixOf)
import Data.Tree import Data.Tree
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft)
@@ -30,45 +33,50 @@ import PostgREST.ApiRequest ( ApiRequest(..)
) )
import PostgREST.Error (apiRequestError) import PostgREST.Error (apiRequestError)
import PostgREST.Parsers import PostgREST.Parsers
import PostgREST.RangeQuery (NonnegRange, restrictRange) import PostgREST.RangeQuery (NonnegRange, restrictRange, allRange)
import PostgREST.Types import PostgREST.Types
import Protolude hiding (from, dropWhile, drop) import Protolude hiding (from)
import Text.Regex.TDFA ((=~)) import Text.Regex.TDFA ((=~))
import Unsafe (unsafeHead) import Unsafe (unsafeHead)
readRequest :: Maybe Integer -> [Relation] -> M.HashMap Text ProcDescription -> ApiRequest -> Either Response ReadRequest readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest
readRequest maxRows allRels allProcs apiRequest = readRequest maxRows allRels proc apiRequest =
mapLeft apiRequestError $ mapLeft apiRequestError $
treeRestrictRange maxRows =<< treeRestrictRange maxRows =<<
augumentRequestWithJoin schema relations =<< augumentRequestWithJoin schema relations =<<
parseReadRequest addFiltersOrdersRanges apiRequest <*>
(buildReadRequest <$> pRequestSelect (iSelect apiRequest))
where where
action = iAction apiRequest
(schema, rootTableName) = fromJust $ -- Make it safe (schema, rootTableName) = fromJust $ -- Make it safe
let target = iTarget apiRequest in let target = iTarget apiRequest in
case target of case target of
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t) (TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
(TargetProc (QualifiedIdentifier s proc) ) -> Just (s, tName) (TargetProc (QualifiedIdentifier s pName) ) -> Just (s, tName)
where where
retType = pdReturnType <$> M.lookup proc allProcs tName = case pdReturnType <$> proc of
tName = case retType of
Just (SetOf (Composite qi)) -> qiName qi Just (SetOf (Composite qi)) -> qiName qi
Just (Single (Composite qi)) -> qiName qi Just (Single (Composite qi)) -> qiName qi
_ -> proc _ -> pName
_ -> Nothing _ -> Nothing
action :: Action -- Build tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having
action = iAction apiRequest -- an alias like "table_depth", this is related to issue #987.
buildReadRequest :: [Tree SelectItem] -> ReadRequest
parseReadRequest :: Either ApiRequestError ReadRequest buildReadRequest fieldTree =
parseReadRequest = addFiltersOrdersRanges apiRequest <*> let rootDepth = 0
pRequestSelect rootName selStr rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in
foldr (treeEntry rootDepth) (Node (Select [] [rootNodeName] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree
where where
selStr = iSelect apiRequest treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
rootName = if action == ActionRead treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
then rootTableName let nxtDepth = succ depth in
else sourceCTEName case fldForest of
[] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) $
foldr (treeEntry nxtDepth) (Node (Select [] [fn] [] [] [] allRange, (fn, Nothing, alias, relationDetail, nxtDepth)) []) fldForest:rForest
relations :: [Relation] relations :: [Relation]
relations = case action of relations = case action of
@@ -77,7 +85,19 @@ readRequest maxRows allRels allProcs apiRequest =
ActionDelete -> fakeSourceRelations ++ allRels ActionDelete -> fakeSourceRelations ++ allRels
ActionInvoke _ -> fakeSourceRelations ++ allRels ActionInvoke _ -> fakeSourceRelations ++ allRels
_ -> allRels _ -> allRels
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels
-- in a relation where one of the tables matches "TableName"
-- replace the name to that table with pg_source
-- this "fake" relations is needed so that in a mutate query
-- we can look at the "returning *" part which is wrapped with a "with"
-- as just another table that has relations with other tables
toSourceRelation :: TableName -> Relation -> Maybe Relation
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
| mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
| Just mt == (tableName <$> rt) = Just $ r {relLinkTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
| otherwise = Nothing
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
@@ -88,136 +108,135 @@ treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` re
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
augumentRequestWithJoin schema allRels request = augumentRequestWithJoin schema allRels request =
addRelations schema allRels Nothing request addRelations schema allRels Nothing request
>>= addJoinFilters schema >>= addJoinConditions schema
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
addRelations schema allRelations parentNode (Node readNode@(query, (name, _, alias, relationDetail)) forest) = addRelations schema allRelations parentNode (Node (query, (nodeName, _, alias, relationDetail, depth)) forest) =
case parentNode of case parentNode of
(Just (Node (Select{from=[parentNodeTable]}, (_, _, _, _)) _)) -> Just (Node (Select{from=[parentNodeTable]}, _) _) ->
Node <$> readNode' <*> forest' let newFrom r = (\tName -> if tName == nodeName then tableName (relTable r) else tName) <$> from query
where newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
forest' = updateForest $ hush node' rel :: Either ApiRequestError Relation
node' = Node <$> readNode' <*> pure forest rel = note (NoRelationBetween parentNodeTable nodeName) $
readNode' = addRel readNode <$> rel findRelation schema allRelations nodeName parentNodeTable relationDetail in
rel :: Either ApiRequestError Relation Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
rel = note (NoRelationBetween parentNodeTable name) _ ->
$ findRelation schema name parentNodeTable relationDetail let rn = (query, (nodeName, Just r, alias, Nothing, depth))
where 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
findRelation s nodeTableName parentNodeTableName Nothing = Node rn <$> updateForest (Just $ Node rn forest)
find (\r ->
s == tableSchema (relTable r) && -- match schema for relation table
s == tableSchema (relFTable r) && -- match schema for relation foriegn table
(
-- (request) => projects { ..., clients{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
nodeTableName == tableName (relTable r) && -- match relation table name
parentNodeTableName == tableName (relFTable r) -- match relation foreign table name
) ||
-- (request) => projects { ..., client_id{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
parentNodeTableName == tableName (relFTable r) &&
length (relFColumns r) == 1 &&
nodeTableName `colMatches` (colName . unsafeHead . relFColumns) r
)
-- (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
)
) allRelations
findRelation s nodeTableName parentNodeTableName (Just rd) =
find (\r ->
s == tableSchema (relTable r) && -- match schema for relation table
s == tableSchema (relFTable r) && -- match schema for relation foriegn table
(
-- (request) => clients { ..., project.client_id{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
nodeTableName == tableName (relTable r) && -- match relation table name
parentNodeTableName == tableName (relFTable r) && -- && -- match relation foreign table name
length (relColumns r) == 1 &&
rd == (colName . unsafeHead . relColumns) r
)
||
-- (request) => tasks { ..., users.tasks_users{...} }
-- will match
-- (relation type) => many
-- (entity) => users
-- (foriegn entity) => tasks
(
relType r == Many &&
nodeTableName == tableName (relTable r) && -- match relation table name
parentNodeTableName == tableName (relFTable r) && -- match relation foreign table name
rd == tableName (fromJust (relLTable r))
)
)
) allRelations
n `colMatches` rc = (toS ("^" <> rc <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS n :: BS.ByteString)
addRel :: (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail))
addRel (query', (n, _, a, _)) r = (query' {from=fromRelation}, (n, Just r, a, Nothing))
where fromRelation = map (\t -> if t == n then tableName (relTable r) else t) (from query')
_ -> n' <$> updateForest (Just (n' forest))
where
n' = Node (query, (name, Just r, alias, Nothing))
t = Table schema name Nothing True -- !!! TODO find another way to get the table from the query
r = Relation t [] t [] Root Nothing Nothing Nothing
where where
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest] updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
updateForest n = mapM (addRelations schema allRelations n) forest updateForest rq = mapM (addRelations schema allRelations rq) forest
addJoinFilters :: Schema -> ReadRequest -> Either ApiRequestError ReadRequest findRelation :: Schema -> [Relation] -> NodeName -> TableName -> Maybe RelationDetail -> Maybe Relation
addJoinFilters schema (Node node@(query, nodeProps@(_, relation, _, _)) forest) = 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}
(
nodeTableName == tableName relTable && -- match relation table name
parentNodeTableName == tableName relFTable -- match relation foreign table name
) ||
-- (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)
)
-- (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
addJoinConditions :: Schema -> ReadRequest -> Either ApiRequestError ReadRequest
addJoinConditions schema (Node node@(query, nodeProps@(_, relation, _, _, _)) forest) =
case relation of case relation of
Just Relation{relType=Root} -> Node node <$> updatedForest -- this is the root node 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=Parent} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
Just rel@Relation{relType=Many, relLTable=(Just linkTable)} -> Just rel@Relation{relType=Many, relLinkTable=(Just linkTable)} ->
let rq = augmentQuery rel in let rq = augmentQuery rel in
Node (rq{from=tableName linkTable:from rq}, nodeProps) <$> updatedForest Node (rq{from=tableName linkTable:from rq}, nodeProps) <$> updatedForest
_ -> Left UnknownRelation _ -> Left UnknownRelation
where where
updatedForest = mapM (addJoinFilters schema) forest updatedForest = mapM (addJoinConditions schema) forest
augmentQuery rel = foldr addFilterToReadQuery query (getJoinFilters rel) augmentQuery rel = foldr addJoinCond query (getJoinConditions rel)
addFilterToReadQuery flt rq@Select{where_=lf} = rq{where_=addFilterToLogicForest flt lf}::ReadQuery addJoinCond :: JoinCondition -> ReadQuery -> ReadQuery
addJoinCond jc rq@Select{joinConditions=jcs} = rq{joinConditions=jc:jcs}
getJoinFilters :: Relation -> [Filter] getJoinConditions :: Relation -> [JoinCondition]
getJoinFilters (Relation t cols ft fcs typ lt lc1 lc2) = getJoinConditions (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols typ lt lc1 lc2) =
case typ of if | typ == Child || typ == Parent ->
Child -> zipWith (toFilter tN ftN) cols fcs zipWith (toJoinCondition tN ftN) cols fCols
Parent -> zipWith (toFilter tN ftN) cols fcs | typ == Many ->
Many -> zipWith (toFilter tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toFilter ftN ltN) fcs (fromMaybe [] lc2) let ltN = maybe "" tableName lt in
Root -> undefined --error "undefined getJoinFilters" zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2)
| typ == Root -> witness
where where
s = if typ == Parent then "" else tableSchema t toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
tN = tableName t toJoinCondition tb ftb c fc =
ftN = tableName ft JoinCondition (QualifiedIdentifier tSchema tb, Nothing, colName c)
ltN = fromMaybe "" (tableName <$> lt) (QualifiedIdentifier tSchema ftb, Nothing, colName fc)
toFilter :: Text -> Text -> Column -> Column -> Filter
toFilter tb ftb c fc = Filter (colName c, Nothing) (OpExpr False (Join (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}})))
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest) addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
@@ -254,7 +273,7 @@ addFilter :: (EmbedPath, Filter) -> ReadRequest -> ReadRequest
addFilter = addProperty addFilterToNode addFilter = addProperty addFilterToNode
addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest
addOrderToNode o (Node (q,i) f) = Node (q{order=Just o}, i) f addOrderToNode o (Node (q,i) f) = Node (q{order=o}, i) f
addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest
addOrder = addProperty addOrderToNode addOrder = addProperty addOrderToNode
@@ -272,53 +291,35 @@ addLogicTree :: (EmbedPath, LogicTree) -> ReadRequest -> ReadRequest
addLogicTree = addProperty addLogicTreeToNode addLogicTree = addProperty addLogicTreeToNode
addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest
addProperty f ([], a) n = f a n addProperty f ([], a) rr = f a rr
addProperty f (path, a) (Node rn forest) = addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
case targetNode of case pathNode of
Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path
Just tn -> Node rn (addProperty f (remainingPath, a) tn:restForest) Just tn -> Node rn (addProperty f (remainingPath, a) tn:delete tn forest)
where where
targetNodeName:remainingPath = path pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
(targetNode,restForest) = splitForest targetNodeName forest
splitForest :: NodeName -> Forest ReadNode -> (Maybe ReadRequest, Forest ReadNode)
splitForest name forst =
case maybeNode of
Nothing -> (Nothing,forest)
Just node -> (Just node, delete node forest)
where
maybeNode :: Maybe ReadRequest
maybeNode = find fnd forst
where
fnd :: ReadRequest -> Bool
fnd (Node (_,(n,_,_,_)) _) = n == name
-- in a relation where one of the tables mathces "TableName" mutateRequest :: ApiRequest -> TableName -> [Text] -> [FieldName] -> Either Response MutateRequest
-- replace the name to that table with pg_source mutateRequest apiRequest tName pkCols fldNames = mapLeft apiRequestError $
-- this "fake" relations is needed so that in a mutate query
-- we can look a 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 {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
| otherwise = Nothing
mutateRequest :: ApiRequest -> [FieldName] -> Either Response MutateRequest
mutateRequest apiRequest fldNames = mapLeft apiRequestError $
case action of case action of
ActionCreate -> Right $ Insert rootTableName payload returnings ActionCreate -> Right $ Insert tName pkCols payload (iPreferResolution apiRequest) [] returnings
ActionUpdate -> Update rootTableName <$> pure payload <*> combinedLogic <*> pure returnings ActionUpdate -> Update tName payload <$> combinedLogic <*> pure returnings
ActionDelete -> Delete rootTableName <$> combinedLogic <*> pure returnings ActionSingleUpsert ->
_ -> Left UnsupportedVerb (\flts ->
if null (iLogic apiRequest) &&
S.fromList (fst <$> iFilters apiRequest) == S.fromList pkCols &&
not (null (S.fromList pkCols)) &&
all (\case
Filter _ (OpExpr False (Op "eq" _)) -> True
_ -> False) flts
then Insert tName pkCols payload (Just MergeDuplicates) <$> combinedLogic <*> pure returnings
else
Left InvalidFilters) =<< filters
ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings
_ -> Left UnsupportedVerb
where where
action = iAction apiRequest action = iAction apiRequest
payload = fromJust $ iPayload apiRequest payload = fromJust $ iPayload apiRequest
rootTableName = -- TODO: Make it safe
let target = iTarget apiRequest in
case target of
(TargetIdent (QualifiedIdentifier _ t) ) -> t
_ -> undefined
returnings = if iPreferRepresentation apiRequest == None then [] else fldNames returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
filters = map snd <$> mapM pRequestFilter mutateFilters filters = map snd <$> mapM pRequestFilter mutateFilters
logic = map snd <$> mapM pRequestLogicTree logicFilters logic = map snd <$> mapM pRequestLogicTree logicFilters
@@ -327,17 +328,12 @@ mutateRequest apiRequest fldNames = mapLeft apiRequestError $
(mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest) (mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst) onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
readRpcRequest :: ApiRequest -> Either Response [RpcQParam]
readRpcRequest apiRequest = mapLeft apiRequestError rpcQParams
where
rpcQParams = mapM pRequestRpcQParam $ iRpcQParams apiRequest
fieldNames :: ReadRequest -> [FieldName] fieldNames :: ReadRequest -> [FieldName]
fieldNames (Node (sel, _) forest) = fieldNames (Node (sel, _) forest) =
map (fst . view _1) (select sel) ++ map colName fks map (fst . view _1) (select sel) ++ map colName fks
where where
fks = concatMap (fromMaybe [] . f) forest fks = concatMap (fromMaybe [] . f) forest
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _)) _) = Just cols f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _, _)) _) = Just cols
f _ = Nothing f _ = Nothing
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree -- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
+218 -195
View File
@@ -3,6 +3,7 @@
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE TypeSynonymInstances #-}
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.DbStructure ( module PostgREST.DbStructure (
getDbStructure getDbStructure
, accessibleTables , accessibleTables
@@ -13,40 +14,43 @@ module PostgREST.DbStructure (
import qualified Hasql.Decoders as HD import qualified Hasql.Decoders as HD
import qualified Hasql.Encoders as HE import qualified Hasql.Encoders as HE
import qualified Hasql.Query as H import qualified Hasql.Statement as H
import Control.Applicative import Control.Applicative
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import Data.List (elemIndex) import qualified Data.List as L
import Data.Maybe (fromJust) import Data.Set as S (fromList)
import Data.Text (split, strip, import Data.Text (split, strip,
breakOn, dropAround, splitOn) breakOn, dropAround,
splitOn)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Hasql.Session as H import qualified Hasql.Session as H
import qualified Hasql.Transaction as HT
import PostgREST.Types import PostgREST.Types
import Text.InterpolatedString.Perl6 (q) import Text.InterpolatedString.Perl6 (q, qc)
import GHC.Exts (groupWith) import GHC.Exts (groupWith)
import Protolude import Protolude
import Unsafe (unsafeHead) import Unsafe (unsafeHead)
getDbStructure :: Schema -> PgVersion -> H.Session DbStructure getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure
getDbStructure schema pgVer = do getDbStructure schema pgVer = do
tabs <- H.query () allTables HT.sql "set local schema ''" -- for getting the fully qualified name(schema.name) of every db object
cols <- H.query schema $ allColumns tabs tabs <- HT.statement () allTables
syns <- H.query () $ allSynonyms cols cols <- HT.statement schema $ allColumns tabs
rels <- H.query () $ allRelations tabs cols syns <- HT.statement schema $ allSynonyms cols pgVer
keys <- H.query () $ allPrimaryKeys tabs childRels <- HT.statement () $ allChildRelations tabs cols
procs <- H.query schema allProcs keys <- HT.statement () $ allPrimaryKeys tabs
procs <- HT.statement schema allProcs
let rels' = (addManyToManyRelations . raiseRelations schema syns . addParentRelations . addSynonymousRelations syns) rels let rels = addManyToManyRelations . addParentRelations $ addViewChildRelations syns childRels
cols' = addForeignKeys rels' cols cols' = addForeignKeys rels cols
keys' = synonymousPrimaryKeys syns keys keys' = addViewPrimaryKeys syns keys
return DbStructure { return DbStructure {
dbTables = tabs dbTables = tabs
, dbColumns = cols' , dbColumns = cols'
, dbRelations = rels' , dbRelations = rels
, dbPrimaryKeys = keys' , dbPrimaryKeys = keys'
, dbProcs = procs , dbProcs = procs
, pgVersion = pgVer , pgVersion = pgVer
@@ -54,69 +58,70 @@ getDbStructure schema pgVer = do
decodeTables :: HD.Result [Table] decodeTables :: HD.Result [Table]
decodeTables = decodeTables =
HD.rowsList tblRow HD.rowList tblRow
where where
tblRow = Table <$> HD.value HD.text tblRow = Table <$> HD.column HD.text
<*> HD.value HD.text <*> HD.column HD.text
<*> HD.nullableValue HD.text <*> HD.nullableColumn HD.text
<*> HD.value HD.bool <*> HD.column HD.bool
decodeColumns :: [Table] -> HD.Result [Column] decodeColumns :: [Table] -> HD.Result [Column]
decodeColumns tables = decodeColumns tables =
mapMaybe (columnFromRow tables) <$> HD.rowsList colRow mapMaybe (columnFromRow tables) <$> HD.rowList colRow
where where
colRow = colRow =
(,,,,,,,,,,,) (,,,,,,,,,,,)
<$> HD.value HD.text <*> HD.value HD.text <$> HD.column HD.text <*> HD.column HD.text
<*> HD.value HD.text <*> HD.nullableValue HD.text <*> HD.column HD.text <*> HD.nullableColumn HD.text
<*> HD.value HD.int4 <*> HD.value HD.bool <*> HD.column HD.int4 <*> HD.column HD.bool
<*> HD.value HD.text <*> HD.value HD.bool <*> HD.column HD.text <*> HD.column HD.bool
<*> HD.nullableValue HD.int4 <*> HD.nullableColumn HD.int4
<*> HD.nullableValue HD.int4 <*> HD.nullableColumn HD.int4
<*> HD.nullableValue HD.text <*> HD.nullableColumn HD.text
<*> HD.nullableValue HD.text <*> HD.nullableColumn HD.text
decodeRelations :: [Table] -> [Column] -> HD.Result [Relation] decodeRelations :: [Table] -> [Column] -> HD.Result [Relation]
decodeRelations tables cols = decodeRelations tables cols =
mapMaybe (relationFromRow tables cols) <$> HD.rowsList relRow mapMaybe (relationFromRow tables cols) <$> HD.rowList relRow
where where
relRow = (,,,,,) relRow = (,,,,,)
<$> HD.value HD.text <$> HD.column HD.text
<*> HD.value HD.text <*> HD.column HD.text
<*> HD.value (HD.array (HD.arrayDimension replicateM (HD.arrayValue HD.text))) <*> HD.column (HD.array (HD.dimension replicateM (HD.element HD.text)))
<*> HD.value HD.text <*> HD.column HD.text
<*> HD.value HD.text <*> HD.column HD.text
<*> HD.value (HD.array (HD.arrayDimension replicateM (HD.arrayValue HD.text))) <*> HD.column (HD.array (HD.dimension replicateM (HD.element HD.text)))
decodePks :: [Table] -> HD.Result [PrimaryKey] decodePks :: [Table] -> HD.Result [PrimaryKey]
decodePks tables = decodePks tables =
mapMaybe (pkFromRow tables) <$> HD.rowsList pkRow mapMaybe (pkFromRow tables) <$> HD.rowList pkRow
where where
pkRow = (,,) <$> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text pkRow = (,,) <$> HD.column HD.text <*> HD.column HD.text <*> HD.column HD.text
decodeSynonyms :: [Column] -> HD.Result [(Column,Column)] decodeSynonyms :: [Column] -> HD.Result [Synonym]
decodeSynonyms cols = decodeSynonyms cols =
mapMaybe (synonymFromRow cols) <$> HD.rowsList synRow mapMaybe (synonymFromRow cols) <$> HD.rowList synRow
where where
synRow = (,,,,,) synRow = (,,,,,)
<$> HD.value HD.text <*> HD.value HD.text <$> HD.column HD.text <*> HD.column HD.text
<*> HD.value HD.text <*> HD.value HD.text <*> HD.column HD.text <*> HD.column HD.text
<*> HD.value HD.text <*> HD.value HD.text <*> HD.column HD.text <*> HD.column HD.text
decodeProcs :: HD.Result (M.HashMap Text ProcDescription) decodeProcs :: HD.Result (M.HashMap Text [ProcDescription])
decodeProcs = decodeProcs =
M.fromList . map addName <$> HD.rowsList tblRow -- 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
where where
tblRow = ProcDescription tblRow = ProcDescription
<$> HD.value HD.text <$> HD.column HD.text
<*> HD.nullableValue HD.text <*> HD.nullableColumn HD.text
<*> (parseArgs <$> HD.value HD.text) <*> (parseArgs <$> HD.column HD.text)
<*> (parseRetType <*> (parseRetType
<$> HD.value HD.text <$> HD.column HD.text
<*> HD.value HD.text <*> HD.column HD.text
<*> HD.value HD.bool <*> HD.column HD.bool
<*> HD.value HD.char) <*> HD.column HD.char)
<*> (parseVolatility <$> HD.value HD.char) <*> (parseVolatility <$> HD.column HD.char)
addName :: ProcDescription -> (Text, ProcDescription) addName :: ProcDescription -> (Text, ProcDescription)
addName pd = (pdName pd, pd) addName pd = (pdName pd, pd)
@@ -152,11 +157,11 @@ decodeProcs =
| v == 's' = Stable | v == 's' = Stable
| otherwise = Volatile -- only 'v' can happen here | otherwise = Volatile -- only 'v' can happen here
allProcs :: H.Query Schema (M.HashMap Text ProcDescription) allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
allProcs = H.statement (toS procsSqlQuery) (HE.value HE.text) decodeProcs True allProcs = H.Statement (toS procsSqlQuery) (HE.param HE.text) decodeProcs True
accessibleProcs :: H.Query Schema (M.HashMap Text ProcDescription) accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
accessibleProcs = H.statement (toS sql) (HE.value HE.text) decodeProcs True accessibleProcs = H.Statement (toS sql) (HE.param HE.text) decodeProcs True
where where
sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')" sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"
@@ -179,9 +184,9 @@ procsSqlQuery = [q|
WHERE pn.nspname = $1 WHERE pn.nspname = $1
|] |]
schemaDescription :: H.Query Schema (Maybe Text) schemaDescription :: H.Statement Schema (Maybe Text)
schemaDescription = schemaDescription =
H.statement sql (HE.value HE.text) (join <$> HD.maybeRow (HD.nullableValue HD.text)) True H.Statement sql (HE.param HE.text) (join <$> HD.rowMaybe (HD.nullableColumn HD.text)) True
where where
sql = [q| sql = [q|
select select
@@ -192,9 +197,9 @@ schemaDescription =
where where
n.nspname = $1 |] n.nspname = $1 |]
accessibleTables :: H.Query Schema [Table] accessibleTables :: H.Statement Schema [Table]
accessibleTables = accessibleTables =
H.statement sql (HE.value HE.text) decodeTables True H.Statement sql (HE.param HE.text) decodeTables True
where where
sql = [q| sql = [q|
select select
@@ -212,7 +217,7 @@ accessibleTables =
join pg_namespace n on n.oid = c.relnamespace join pg_namespace n on n.oid = c.relnamespace
left join pg_catalog.pg_description as d on d.objoid = c.oid and d.objsubid = 0 left join pg_catalog.pg_description as d on d.objoid = c.oid and d.objsubid = 0
where where
c.relkind in ('v', 'r', 'm') c.relkind in ('v', 'r', 'm', 'f')
and n.nspname = $1 and n.nspname = $1
and ( and (
pg_has_role(c.relowner, 'USAGE'::text) pg_has_role(c.relowner, 'USAGE'::text)
@@ -221,18 +226,6 @@ accessibleTables =
) )
order by relname |] order by relname |]
synonymousColumns :: [(Column,Column)] -> [Column] -> [[Column]]
synonymousColumns allSyns cols = synCols'
where
syns = case headMay cols of
Just firstCol -> sort $ filter ((== colTable firstCol) . colTable . fst) allSyns
Nothing -> []
synCols  = transpose $ map (\c -> map snd $ filter ((== c) . fst) syns) cols
synCols' = (filter sameTable . filter matchLength) synCols
matchLength cs = length cols == length cs
sameTable (c:cs) = all (\cc -> colTable c == colTable cc) (c:cs)
sameTable [] = False
addForeignKeys :: [Relation] -> [Column] -> [Column] addForeignKeys :: [Relation] -> [Column] -> [Column]
addForeignKeys rels = map addFk addForeignKeys rels = map addFk
where where
@@ -241,35 +234,88 @@ addForeignKeys rels = map addFk
lookupFn :: Column -> Relation -> Bool lookupFn :: Column -> Relation -> Bool
lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==Child lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==Child
relToFk col Relation{relColumns=cols, relFColumns=colsF} = do relToFk col Relation{relColumns=cols, relFColumns=colsF} = do
pos <- elemIndex col cols pos <- L.elemIndex col cols
colF <- atMay colsF pos colF <- atMay colsF pos
return $ ForeignKey colF return $ ForeignKey colF
addSynonymousRelations :: [(Column,Column)] -> [Relation] -> [Relation] {-
addSynonymousRelations _ [] = [] Adds Views Child Relations based on Synonyms found, the logic is as follows:
addSynonymousRelations syns (rel:rels) = rel : synRelsP ++ synRelsF ++ addSynonymousRelations syns rels
where Having a Relation{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relType=Child} represented by:
synRelsP = synRels (relColumns rel) (\t cs -> rel{relTable=t,relColumns=cs})
synRelsF = synRels (relFColumns rel) (\t cs -> rel{relFTable=t,relFColumns=cs}) t1.c1------t2.c2
synRels cols mapFn = map (\cs -> mapFn (colTable $ unsafeHead cs) cs) $ synonymousColumns syns cols
When only having a t1_view.c1 synonym, we need to add a View to Table Child Relation
t1.c1----t2.c2 t1.c1----------t2.c2
-> ________/
/
t1_view.c1 t1_view.c1
When only having a t2_view.c2 synonym, we need to add a Table to View Child 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
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.
-}
addViewChildRelations :: [Synonym] -> [Relation] -> [Relation]
addViewChildRelations allSyns = concatMap (\rel ->
rel : case rel of
Relation{relType=Child, relTable, relColumns, 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
getView = colTable . snd . unsafeHead
syns `allSynsOf` cols = S.fromList (fst <$> syns) == 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 in
-- View Table Child Relations
[Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns) relFTable relFColumns Child Nothing Nothing Nothing
| syns <- colsSyns, syns `allSynsOf` relColumns] ++
-- Table View Child Relations
[Relation relTable relColumns (getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns) Child Nothing Nothing Nothing
| fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns] ++
-- View View Child Relations
[Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns) (getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns) Child Nothing Nothing Nothing
| syns <- colsSyns, fSyns <- fColsSyns, syns `allSynsOf` relColumns, fSyns `allSynsOf` relFColumns]
_ -> [])
addParentRelations :: [Relation] -> [Relation] addParentRelations :: [Relation] -> [Relation]
addParentRelations [] = [] addParentRelations = concatMap (\rel@(Relation t c ft fc _ _ _ _) -> [rel, Relation ft fc t c Parent Nothing Nothing Nothing])
addParentRelations (rel@(Relation t c ft fc _ _ _ _):rels) = Relation ft fc t c Parent Nothing Nothing Nothing : rel : addParentRelations rels
addManyToManyRelations :: [Relation] -> [Relation] addManyToManyRelations :: [Relation] -> [Relation]
addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation links) addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation links)
where where
links = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==Child). relType) rels links = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==Child). relType) rels
groupFn :: Relation -> Text groupFn :: Relation -> Text
groupFn Relation{relTable=Table{tableSchema=s, tableName=t}} = s<>"_"<>t groupFn Relation{relTable=Table{tableSchema=s, tableName=t}} = s <> "_" <> t
-- Reference : https://wiki.haskell.org/99_questions/Solutions/26 -- Reference : https://wiki.haskell.org/99_questions/Solutions/26
combinations :: Int -> [a] -> [[a]] combinations :: Int -> [a] -> [[a]]
combinations 0 _ = [ [] ] combinations 0 _ = [ [] ]
combinations n xs = [ y:ys | y:xs' <- tails xs combinations n xs = [ y:ys | y:xs' <- tails xs
, ys <- combinations (n-1) xs'] , ys <- combinations (n-1) xs']
addMirrorRelation [] = [] addMirrorRelation = concatMap (\rel@(Relation t c ft fc _ lt lc1 lc2) -> [rel, Relation ft fc t c Many lt lc2 lc1])
addMirrorRelation (rel@(Relation t c ft fc _ lt lc1 lc2):rels') = Relation ft fc t c Many lt lc2 lc1 : rel : addMirrorRelation rels'
link2Relation [ link2Relation [
Relation{relTable=lt, relColumns=lc1, relFTable=t, relFColumns=c}, Relation{relTable=lt, relColumns=lc1, relFTable=t, relFColumns=c},
Relation{ relColumns=lc2, relFTable=ft, relFColumns=fc} Relation{ relColumns=lc2, relFTable=ft, relFColumns=fc}
@@ -278,29 +324,15 @@ addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation
| otherwise = Nothing | otherwise = Nothing
link2Relation _ = Nothing link2Relation _ = Nothing
raiseRelations :: Schema -> [(Column,Column)] -> [Relation] -> [Relation] addViewPrimaryKeys :: [Synonym] -> [PrimaryKey] -> [PrimaryKey]
raiseRelations schema syns = map raiseRel addViewPrimaryKeys syns = concatMap (\pk ->
where let viewPks = (\(_, viewCol) -> PrimaryKey{pkTable=colTable viewCol, pkName=colName viewCol}) <$>
raiseRel rel filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) syns in
| tableSchema table == schema = rel pk : viewPks)
| isJust newCols = rel{relFTable=fromJust newTable,relFColumns=fromJust newCols}
| otherwise = rel
where
cols = relFColumns rel
table = relFTable rel
newCols = listToMaybe $ filter ((== schema) . tableSchema . colTable . unsafeHead) (synonymousColumns syns cols)
newTable = (colTable . unsafeHead) <$> newCols
synonymousPrimaryKeys :: [(Column,Column)] -> [PrimaryKey] -> [PrimaryKey] allTables :: H.Statement () [Table]
synonymousPrimaryKeys _ [] = []
synonymousPrimaryKeys syns (key:keys) = key : newKeys ++ synonymousPrimaryKeys syns keys
where
keySyns = filter ((\c -> colTable c == pkTable key && colName c == pkName key) . fst) syns
newKeys = map ((\c -> PrimaryKey{pkTable=colTable c,pkName=colName c}) . snd) keySyns
allTables :: H.Query () [Table]
allTables = allTables =
H.statement sql HE.unit decodeTables True H.Statement sql HE.unit decodeTables True
where where
sql = [q| sql = [q|
SELECT SELECT
@@ -316,14 +348,14 @@ allTables =
AND (pg_trigger.tgtype::integer & 69) = 69) ) AS insertable AND (pg_trigger.tgtype::integer & 69) = 69) ) AS insertable
FROM pg_class c FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('v','r','m') WHERE c.relkind IN ('v','r','m','f')
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
GROUP BY table_schema, table_name, insertable GROUP BY table_schema, table_name, insertable
ORDER BY table_schema, table_name |] ORDER BY table_schema, table_name |]
allColumns :: [Table] -> H.Query Schema [Column] allColumns :: [Table] -> H.Statement Schema [Column]
allColumns tabs = allColumns tabs =
H.statement sql (HE.value HE.text) (decodeColumns tabs) True H.Statement sql (HE.param HE.text) (decodeColumns tabs) True
where where
sql = [q| sql = [q|
SELECT DISTINCT SELECT DISTINCT
@@ -358,7 +390,7 @@ allColumns tabs =
pg_catalog.pg_namespace n pg_catalog.pg_namespace n
WHERE WHERE
r.contype IN ('f', 'p') r.contype IN ('f', 'p')
AND c.relkind IN ('r', 'v', 'f', 'mv') AND c.relkind IN ('r', 'v', 'f', 'm')
AND r.conrelid = c.oid AND r.conrelid = c.oid
AND c.relnamespace = n.oid AND c.relnamespace = n.oid
AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1) AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1)
@@ -462,7 +494,7 @@ allColumns tabs =
NOT pg_is_other_temp_schema(nc.oid) NOT pg_is_other_temp_schema(nc.oid)
AND a.attnum > 0 AND a.attnum > 0
AND NOT a.attisdropped AND NOT a.attisdropped
AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char"])) 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 = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
/*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/ /*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
) )
@@ -506,11 +538,11 @@ columnFromRow tabs (s, t, n, desc, pos, nul, typ, u, l, p, d, e) = buildColumn <
buildColumn tbl = Column tbl n desc pos nul typ u l p d (parseEnum e) Nothing buildColumn tbl = Column tbl n desc pos nul typ u l p d (parseEnum e) Nothing
table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
parseEnum :: Maybe Text -> [Text] parseEnum :: Maybe Text -> [Text]
parseEnum str = fromMaybe [] $ split (==',') <$> str parseEnum = maybe [] (split (==','))
allRelations :: [Table] -> [Column] -> H.Query () [Relation] allChildRelations :: [Table] -> [Column] -> H.Statement () [Relation]
allRelations tabs cols = allChildRelations tabs cols =
H.statement sql HE.unit (decodeRelations tabs cols) True H.Statement sql HE.unit (decodeRelations tabs cols) True
where where
sql = [q| sql = [q|
SELECT ns1.nspname AS table_schema, SELECT ns1.nspname AS table_schema,
@@ -549,9 +581,9 @@ relationFromRow allTabs allCols (rs, rt, rcs, frs, frt, frcs) =
cols = mapM (findCol rs rt) rcs cols = mapM (findCol rs rt) rcs
colsF = mapM (findCol frs frt) frcs colsF = mapM (findCol frs frt) frcs
allPrimaryKeys :: [Table] -> H.Query () [PrimaryKey] allPrimaryKeys :: [Table] -> H.Statement () [PrimaryKey]
allPrimaryKeys tabs = allPrimaryKeys tabs =
H.statement sql HE.unit (decodePks tabs) True H.Statement sql HE.unit (decodePks tabs) True
where where
sql = [q| sql = [q|
/* /*
@@ -659,82 +691,73 @@ pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
allSynonyms :: [Column] -> H.Query () [(Column,Column)] allSynonyms :: [Column] -> PgVersion -> H.Statement Schema [Synonym]
allSynonyms cols = allSynonyms cols pgVer =
H.statement sql HE.unit (decodeSynonyms cols) True H.Statement sql (HE.param HE.text) (decodeSynonyms cols) True
where -- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
-- query explanation at https://gist.github.com/ruslantalpa/2eab8c930a65e8043d8f where
sql = [q| subselectRegex :: Text
with view_columns as ( subselectRegex | pgVer < pgVersion100 = ":subselect {.*?:constraintDeps <>} :location"
| otherwise = ":subselect {.*?:stmt_len 0} :location"
sql = [qc|
with
views as (
select select
c.oid as view_oid, n.nspname as view_schema,
a.attname::information_schema.sql_identifier as column_name c.relname as view_name,
from pg_attribute a r.ev_action as view_definition
join pg_class c on a.attrelid = c.oid from pg_class c
join pg_namespace nc on c.relnamespace = nc.oid join pg_namespace n on n.oid = c.relnamespace
where join pg_rewrite r on r.ev_class = c.oid
not pg_is_other_temp_schema(nc.oid) where (c.relkind = 'v'::char) and n.nspname = $1
and a.attnum > 0 ),
and not a.attisdropped removed_subselects as(
and (c.relkind = 'v'::"char")
and nc.nspname not in ('information_schema', 'pg_catalog')
),
view_column_usage as (
select distinct
v.oid as view_oid,
nv.nspname::information_schema.sql_identifier as view_schema,
v.relname::information_schema.sql_identifier as view_name,
nt.nspname::information_schema.sql_identifier as table_schema,
t.relname::information_schema.sql_identifier as table_name,
a.attname::information_schema.sql_identifier as column_name,
pg_get_viewdef(v.oid)::information_schema.character_data as view_definition
from pg_namespace nv
join pg_class v on nv.oid = v.relnamespace
join pg_depend dv on v.oid = dv.refobjid
join pg_depend dt on dv.objid = dt.objid
join pg_class t on dt.refobjid = t.oid
join pg_namespace nt on t.relnamespace = nt.oid
join pg_attribute a on t.oid = a.attrelid and dt.refobjsubid = a.attnum
where
nv.nspname not in ('information_schema', 'pg_catalog')
and v.relkind = 'v'::"char"
and dv.refclassid = 'pg_class'::regclass::oid
and dv.classid = 'pg_rewrite'::regclass::oid
and dv.deptype = 'i'::"char"
and dv.refobjid <> dt.refobjid
and dt.classid = 'pg_rewrite'::regclass::oid
and dt.refclassid = 'pg_class'::regclass::oid
and (t.relkind = any (array['r'::"char", 'v'::"char", 'f'::"char"]))
),
candidates as (
select select
vcu.*, view_schema, view_name,
( regexp_replace(view_definition, '{subselectRegex}', '', 'g') as x
select case when match is not null then coalesce(match[8], match[7], match[4]) end from views
from regexp_matches( ),
CONCAT('SELECT ', SPLIT_PART(vcu.view_definition, 'SELECT', 2)), target_lists as(
CONCAT('SELECT.*?((',vcu.table_name,')|(\w+))\.(', vcu.column_name, ')(\s+AS\s+("([^"]+)"|([^, \n\t]+)))?.*?FROM.*?(',vcu.table_schema,'\.|)(\2|',vcu.table_name,'\s+(as\s)?\3)'), select
'nsi' view_schema, view_name,
) match regexp_split_to_array(x, 'targetList') as x
) as view_column_name from removed_subselects
from view_column_usage as vcu ),
) last_target_list_wo_tail as(
select select
c.table_schema, view_schema, view_name,
c.table_name, (regexp_split_to_array(x[array_upper(x, 1)], ':onConflict'))[1] as x
c.column_name as table_column_name, from target_lists
c.view_schema, ),
c.view_name, target_entries as(
c.view_column_name select
from view_columns as vc, candidates as c view_schema, view_name,
where unnest(regexp_split_to_array(x, 'TARGETENTRY')) as entry
vc.view_oid = c.view_oid from last_target_list_wo_tail
and vc.column_name = c.view_column_name ),
order by c.view_schema, c.view_name, c.table_name, c.view_column_name results as(
|] select
view_schema, view_name,
substring(entry from ':resname (.*?) :') as view_colum_name,
substring(entry from ':resorigtbl (.*?) :') as resorigtbl,
substring(entry from ':resorigcol (.*?) :') as resorigcol
from target_entries
)
select
sch.nspname as table_schema,
tbl.relname as table_name,
col.attname as table_column_name,
res.view_schema,
res.view_name,
res.view_colum_name
from results res
join pg_class tbl on tbl.oid::text = res.resorigtbl
join pg_attribute col on col.attrelid = tbl.oid and col.attnum::text = res.resorigcol
join pg_namespace sch on sch.oid = tbl.relnamespace
where resorigtbl <> '0'
order by view_schema, view_name, view_colum_name; |]
synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe (Column,Column) synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe Synonym
synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2 synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
where where
col1 = findCol s1 t1 c1 col1 = findCol s1 t1 c1
@@ -742,7 +765,7 @@ synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
getPgVersion :: H.Session PgVersion getPgVersion :: H.Session PgVersion
getPgVersion = H.query () $ H.statement sql HE.unit versionRow False getPgVersion = H.statement () $ H.Statement sql HE.unit versionRow False
where where
sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')" sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')"
versionRow = HD.singleRow $ PgVersion <$> HD.value HD.int4 <*> HD.value HD.text versionRow = HD.singleRow $ PgVersion <$> HD.column HD.int4 <*> HD.column HD.text
+10 -4
View File
@@ -39,6 +39,7 @@ apiRequestError err =
NoRelationBetween _ _ -> HT.status400 NoRelationBetween _ _ -> HT.status400
InvalidRange -> HT.status416 InvalidRange -> HT.status416
UnknownRelation -> HT.status404 UnknownRelation -> HT.status404
InvalidFilters -> HT.status405
simpleError :: HT.Status -> [Header] -> Text -> Response simpleError :: HT.Status -> [Header] -> Text -> Response
simpleError status hdrs message = simpleError status hdrs message =
@@ -107,6 +108,8 @@ instance JSON.ToJSON ApiRequestError where
"message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)] "message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)]
toJSON UnsupportedVerb = JSON.object [ toJSON UnsupportedVerb = JSON.object [
"message" .= ("Unsupported HTTP verb" :: Text)] "message" .= ("Unsupported HTTP verb" :: Text)]
toJSON InvalidFilters = JSON.object [
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
instance JSON.ToJSON P.UsageError where instance JSON.ToJSON P.UsageError where
toJSON (P.ConnectionError e) = JSON.object [ toJSON (P.ConnectionError e) = JSON.object [
@@ -115,7 +118,10 @@ instance JSON.ToJSON P.UsageError where
"details" .= (toS $ fromMaybe "" e :: Text)] "details" .= (toS $ fromMaybe "" e :: Text)]
toJSON (P.SessionError e) = JSON.toJSON e -- H.Error toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
instance JSON.ToJSON H.Error where instance JSON.ToJSON H.QueryError where
toJSON (H.QueryError _ _ e) = JSON.toJSON e
instance JSON.ToJSON H.CommandError where
toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of
'P':'T':_ -> 'P':'T':_ ->
JSON.object [ JSON.object [
@@ -151,7 +157,7 @@ instance JSON.ToJSON H.Error where
httpStatus :: Bool -> P.UsageError -> HT.Status httpStatus :: Bool -> P.UsageError -> HT.Status
httpStatus _ (P.ConnectionError _) = HT.status503 httpStatus _ (P.ConnectionError _) = HT.status503
httpStatus authed (P.SessionError (H.ResultError (H.ServerError c m _ _))) = httpStatus authed (P.SessionError (H.QueryError _ _ (H.ResultError (H.ServerError c m _ _)))) =
case toS c of case toS c of
'0':'8':_ -> HT.status503 -- pg connection err '0':'8':_ -> HT.status503 -- pg connection err
'0':'9':_ -> HT.status500 -- triggered action exception '0':'9':_ -> HT.status500 -- triggered action exception
@@ -181,5 +187,5 @@ httpStatus authed (P.SessionError (H.ResultError (H.ServerError c m _ _))) =
"42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege "42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m) 'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m)
_ -> HT.status400 _ -> HT.status400
httpStatus _ (P.SessionError (H.ResultError _)) = HT.status500 httpStatus _ (P.SessionError (H.QueryError _ _ (H.ResultError _))) = HT.status500
httpStatus _ (P.SessionError (H.ClientError _)) = HT.status503 httpStatus _ (P.SessionError (H.QueryError _ _ (H.ClientError _))) = HT.status503
+12 -10
View File
@@ -5,7 +5,7 @@
module PostgREST.Middleware where module PostgREST.Middleware where
import Crypto.JWT import Crypto.JWT
import Data.Aeson (Value (..)) import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import qualified Hasql.Transaction as H import qualified Hasql.Transaction as H
@@ -19,9 +19,9 @@ import PostgREST.ApiRequest (ApiRequest(..))
import PostgREST.Auth (JWTAttempt(..)) import PostgREST.Auth (JWTAttempt(..))
import PostgREST.Config (AppConfig (..), corsPolicy) import PostgREST.Config (AppConfig (..), corsPolicy)
import PostgREST.Error (simpleError) import PostgREST.Error (simpleError)
import PostgREST.QueryBuilder (pgFmtLit, unquoted, pgFmtEnvVar) import PostgREST.QueryBuilder (unquoted, pgFmtSetLocal, pgFmtSetLocalSearchPath)
import Protolude hiding (concat, null) import Protolude
runWithClaims :: AppConfig -> JWTAttempt -> runWithClaims :: AppConfig -> JWTAttempt ->
(ApiRequest -> H.Transaction Response) -> (ApiRequest -> H.Transaction Response) ->
@@ -32,18 +32,20 @@ runWithClaims conf eClaims app req =
JWTInvalid e -> return $ unauthed $ show e JWTInvalid e -> return $ unauthed $ show e
JWTMissingSecret -> return $ simpleError status500 [] "Server lacks JWT secret" JWTMissingSecret -> return $ simpleError status500 [] "Server lacks JWT secret"
JWTClaims claims -> do JWTClaims claims -> do
H.sql $ toS.mconcat $ setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql
mapM_ H.sql customReqCheck mapM_ H.sql customReqCheck
app req app req
where where
headersSql = map (pgFmtEnvVar "request.header.") $ iHeaders req headersSql = pgFmtSetLocal "request.header." <$> iHeaders req
cookiesSql = map (pgFmtEnvVar "request.cookie.") $ iCookies req cookiesSql = pgFmtSetLocal "request.cookie." <$> iCookies req
claimsSql = map (pgFmtEnvVar "request.jwt.claim.") [(c,unquoted v) | (c,v) <- M.toList claimsWithRole] claimsSql = pgFmtSetLocal "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
setRoleSql = maybeToList $ appSettingsSql = pgFmtSetLocal mempty <$> configSettings conf
(\r -> "set local role " <> r <> ";") . toS . pgFmtLit . unquoted <$> M.lookup "role" claimsWithRole setRoleSql = maybeToList $ (\x ->
pgFmtSetLocal mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
setSearchPathSql = pgFmtSetLocalSearchPath $ configSchema conf : configExtraSearchPath conf
-- role claim defaults to anon if not specified in jwt -- role claim defaults to anon if not specified in jwt
claimsWithRole = M.union claims (M.singleton "role" anon) claimsWithRole = M.union claims (M.singleton "role" anon)
anon = String . toS $ configAnonRole conf anon = JSON.String . toS $ configAnonRole conf
customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
where where
unauthed message = simpleError unauthed message = simpleError
+14 -5
View File
@@ -42,7 +42,8 @@ makeTableDef pks (t, cs, _) =
(tn, (mempty :: Schema) (tn, (mempty :: Schema)
& description .~ tableDescription t & description .~ tableDescription t
& type_ .~ SwaggerObject & type_ .~ SwaggerObject
& properties .~ fromList (map (makeProperty pks) cs)) & properties .~ fromList (map (makeProperty pks) cs)
& required .~ map colName (filter (not . colNullable) cs))
makeProperty :: [PrimaryKey] -> Column -> (Text, Referenced Schema) makeProperty :: [PrimaryKey] -> Column -> (Text, Referenced Schema)
makeProperty pks c = (colName c, Inline s) makeProperty pks c = (colName c, Inline s)
@@ -59,7 +60,7 @@ makeProperty pks c = (colName c, Inline s)
] ]
d = d =
if length n > 1 then if length n > 1 then
Just $ append (fromMaybe "" ((`append` "\n\n") <$> colDescription c)) (intercalate "\n" n) Just $ append (maybe "" (`append` "\n\n") $ colDescription c) (intercalate "\n" n)
else else
colDescription c colDescription c
s = s =
@@ -197,8 +198,11 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
& at 206 ?~ "Partial Content" & at 206 ?~ "Partial Content"
& at 200 ?~ Inline ((mempty :: Response) & at 200 ?~ Inline ((mempty :: Response)
& description .~ "OK" & description .~ "OK"
& schema ?~ (Ref $ Reference $ tableName t) & schema ?~ Inline (mempty
& type_ .~ SwaggerArray
& items ?~ (SwaggerItemsObject $ Ref $ Reference $ tableName t)
) )
)
postOp = tOp postOp = tOp
& parameters .~ map ref ["body." <> tn, "preferReturn"] & parameters .~ map ref ["body." <> tn, "preferReturn"]
& at 201 ?~ "Created" & at 201 ?~ "Created"
@@ -219,8 +223,13 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
makeProcPathItem :: ProcDescription -> (FilePath, PathItem) makeProcPathItem :: ProcDescription -> (FilePath, PathItem)
makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe) makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe)
where where
-- Use first line of proc description as summary; rest as description (if present)
-- We strip leading newlines from description so that users can include a blank line between summary and description
(pSum, pDesc) = fmap fst &&& fmap (dropWhile (=='\n') . snd) $
breakOn "\n" <$> pdDescription pd
postOp = (mempty :: Operation) postOp = (mempty :: Operation)
& description .~ pdDescription pd & summary .~ pSum
& description .~ mfilter (/="") pDesc
& parameters .~ makeProcParam pd & parameters .~ makeProcParam pd
& tags .~ Set.fromList ["(rpc) " <> pdName pd] & tags .~ Set.fromList ["(rpc) " <> pdName pd]
& produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON] & produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON]
@@ -260,7 +269,7 @@ postgrestSpec pds ti (s, h, p, b) sd pks = (mempty :: Swagger)
& description ?~ d) & description ?~ d)
& externalDocs ?~ ((mempty :: ExternalDocs) & externalDocs ?~ ((mempty :: ExternalDocs)
& description ?~ "PostgREST Documentation" & description ?~ "PostgREST Documentation"
& url .~ URL ("https://postgrest.com/en/" <> docsVersion <> "/api.html")) & url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
& host .~ h' & host .~ h'
& definitions .~ fromList (map (makeTableDef pks) ti) & definitions .~ fromList (map (makeTableDef pks) ti)
& parameters .~ fromList (makeParamDefs ti) & parameters .~ fromList (makeParamDefs ti)
+81 -75
View File
@@ -3,25 +3,27 @@ module PostgREST.Parsers where
import Protolude hiding (try, intercalate, replace, option) import Protolude hiding (try, intercalate, replace, option)
import Control.Monad ((>>)) import Control.Monad ((>>))
import Data.Foldable (foldl1) import Data.Foldable (foldl1)
import Data.Functor (($>))
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import Data.Text (intercalate, replace, strip) import Data.Text (intercalate, replace, strip)
import Data.List (init, last) import Data.List (init, last)
import Data.Tree import Data.Tree
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft)
import PostgREST.RangeQuery (NonnegRange,allRange) import PostgREST.RangeQuery (NonnegRange)
import PostgREST.Types import PostgREST.Types
import Text.ParserCombinators.Parsec hiding (many, (<|>)) import Text.ParserCombinators.Parsec hiding (many, (<|>))
import Text.Parsec.Error import Text.Parsec.Error
import Text.Read (read)
pRequestSelect :: Text -> Text -> Either ApiRequestError ReadRequest pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
pRequestSelect rootName selStr = pRequestSelect selStr =
mapError $ parse (pReadRequest rootName) ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr) mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter) pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper) pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
where where
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
oper = parse (pOpExpr pSingleVal pListVal) ("failed to parse filter (" ++ toS v ++ ")") $ toS v oper = parse (pOpExpr pSingleVal) ("failed to parse filter (" ++ toS v ++ ")") $ toS v
path = fst <$> treePath path = fst <$> treePath
fld = snd <$> treePath fld = snd <$> treePath
@@ -47,69 +49,59 @@ pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree
-- Concat op and v to make pLogicTree argument regular, in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)" -- Concat op and v to make pLogicTree argument regular, in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)"
logicTree = join $ parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") . toS <$> ((<>) <$> op <*> pure v) logicTree = join $ parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") . toS <$> ((<>) <$> op <*> pure v)
pRequestRpcQParam :: (Text, Text) -> Either ApiRequestError RpcQParam
pRequestRpcQParam (k, v) = mapError $ (,) <$> name <*> val
where
name = parse pFieldName ("failed to parse rpc arg name (" ++ toS k ++ ")") $ toS k
val = toS <$> parse (many anyChar) ("failed to parse rpc arg value (" ++ toS v ++ ")") v
ws :: Parser Text ws :: Parser Text
ws = toS <$> many (oneOf " \t") ws = toS <$> many (oneOf " \t")
lexeme :: Parser a -> Parser a lexeme :: Parser a -> Parser a
lexeme p = ws *> p <* ws lexeme p = ws *> p <* ws
pReadRequest :: Text -> Parser ReadRequest
pReadRequest rootNodeName = do
fieldTree <- pFieldForest
return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing, Nothing)) []) fieldTree
where
readQuery = Select [] [rootNodeName] [] Nothing allRange
treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest
treeEntry (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
case fldForest of
[] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) newForest
where
newForest =
foldr treeEntry (Node (Select [] [fn] [] Nothing allRange, (fn, Nothing, alias, relationDetail)) []) fldForest:rForest
pTreePath :: Parser (EmbedPath, Field) pTreePath :: Parser (EmbedPath, Field)
pTreePath = do pTreePath = do
p <- pFieldName `sepBy1` pDelimiter p <- pFieldName `sepBy1` pDelimiter
jp <- optionMaybe pJsonPath jp <- option [] pJsonPath
return (init p, (last p, jp)) return (init p, (last p, jp))
pFieldForest :: Parser [Tree SelectItem] pFieldForest :: Parser [Tree SelectItem]
pFieldForest = pFieldTree `sepBy1` lexeme (char ',') pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
where
pFieldTree :: Parser (Tree SelectItem) pFieldTree :: Parser (Tree SelectItem)
pFieldTree = try (Node <$> pRelationSelect <*> between (char '{') (char '}') pFieldForest) -- TODO: "{}" deprecated pFieldTree = try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) <|>
<|> try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) Node <$> pFieldSelect <*> pure []
<|> Node <$> pFieldSelect <*> pure []
pStar :: Parser Text pStar :: Parser Text
pStar = toS <$> (string "*" *> pure ("*"::ByteString)) pStar = toS <$> (string "*" $> ("*"::ByteString))
pFieldName :: Parser Text pFieldName :: Parser Text
pFieldName = do pFieldName =
matches <- (many1 (letter <|> digit <|> oneOf "_") `sepBy1` dash) <?> "field name (* or [a..z0..9_])" pQuotedValue <|>
return $ intercalate "-" $ map toS matches intercalate "-" . map toS <$> (many1 (letter <|> digit <|> oneOf "_ ") `sepBy1` dash) <?>
"field name (* or [a..z0..9_])"
where where
isDash :: GenParser Char st () isDash :: GenParser Char st ()
isDash = try ( char '-' >> notFollowedBy (char '>') ) isDash = try ( char '-' >> notFollowedBy (char '>') )
dash :: Parser Char dash :: Parser Char
dash = isDash *> pure '-' dash = isDash $> '-'
pJsonPathStep :: Parser Text pJsonPath :: Parser JsonPath
pJsonPathStep = toS <$> try (string "->" *> pFieldName) pJsonPath = many pJsonOperation
where
pJsonOperation :: Parser JsonOperation
pJsonOperation = pJsonArrow <*> pJsonOperand
pJsonPath :: Parser [Text] pJsonArrow =
pJsonPath = (<>) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) ) try (string "->>" $> J2Arrow) <|>
try (string "->" $> JArrow)
pJsonOperand =
let pJKey = JKey . toS <$> pFieldName
pJIdx = JIdx . toS <$> ((:) <$> option '+' (char '-') <*> many1 digit) <* pEnd
pEnd = try (void $ lookAhead (string "->")) <|>
try (void $ lookAhead (string "::")) <|>
try eof in
try pJIdx <|> try pJKey
pField :: Parser Field pField :: Parser Field
pField = lexeme $ (,) <$> pFieldName <*> optionMaybe pJsonPath pField = lexeme $ (,) <$> pFieldName <*> option [] pJsonPath
aliasSeparator :: Parser () aliasSeparator :: Parser ()
aliasSeparator = char ':' >> notFollowedBy (char ':') aliasSeparator = char ':' >> notFollowedBy (char ':')
@@ -134,15 +126,15 @@ pFieldSelect = lexeme $
) )
<|> do <|> do
s <- pStar s <- pStar
return ((s, Nothing), Nothing, Nothing, Nothing) return ((s, []), Nothing, Nothing, Nothing)
pOpExpr :: Parser SingleVal -> Parser ListVal -> Parser OpExpr pOpExpr :: Parser SingleVal -> Parser OpExpr
pOpExpr pSVal pLVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
where where
pOperation :: Parser Operation pOperation :: Parser Operation
pOperation = pOperation =
Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal
<|> In <$> (string "in" *> pDelimiter *> pLVal) <|> In <$> (try (string "in" *> pDelimiter) *> pListVal)
<|> pFts <|> pFts
<?> "operator (eq, gt, ...)" <?> "operator (eq, gt, ...)"
@@ -158,56 +150,53 @@ pSingleVal :: Parser SingleVal
pSingleVal = toS <$> many anyChar pSingleVal = toS <$> many anyChar
pListVal :: Parser ListVal pListVal :: Parser ListVal
pListVal = try (lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')) pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
<|> lexeme pListElement `sepBy1` char ',' -- TODO: "in.3,4,5" deprecated, parens e.g. "in.(3,4,5)" should be used
pListElement :: Parser Text pListElement :: Parser Text
pListElement = try pQuotedValue <|> (toS <$> many (noneOf ",)")) pListElement = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> (toS <$> many (noneOf ",)"))
pQuotedValue :: Parser Text pQuotedValue :: Parser Text
pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"' <* notFollowedBy (noneOf ",)")) pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"')
pDelimiter :: Parser Char pDelimiter :: Parser Char
pDelimiter = char '.' <?> "delimiter (.)" pDelimiter = char '.' <?> "delimiter (.)"
pOrder :: Parser [OrderTerm] pOrder :: Parser [OrderTerm]
pOrder = lexeme pOrderTerm `sepBy` char ',' pOrder = lexeme pOrderTerm `sepBy1` char ','
pOrderTerm :: Parser OrderTerm pOrderTerm :: Parser OrderTerm
pOrderTerm = pOrderTerm = do
try ( do fld <- pField
c <- pField dir <- optionMaybe $
d <- optionMaybe (try $ pDelimiter *> ( try (pDelimiter *> string "asc" $> OrderAsc) <|>
try(string "asc" *> pure OrderAsc) try (pDelimiter *> string "desc" $> OrderDesc)
<|> try(string "desc" *> pure OrderDesc) nls <- optionMaybe pNulls <* pEnd <|>
)) pEnd $> Nothing
nls <- optionMaybe (pDelimiter *> ( return $ OrderTerm fld dir nls
try(string "nullslast" *> pure OrderNullsLast) where
<|> try(string "nullsfirst" *> pure OrderNullsFirst) pNulls = try (pDelimiter *> string "nullsfirst" $> OrderNullsFirst) <|>
)) try (pDelimiter *> string "nullslast" $> OrderNullsLast)
return $ OrderTerm c d nls pEnd = try (void $ lookAhead (char ',')) <|>
) try eof
<|> OrderTerm <$> pField <*> pure Nothing <*> pure Nothing
pLogicTree :: Parser LogicTree pLogicTree :: Parser LogicTree
pLogicTree = Stmnt <$> try pLogicFilter pLogicTree = Stmnt <$> try pLogicFilter
<|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')')) <|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')'))
where where
pLogicFilter :: Parser Filter pLogicFilter :: Parser Filter
pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal pLogicListVal pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal
pNot :: Parser Bool pNot :: Parser Bool
pNot = try (string "not" *> pDelimiter *> pure True) pNot = try (string "not" *> pDelimiter $> True)
<|> pure False <|> pure False
<?> "negation operator (not)" <?> "negation operator (not)"
pLogicOp :: Parser LogicOperator pLogicOp :: Parser LogicOperator
pLogicOp = try (string "and" *> pure And) pLogicOp = try (string "and" $> And)
<|> string "or" *> pure Or <|> string "or" $> Or
<?> "logic operator (and, or)" <?> "logic operator (and, or)"
pLogicSingleVal :: Parser SingleVal pLogicSingleVal :: Parser SingleVal
pLogicSingleVal = try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",)")) pLogicSingleVal = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> try pPgArray <|> (toS <$> many (noneOf ",)"))
where where
-- TODO: "{}" deprecated, after removal pPgArray can be removed
pPgArray :: Parser Text pPgArray :: Parser Text
pPgArray = do pPgArray = do
a <- string "{" a <- string "{"
@@ -215,9 +204,6 @@ pLogicSingleVal = try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",
c <- string "}" c <- string "}"
toS <$> pure (a ++ b ++ c) toS <$> pure (a ++ b ++ c)
pLogicListVal :: Parser ListVal
pLogicListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
pLogicPath :: Parser (EmbedPath, Text) pLogicPath :: Parser (EmbedPath, Text)
pLogicPath = do pLogicPath = do
path <- pFieldName `sepBy1` pDelimiter path <- pFieldName `sepBy1` pDelimiter
@@ -234,3 +220,23 @@ mapError = mapLeft translateError
message = show $ errorPos e message = show $ errorPos e
details = strip $ replace "\n" " " $ toS details = strip $ replace "\n" " " $ toS
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e) $ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
-- Used for the config value "role-claim-key"
pRoleClaimKey :: Text -> Either ApiRequestError JSPath
pRoleClaimKey selStr =
mapError $ parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)
pJSPath :: Parser JSPath
pJSPath = toJSPath <$> (period *> pPath `sepBy` period <* eof)
where
toJSPath :: [(Text, Maybe Int)] -> JSPath
toJSPath = concatMap (\(key, idx) -> JSPKey key : maybeToList (JSPIdx <$> idx))
period = char '.' <?> "period (.)"
pPath :: Parser (Text, Maybe Int)
pPath = (,) <$> pJSPKey <*> optionMaybe pJSPIdx
pJSPKey :: Parser Text
pJSPKey = toS <$> many1 (alphaNum <|> oneOf "_$@") <|> pQuotedValue <?> "attribute name [a..z0..9_$@])"
pJSPIdx :: Parser Int
pJSPIdx = char '[' *> (read <$> many1 digit) <* char ']' <?> "array index [0..n]"
+176 -150
View File
@@ -1,5 +1,8 @@
{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE FlexibleInstances #-}
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-| {-|
Module : PostgREST.QueryBuilder Module : PostgREST.QueryBuilder
Description : PostgREST SQL generating functions. Description : PostgREST SQL generating functions.
@@ -20,25 +23,24 @@ module PostgREST.QueryBuilder (
, requestToCountQuery , requestToCountQuery
, unquoted , unquoted
, ResultsWithCount , ResultsWithCount
, pgFmtEnvVar , pgFmtSetLocal
, pgFmtSetLocalSearchPath
) where ) where
import qualified Hasql.Query as H import qualified Hasql.Statement as H
import qualified Hasql.Encoders as HE import qualified Hasql.Encoders as HE
import qualified Hasql.Decoders as HD import qualified Hasql.Decoders as HD
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import PostgREST.Config (pgVersion96) import PostgREST.RangeQuery (rangeLimit, rangeOffset, allRange)
import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange)
import Data.Functor.Contravariant (contramap)
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import Data.Maybe import Data.Maybe
import qualified Data.Set as S
import Data.Text (intercalate, unwords, replace, isInfixOf, toLower) import Data.Text (intercalate, unwords, replace, isInfixOf, toLower)
import qualified Data.Text as T (map, takeWhile, null) import qualified Data.Text as T (map, takeWhile, null)
import qualified Data.Text.Encoding as T import qualified Data.Text.Encoding as T
import Data.Tree (Tree(..)) import Data.Tree (Tree(..))
import qualified Data.Vector as V
import PostgREST.Types import PostgREST.Types
import Text.InterpolatedString.Perl6 (qc) import Text.InterpolatedString.Perl6 (qc)
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
@@ -46,7 +48,7 @@ import Data.Scientific ( FPFormat (..)
, formatScientific , formatScientific
, isInteger , isInteger
) )
import Protolude hiding (from, intercalate, ord, cast, replace) import Protolude hiding ( intercalate, cast, replace)
import PostgREST.ApiRequest (PreferRepresentation (..)) import PostgREST.ApiRequest (PreferRepresentation (..))
{-| The generic query result format used by API responses. The location header {-| The generic query result format used by API responses. The location header
@@ -56,10 +58,10 @@ import PostgREST.ApiRequest (PreferRepresentation (..))
type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString) type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString)
standardRow :: HD.Row ResultsWithCount standardRow :: HD.Row ResultsWithCount
standardRow = (,,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8 standardRow = (,,,) <$> HD.nullableColumn HD.int8 <*> HD.column HD.int8
<*> HD.value header <*> HD.value HD.bytea <*> HD.column header <*> HD.column HD.bytea
where where
header = HD.array $ HD.arrayDimension replicateM $ HD.arrayValue HD.bytea header = HD.array $ HD.dimension replicateM $ HD.element HD.bytea
noLocationF :: Text noLocationF :: Text
noLocationF = "array[]::text[]" noLocationF = "array[]::text[]"
@@ -74,18 +76,10 @@ decodeStandard =
decodeStandardMay :: HD.Result (Maybe ResultsWithCount) decodeStandardMay :: HD.Result (Maybe ResultsWithCount)
decodeStandardMay = decodeStandardMay =
HD.maybeRow standardRow HD.rowMaybe standardRow
{-| JSON and CSV payloads from the client are given to us as
PayloadJSON (objects who all have the same keys),
and we turn this into an old fasioned JSON array
-}
encodeUniformObjs :: HE.Params PayloadJSON
encodeUniformObjs =
contramap (JSON.Array . V.map JSON.Object . unPayloadJSON) (HE.value HE.json)
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName -> createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName ->
H.Query () ResultsWithCount H.Statement () ResultsWithCount
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField = createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField =
unicodeStatement sql HE.unit decodeStandard False unicodeStatement sql HE.unit decodeStandard False
where where
@@ -105,11 +99,12 @@ createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField
| isJust binaryField = asBinaryF $ fromJust binaryField | isJust binaryField = asBinaryF $ fromJust binaryField
| otherwise = asJsonF | otherwise = asJsonF
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
PreferRepresentation -> [Text] -> PreferRepresentation -> [Text] ->
H.Query PayloadJSON (Maybe ResultsWithCount) H.Statement ByteString (Maybe ResultsWithCount)
createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys = createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys =
unicodeStatement sql encodeUniformObjs decodeStandardMay True unicodeStatement sql (HE.param HE.unknown) decodeStandardMay True
where where
sql = case rep of sql = case rep of
@@ -129,7 +124,7 @@ createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys
"'' AS total_result_set", -- when updateing it does not make sense "'' AS total_result_set", -- when updateing it does not make sense
"pg_catalog.count(_postgrest_t) AS page_total", "pg_catalog.count(_postgrest_t) AS page_total",
if wantHdrs if wantHdrs
then locationF pKeys then "coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")"
else noLocationF <> " AS header", else noLocationF <> " AS header",
if rep == Full if rep == Full
then bodyF <> " AS body" then bodyF <> " AS body"
@@ -142,14 +137,18 @@ createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys
| otherwise = asJsonF | otherwise = asJsonF
type ProcResults = (Maybe Int64, Int64, ByteString, ByteString) type ProcResults = (Maybe Int64, Int64, ByteString, ByteString)
callProc :: QualifiedIdentifier -> JSON.Object -> Bool -> SqlQuery -> SqlQuery -> Bool -> callProc :: QualifiedIdentifier -> [PgArg] -> Bool -> SqlQuery -> SqlQuery -> Bool ->
Bool -> Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion -> H.Query () (Maybe ProcResults) Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> Bool -> PgVersion ->
callProc qi params returnsScalar selectQuery countQuery countTotal isSingle paramsAsJson asCsv asBinary isReadOnly binaryField pgVer = H.Statement ByteString (Maybe ProcResults)
unicodeStatement sql HE.unit decodeProc True callProc qi pgArgs returnsScalar selectQuery countQuery countTotal isSingle paramsAsSingleObject asCsv asBinary binaryField isObject pgVer =
unicodeStatement sql (HE.param HE.unknown) decodeProc True
where where
sql = sql =
if returnsScalar then [qc| if returnsScalar then [qc|
WITH {sourceCTEName} AS (select {fromQi qi}({_args})) WITH {argsRecord},
{sourceCTEName} AS (
SELECT {fromQi qi}({args})
)
SELECT SELECT
{countResultF} AS total_result_set, {countResultF} AS total_result_set,
1 AS page_total, 1 AS page_total,
@@ -157,7 +156,10 @@ callProc qi params returnsScalar selectQuery countQuery countTotal isSingle para
{responseHeaders} AS response_headers {responseHeaders} AS response_headers
FROM ({selectQuery}) _postgrest_t;|] FROM ({selectQuery}) _postgrest_t;|]
else [qc| else [qc|
WITH {sourceCTEName} AS (select * from {fromQi qi}({_args})) WITH {argsRecord},
{sourceCTEName} AS (
SELECT * FROM {fromQi qi}({args})
)
SELECT SELECT
{countResultF} AS total_result_set, {countResultF} AS total_result_set,
pg_catalog.count(_postgrest_t) AS page_total, pg_catalog.count(_postgrest_t) AS page_total,
@@ -165,19 +167,24 @@ callProc qi params returnsScalar selectQuery countQuery countTotal isSingle para
{responseHeaders} AS response_headers {responseHeaders} AS response_headers
FROM ({selectQuery}) _postgrest_t;|] FROM ({selectQuery}) _postgrest_t;|]
(argsRecord, args) | paramsAsSingleObject = ("_args_record AS (SELECT NULL)", "$1::json")
| null pgArgs = (ignoredBody, "")
| otherwise = (
unwords [
"_args_record AS (",
"SELECT * FROM " <> (if isObject then "json_to_record" else "json_to_recordset") <> "($1)",
"AS _(" <> intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " " <> pgaType a) <$> pgArgs) <> ")",
")"]
, intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " := (SELECT " <> pgFmtIdent (pgaName a) <> " FROM _args_record)") <$> pgArgs))
countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text
_args = if paramsAsJson && not isReadOnly
then insertableValueWithType "json" $ JSON.Object params
else intercalate "," $ map _assignment (HM.toList params)
_procName = qiName qi _procName = qiName qi
_assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v
responseHeaders = responseHeaders =
if pgVer >= pgVersion96 if pgVer >= pgVersion96
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15 then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
else "'[]'" :: Text else "'[]'" :: Text
decodeProc = HD.maybeRow procRow decodeProc = HD.rowMaybe procRow
procRow = (,,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8 procRow = (,,,) <$> HD.nullableColumn HD.int8 <*> HD.column HD.int8
<*> HD.value HD.bytea <*> HD.value HD.bytea <*> HD.column HD.bytea <*> HD.column HD.bytea
scalarBodyF scalarBodyF
| asBinary = asBinaryF _procName | asBinary = asBinaryF _procName
| otherwise = "(row_to_json(_postgrest_t)->" <> pgFmtLit _procName <> ")::character varying" | otherwise = "(row_to_json(_postgrest_t)->" <> pgFmtLit _procName <> ")::character varying"
@@ -201,66 +208,62 @@ pgFmtLit x =
else slashed else slashed
requestToCountQuery :: Schema -> DbRequest -> SqlQuery requestToCountQuery :: Schema -> DbRequest -> SqlQuery
requestToCountQuery _ (DbMutate _) = undefined requestToCountQuery _ (DbMutate _) = witness
requestToCountQuery schema (DbRead (Node (Select _ _ logicForest _ _, (mainTbl, _, _, _)) _)) = requestToCountQuery schema (DbRead (Node (Select{where_=logicForest}, (mainTbl, _, _, _, _)) _)) =
unwords [ unwords [
"SELECT pg_catalog.count(*)", "SELECT pg_catalog.count(*)",
"FROM ", fromQi qi, "FROM ", fromQi qi,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) filteredLogic)) `emptyOnFalse` null filteredLogic ("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest
] ]
where where
qi = removeSourceCTESchema schema mainTbl qi = removeSourceCTESchema schema mainTbl
-- all foreing key filters are root nodes(see addFilterToLogicForest), only those are filtered
nonFKRoot :: LogicTree -> Bool
nonFKRoot (Stmnt (Filter _ (OpExpr _ (Join _ _)))) = False
nonFKRoot (Stmnt _) = True
nonFKRoot Expr{} = True
filteredLogic = filter nonFKRoot logicForest
requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
requestToQuery schema isParent (DbRead (Node (Select colSelects tbls logicForest ord range, (nodeName, maybeRelation, _, _)) forest)) = requestToQuery schema isParent (DbRead (Node (Select colSelects tbls logicForest joinConditions_ ordts range, (nodeName, maybeRelation, _, _, depth)) forest)) =
query unwords [
"SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
"FROM " <> intercalate ", " tables,
unwords joins,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConds))
`emptyOnFalse` (null logicForest && null joinConds),
("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts,
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (isParent || range == allRange) ]
where where
mainTbl = fromMaybe nodeName (tableName . relTable <$> maybeRelation) mainTbl = maybe nodeName (tableName . relTable) maybeRelation
qi = removeSourceCTESchema schema mainTbl isSelfJoin = maybe False (\r -> relType r /= Root && relTable r == relFTable r) maybeRelation
toQi = removeSourceCTESchema schema (qi, tables, joinConds) =
query = unwords [ let depthAlias name dpth = if dpth /= 0 then name <> "_" <> show dpth else name in -- Root node doesn't get aliased
"SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects), if isSelfJoin
"FROM ", intercalate ", " (map (fromQi . toQi) tbls), then (
unwords joins, QualifiedIdentifier "" (depthAlias mainTbl depth),
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest, (\t -> fromQi (removeSourceCTESchema schema t) <> " AS " <> pgFmtIdent (depthAlias t depth)) <$> tbls,
orderF (fromMaybe [] ord), (\(JoinCondition (qi1, _, c1) (qi2, _, c2)) ->
if isParent then "" else limitF range JoinCondition (qi1, Just $ depthAlias (qiName qi1) depth, c1)
] (qi2, Just $ depthAlias (qiName qi2) (depth - 1), c2)) <$> joinConditions_)
orderF ts = else (
if null ts removeSourceCTESchema schema mainTbl,
then "" fromQi . removeSourceCTESchema schema <$> tbls,
else "ORDER BY " <> clause joinConditions_)
where
clause = intercalate "," (map queryTerm ts)
queryTerm :: OrderTerm -> Text
queryTerm t = " "
<> toS (pgFmtField qi $ otTerm t) <> " "
<> maybe "" show (otDirection t) <> " "
<> maybe "" show (otNullOrder t) <> " "
(joins, selects) = foldr getQueryParts ([],[]) forest (joins, selects) = foldr getQueryParts ([],[]) forest
getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment]) 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) getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
where where
sel = "COALESCE((" sel = "COALESCE(("
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) " <> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table <> "FROM (" <> subquery <> ") " <> pgFmtIdent table
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias) <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
where subquery = requestToQuery schema False (DbRead (Node n forst)) 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) getQueryParts (Node n@(_, (name, Just Relation{relType=Parent,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (joi:j,sel:s)
where where
aliasOrName = fromMaybe name alias aliasOrName = fromMaybe name alias
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName
joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE " joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE "
where subquery = requestToQuery schema True (DbRead (Node n forst)) 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) getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
where where
sel = "COALESCE ((" sel = "COALESCE (("
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) " <> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
@@ -270,45 +273,65 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbls logicForest
--the following is just to remove the warning --the following is just to remove the warning
--getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only --getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only
--posible relations are Child Parent Many --posible relations are Child Parent Many
getQueryParts _ _ = undefined getQueryParts _ _ = witness
requestToQuery schema _ (DbMutate (Insert mainTbl (PayloadJSON rows) returnings)) = requestToQuery schema _ (DbMutate (Insert mainTbl pkCols p@(PayloadJSON _ pType pKeys) onConflct logicForest returnings)) =
insInto <> vals <> ret unwords [
where qi = QualifiedIdentifier schema mainTbl ("WITH " <> ignoredBody) `emptyOnFalse` not payloadIsEmpty,
cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0)) "INSERT INTO ", fromQi qi, if payloadIsEmpty then " " else "(" <> cols <> ")",
colsString = intercalate ", " cols case (pType, payloadIsEmpty) of
insInto = unwords [ "INSERT INTO" , fromQi qi, (PJArray _, True) -> "SELECT null WHERE false"
if T.null colsString then "" else "(" <> colsString <> ")" (PJObject, True) -> "DEFAULT VALUES"
] _ -> unwords [
vals = unwords $ "SELECT " <> cols <> " FROM",
if T.null colsString case pType of
then if V.null rows then ["SELECT null WHERE false"] else ["DEFAULT VALUES"] PJObject -> "json_populate_record"
else ["SELECT", colsString, "FROM json_populate_recordset(null::" , fromQi qi, ", $1)"] PJArray _ -> "json_populate_recordset", "(null::", fromQi qi, ", $1) _",
ret = if null returnings -- Only used for PUT
then "" ("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier "" "_") <$> logicForest)) `emptyOnFalse` null logicForest],
else unwords [" RETURNING ", intercalate ", " (map (pgFmtColumn qi) returnings)] maybe "" (\x -> (
requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) logicForest returnings)) = "ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> pkCols) <> ") " <> case x of
case rows V.!? 0 of IgnoreDuplicates ->
Just obj -> "DO NOTHING"
let assignments = map MergeDuplicates ->
(\(k,v) -> pgFmtIdent k <> "=" <> insertableValue v) $ HM.toList obj in "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList pKeys)
) `emptyOnFalse` null pkCols) onConflct,
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings]
where
qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " $ pgFmtIdent <$> S.toList pKeys
payloadIsEmpty = pjIsEmpty p
requestToQuery schema _ (DbMutate (Update mainTbl p@(PayloadJSON _ pType keys) logicForest returnings)) =
if pjIsEmpty p
then "WITH " <> ignoredBody <> "SELECT ''"
else
unwords [ unwords [
"UPDATE ", fromQi qi, "UPDATE " <> fromQi qi <> " SET " <> cols,
" SET " <> intercalate "," assignments <> " ", "FROM (SELECT * FROM ",
case pType of
PJObject -> " json_populate_record"
PJArray _ -> " json_populate_recordset", "(null::", fromQi qi, ", $1)) _ ",
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest, ("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest,
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
] ]
Nothing -> undefined
where where
qi = QualifiedIdentifier schema mainTbl qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList keys)
requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) = requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) =
query 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
]
where where
qi = QualifiedIdentifier schema mainTbl qi = QualifiedIdentifier schema mainTbl
query = unwords [
"DELETE FROM ", fromQi qi, -- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest, -- otherwise the query will err with a `could not determine data type of parameter $1`.
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings -- 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) "
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
@@ -345,24 +368,12 @@ asBinaryF :: FieldName -> SqlFragment
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')" asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
locationF :: [Text] -> SqlFragment locationF :: [Text] -> SqlFragment
locationF pKeys = locationF pKeys = [qc|(
"(" <> WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
" WITH s AS (SELECT row_to_json(ss) as r from " <> sourceCTEName <> " as ss limit 1)" <> SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
" 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
" FROM s, json_each_text(s.r) AS json_data" <> {("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}
( )|]
if null pKeys
then ""
else " WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')"
) <> ")"
limitF :: NonnegRange -> SqlFragment
limitF r = if r == allRange
then ""
else "LIMIT " <> limit <> " OFFSET " <> offset
where
limit = maybe "ALL" show $ rangeLimit r
offset = show $ rangeOffset r
fromQi :: QualifiedIdentifier -> SqlFragment fromQi :: QualifiedIdentifier -> SqlFragment
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
@@ -370,20 +381,12 @@ fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
n = qiName t n = qiName t
s = qiSchema t s = qiSchema t
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
unicodeStatement = H.statement . T.encodeUtf8 unicodeStatement = H.Statement . T.encodeUtf8
emptyOnFalse :: Text -> Bool -> Text emptyOnFalse :: Text -> Bool -> Text
emptyOnFalse val cond = if cond then "" else val emptyOnFalse val cond = if cond then "" else val
insertableValue :: JSON.Value -> SqlFragment
insertableValue JSON.Null = "null"
insertableValue v = (<> "::unknown") . pgFmtLit $ unquoted v
insertableValueWithType :: Text -> JSON.Value -> SqlFragment
insertableValueWithType t v =
pgFmtLit (unquoted v) <> "::" <> t
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
pgFmtColumn table "*" = fromQi table <> ".*" pgFmtColumn table "*" = fromQi table <> ".*"
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
@@ -392,8 +395,14 @@ pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
pgFmtSelectItem table (f@(_, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs jp alias pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
pgFmtSelectItem table (f@(_, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs 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 :: QualifiedIdentifier -> Filter -> SqlFragment
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
@@ -405,7 +414,7 @@ pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper
In vals -> pgFmtField table fld <> " " <> In vals -> pgFmtField table fld <> " " <>
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')" 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 case (&&) (length vals == 1) . T.null <$> headMay vals of
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") " Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
Just True -> emptyValForIn Just True -> emptyValForIn
Nothing -> emptyValForIn Nothing -> emptyValForIn
@@ -416,9 +425,6 @@ pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper
<> maybe "" ((<> ", ") . pgFmtLit) lang <> maybe "" ((<> ", ") . pgFmtLit) lang
<> unknownLiteral val <> unknownLiteral val
<> ") " <> ") "
Join fQi (ForeignKey Column{colTable=Table{tableName=fTableName}, colName=fColName}) ->
pgFmtField fQi fld <> " = " <> pgFmtColumn (removeSourceCTESchema (qiSchema fQi) fTableName) fColName
where where
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
sqlOperator o = HM.lookupDefault "=" o operators sqlOperator o = HM.lookupDefault "=" o operators
@@ -430,26 +436,46 @@ pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper
(toS (pgFmtLit v) <> "::unknown ") (toS (pgFmtLit v) <> "::unknown ")
(find ((==) . toLower $ v) ["null","true","false"]) (find ((==) . toLower $ v) ["null","true","false"])
pgFmtJoinCondition :: JoinCondition -> SqlFragment
pgFmtJoinCondition (JoinCondition (qi, al1, col1) (QualifiedIdentifier schema fTable, al2, col2)) =
pgFmtColumn (fromMaybe qi $ aliasToQi al1) col1 <> " = " <>
pgFmtColumn (fromMaybe (removeSourceCTESchema schema fTable) $ aliasToQi al2) col2
where
aliasToQi al = QualifiedIdentifier "" <$> al
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")" pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"
where notOp = if hasNot then "NOT" else "" where notOp = if hasNot then "NOT" else ""
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt
pgFmtJsonPath :: Maybe JsonPath -> SqlFragment pgFmtJsonPath :: JsonPath -> SqlFragment
pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x pgFmtJsonPath = \case
pgFmtJsonPath (Just (x:xs)) = "->" <> pgFmtLit x <> pgFmtJsonPath ( Just xs ) [] -> ""
pgFmtJsonPath _ = "" (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 :: Maybe JsonPath -> Maybe Alias -> SqlFragment pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment
pgFmtAs Nothing Nothing = "" pgFmtAs _ [] Nothing = ""
pgFmtAs (Just xx) Nothing = case lastMay xx of pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
Just alias -> " AS " <> pgFmtIdent alias 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 -> "" Nothing -> ""
pgFmtAs _ (Just alias) = " AS " <> pgFmtIdent alias pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
pgFmtEnvVar :: Text -> (Text, Text) -> SqlFragment pgFmtSetLocal :: Text -> (Text, Text) -> SqlFragment
pgFmtEnvVar prefix (k, v) = pgFmtSetLocal prefix (k, v) =
"set local " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";" "SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
pgFmtSetLocalSearchPath :: [Text] -> SqlFragment
pgFmtSetLocalSearchPath vals =
"SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
trimNullChars :: Text -> Text trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0') trimNullChars = T.takeWhile (/= '\x0')
+3 -4
View File
@@ -32,14 +32,13 @@ rangeParse range = do
case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of
Just parsedRange -> Just parsedRange ->
let [_, mLower, mUpper] = readMaybe . toS <$> parsedRange let [_, mLower, mUpper] = readMaybe . toS <$> parsedRange
lower = fromMaybe emptyRange (rangeGeq <$> mLower) lower = maybe emptyRange rangeGeq mLower
upper = fromMaybe allRange (rangeLeq <$> mUpper) in upper = maybe allRange rangeLeq mUpper in
rangeIntersection lower upper rangeIntersection lower upper
Nothing -> allRange Nothing -> allRange
rangeRequested :: RequestHeaders -> NonnegRange rangeRequested :: RequestHeaders -> NonnegRange
rangeRequested headers = fromMaybe allRange $ rangeRequested headers = maybe allRange rangeParse $ lookup hRange headers
rangeParse <$> lookup hRange headers
restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange
restrictRange Nothing r = r restrictRange Nothing r = r
+105 -40
View File
@@ -2,12 +2,12 @@
module PostgREST.Types where module PostgREST.Types where
import Protolude import Protolude
import qualified GHC.Show import qualified GHC.Show
import Data.Aeson import qualified Data.Aeson as JSON
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import qualified Data.Set as S
import Data.Tree import Data.Tree
import qualified Data.Vector as V
import PostgREST.RangeQuery (NonnegRange) import PostgREST.RangeQuery (NonnegRange)
import Network.HTTP.Types.Header (hContentType, Header) import Network.HTTP.Types.Header (hContentType, Header)
@@ -23,29 +23,44 @@ data ApiRequestError = ActionInappropriate
| UnknownRelation | UnknownRelation
| NoRelationBetween Text Text | NoRelationBetween Text Text
| UnsupportedVerb | UnsupportedVerb
| InvalidFilters
deriving (Show, Eq) deriving (Show, Eq)
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
instance Show PreferResolution where
show MergeDuplicates = "resolution=merge-duplicates"
show IgnoreDuplicates = "resolution=ignore-duplicates"
data DbStructure = DbStructure { data DbStructure = DbStructure {
dbTables :: [Table] dbTables :: [Table]
, dbColumns :: [Column] , dbColumns :: [Column]
, dbRelations :: [Relation] , dbRelations :: [Relation]
, dbPrimaryKeys :: [PrimaryKey] , dbPrimaryKeys :: [PrimaryKey]
, dbProcs :: M.HashMap Text ProcDescription -- ProcDescription is a list because a function can be overloaded
, dbProcs :: M.HashMap Text [ProcDescription]
, pgVersion :: PgVersion , pgVersion :: PgVersion
} deriving (Show, Eq) } deriving (Show, Eq)
-- TODO Table could hold references to all its Columns
tableCols :: DbStructure -> Schema -> TableName -> [Column]
tableCols dbs tSchema tName = filter (\Column{colTable=Table{tableSchema=s, tableName=t}} -> s==tSchema && t==tName) $ dbColumns dbs
-- TODO Table could hold references to all its PrimaryKeys
tablePKCols :: DbStructure -> Schema -> TableName -> [Text]
tablePKCols dbs tSchema tName = pkName <$> filter (\pk -> tSchema == (tableSchema . pkTable) pk && tName == (tableName . pkTable) pk) (dbPrimaryKeys dbs)
data PgArg = PgArg { data PgArg = PgArg {
pgaName :: Text pgaName :: Text
, pgaType :: Text , pgaType :: Text
, pgaReq :: Bool , pgaReq :: Bool
} deriving (Show, Eq) } deriving (Show, Eq, Ord)
data PgType = Scalar QualifiedIdentifier | Composite QualifiedIdentifier deriving (Eq, Show) data PgType = Scalar QualifiedIdentifier | Composite QualifiedIdentifier deriving (Eq, Show, Ord)
data RetType = Single PgType | SetOf PgType deriving (Eq, Show) data RetType = Single PgType | SetOf PgType deriving (Eq, Show, Ord)
data ProcVolatility = Volatile | Stable | Immutable data ProcVolatility = Volatile | Stable | Immutable
deriving (Eq, Show) deriving (Eq, Show, Ord)
data ProcDescription = ProcDescription { data ProcDescription = ProcDescription {
pdName :: Text pdName :: Text
@@ -55,11 +70,17 @@ data ProcDescription = ProcDescription {
, pdVolatility :: ProcVolatility , pdVolatility :: ProcVolatility
} deriving (Show, Eq) } deriving (Show, Eq)
-- 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)
type Schema = Text type Schema = Text
type TableName = Text type TableName = Text
type SqlQuery = Text type SqlQuery = Text
type SqlFragment = Text type SqlFragment = Text
type RequestBody = BL.ByteString
data Table = Table { data Table = Table {
tableSchema :: Schema tableSchema :: Schema
@@ -86,7 +107,9 @@ data Column =
, colFK :: Maybe ForeignKey , colFK :: Maybe ForeignKey
} deriving (Show, Ord) } deriving (Show, Ord)
type Synonym = (Column,Column) -- | A view column that refers to a table column
type Synonym = (Column, ViewColumn)
type ViewColumn = Column
data PrimaryKey = PrimaryKey { data PrimaryKey = PrimaryKey {
pkTable :: Table pkTable :: Table
@@ -95,13 +118,13 @@ data PrimaryKey = PrimaryKey {
data OrderDirection = OrderAsc | OrderDesc deriving (Eq) data OrderDirection = OrderAsc | OrderDesc deriving (Eq)
instance Show OrderDirection where instance Show OrderDirection where
show OrderAsc = "asc" show OrderAsc = "ASC"
show OrderDesc = "desc" show OrderDesc = "DESC"
data OrderNulls = OrderNullsFirst | OrderNullsLast deriving (Eq) data OrderNulls = OrderNullsFirst | OrderNullsLast deriving (Eq)
instance Show OrderNulls where instance Show OrderNulls where
show OrderNullsFirst = "nulls first" show OrderNullsFirst = "NULLS FIRST"
show OrderNullsLast = "nulls last" show OrderNullsLast = "NULLS LAST"
data OrderTerm = OrderTerm { data OrderTerm = OrderTerm {
otTerm :: Field otTerm :: Field
@@ -112,7 +135,7 @@ data OrderTerm = OrderTerm {
data QualifiedIdentifier = QualifiedIdentifier { data QualifiedIdentifier = QualifiedIdentifier {
qiSchema :: Schema qiSchema :: Schema
, qiName :: TableName , qiName :: TableName
} deriving (Show, Eq) } deriving (Show, Eq, Ord)
data RelationType = Child | Parent | Many | Root deriving (Show, Eq) data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
@@ -121,6 +144,9 @@ data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
The name 'Relation' here is used with the meaning The name 'Relation' here is used with the meaning
"What is the relation between the current node and the parent node". "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. It has nothing to do with PostgreSQL referring to tables/views as relations.
The order of the relColumns and relFColumns should be maintained to get
the join conditions right.
TODO merge relColumns and relFColumns to a tuple or Data.Bimap
-} -}
data Relation = Relation { data Relation = Relation {
relTable :: Table relTable :: Table
@@ -128,18 +154,29 @@ data Relation = Relation {
, relFTable :: Table , relFTable :: Table
, relFColumns :: [Column] , relFColumns :: [Column]
, relType :: RelationType , relType :: RelationType
, relLTable :: Maybe Table -- The Link attrs are used when RelationType == Many
, relLCols1 :: Maybe [Column] , relLinkTable :: Maybe Table
, relLCols2 :: Maybe [Column] , relLinkCols1 :: Maybe [Column]
, relLinkCols2 :: Maybe [Column]
} deriving (Show, Eq) } deriving (Show, Eq)
-- | An array of JSON objects that has been verified to have -- | Cached attributes of a JSON payload
-- the same keys in every object data PayloadJSON = PayloadJSON {
newtype PayloadJSON = PayloadJSON (V.Vector Object) -- | This is the raw ByteString that comes from the request body.
deriving (Show, Eq) -- We cache this instead of an Aeson Value because it was detected that for large payloads the encoding
-- had high memory usage, see #1005 for more details
pjRaw :: BL.ByteString
, pjType :: PJType
-- | Keys of the object or if it's an array these keys are guaranteed to be the same across all its objects
, pjKeys :: S.Set Text
} deriving (Show, Eq)
unPayloadJSON :: PayloadJSON -> V.Vector Object data PJType = PJArray { pjaLength :: Int } | PJObject deriving (Show, Eq)
unPayloadJSON (PayloadJSON objs) = objs
-- | e.g. whether it is []/{} or not
pjIsEmpty :: PayloadJSON -> Bool
pjIsEmpty (PayloadJSON _ PJObject keys) = S.size keys == 0
pjIsEmpty (PayloadJSON _ (PJArray l) _) = l == 0
data Proxy = Proxy { data Proxy = Proxy {
proxyScheme :: Text proxyScheme :: Text
@@ -168,14 +205,10 @@ operators = M.union (M.fromList [
("sr", ">>"), ("sr", ">>"),
("nxr", "&<"), ("nxr", "&<"),
("nxl", "&>"), ("nxl", "&>"),
("adj", "-|-"), ("adj", "-|-")]) ftsOperators
-- TODO: these are deprecated and should be removed in v0.5.0.0
("@>", "@>"),
("<@", "<@")]) ftsOperators
ftsOperators :: M.HashMap Operator SqlFragment ftsOperators :: M.HashMap Operator SqlFragment
ftsOperators = M.fromList [ ftsOperators = M.fromList [
("@@", "@@ to_tsquery"), -- TODO: '@@' deprecated
("fts", "@@ to_tsquery"), ("fts", "@@ to_tsquery"),
("plfts", "@@ plainto_tsquery"), ("plfts", "@@ plainto_tsquery"),
("phfts", "@@ phraseto_tsquery") ("phfts", "@@ phraseto_tsquery")
@@ -184,8 +217,7 @@ ftsOperators = M.fromList [
data OpExpr = OpExpr Bool Operation deriving (Eq, Show) data OpExpr = OpExpr Bool Operation deriving (Eq, Show)
data Operation = Op Operator SingleVal | data Operation = Op Operator SingleVal |
In ListVal | In ListVal |
Fts Operator (Maybe Language) SingleVal | Fts Operator (Maybe Language) SingleVal deriving (Eq, Show)
Join QualifiedIdentifier ForeignKey deriving (Eq, Show)
type Language = Text type Language = Text
-- | Represents a single value in a filter, e.g. id=eq.singleval -- | Represents a single value in a filter, e.g. id=eq.singleval
@@ -209,8 +241,16 @@ instance Show LogicOperator where
data LogicTree = Expr Bool LogicOperator [LogicTree] | Stmnt Filter deriving (Show, Eq) data LogicTree = Expr Bool LogicOperator [LogicTree] | Stmnt Filter deriving (Show, Eq)
type FieldName = Text type FieldName = Text
type JsonPath = [Text] {-|
type Field = (FieldName, Maybe JsonPath) Json path operations as specified in https://www.postgresql.org/docs/9.4/static/functions-json.html
-}
type JsonPath = [JsonOperation]
-- | Represents the single arrow `->` or double arrow `->>` operators
data JsonOperation = JArrow{jOp :: JsonOperand} | J2Arrow{jOp :: JsonOperand} deriving (Show, Eq)
-- | Represents the key(`->'key'`) or index(`->'1`::int`), the index is Text because we reuse our escaping functons and let pg do the casting with '1'::int
data JsonOperand = JKey{jVal :: Text} | JIdx{jVal :: Text} deriving (Show, Eq)
type Field = (FieldName, JsonPath)
type Alias = Text type Alias = Text
type Cast = Text type Cast = Text
type NodeName = Text type NodeName = Text
@@ -224,10 +264,10 @@ type RpcQParam = (Text, Text)
-} -}
newtype GucHeader = GucHeader (Text, Text) newtype GucHeader = GucHeader (Text, Text)
instance FromJSON GucHeader where instance JSON.FromJSON GucHeader where
parseJSON (Object o) = case headMay (M.toList o) of parseJSON (JSON.Object o) = case headMay (M.toList o) of
Just (k, String s) | M.size o == 1 -> pure $ GucHeader (k, s) Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (k, s)
| otherwise -> mzero | otherwise -> mzero
_ -> mzero _ -> mzero
parseJSON _ = mzero parseJSON _ = mzero
@@ -243,13 +283,17 @@ type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe RelationDetail)
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"] -- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]
type EmbedPath = [Text] type EmbedPath = [Text]
data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq) data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)
data JoinCondition = JoinCondition (QualifiedIdentifier, Maybe Alias, FieldName)
(QualifiedIdentifier, Maybe Alias, FieldName) deriving (Show, Eq)
data ReadQuery = Select { select::[SelectItem], from::[TableName], where_::[LogicTree], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq) data ReadQuery = Select { select::[SelectItem], from::[TableName], where_::[LogicTree], joinConditions::[JoinCondition], order::[OrderTerm], range_::NonnegRange } deriving (Show, Eq)
data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] } data MutateQuery = Insert { in_::TableName, insPkCols::[Text], qPayload::PayloadJSON, onConflict:: Maybe PreferResolution, where_::[LogicTree], returning::[FieldName] }
| Delete { in_::TableName, where_::[LogicTree], returning::[FieldName] } | Delete { in_::TableName, where_::[LogicTree], returning::[FieldName] }
| Update { in_::TableName, qPayload::PayloadJSON, where_::[LogicTree], returning::[FieldName] } deriving (Show, Eq) | Update { in_::TableName, qPayload::PayloadJSON, where_::[LogicTree], returning::[FieldName] } deriving (Show, Eq)
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail)) type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail, Depth))
type ReadRequest = Tree ReadNode type ReadRequest = Tree ReadNode
-- Depth of the ReadRequest tree
type Depth = Integer
type MutateRequest = MutateQuery type MutateRequest = MutateQuery
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
@@ -276,7 +320,28 @@ toMime (CTOther ct) = ct
data PgVersion = PgVersion { data PgVersion = PgVersion {
pgvNum :: Int32 pgvNum :: Int32
, pgvName :: Text , pgvName :: Text
} deriving (Eq, Ord, Show) } deriving (Eq, Show)
instance Ord PgVersion where
(PgVersion v1 _) `compare` (PgVersion v2 _) = v1 `compare` v2
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
minimumPgVersion :: PgVersion
minimumPgVersion = PgVersion 90400 "9.4"
pgVersion95 :: PgVersion
pgVersion95 = PgVersion 90500 "9.5"
pgVersion96 :: PgVersion
pgVersion96 = PgVersion 90600 "9.6"
pgVersion100 :: PgVersion
pgVersion100 = PgVersion 100000 "10"
sourceCTEName :: SqlFragment sourceCTEName :: SqlFragment
sourceCTEName = "pg_source" sourceCTEName = "pg_source"
-- | full jspath, e.g. .property[0].attr.detail
type JSPath = [JSPathExp]
-- | jspath expression, e.g. .property, .property[0] or ."property-dash"
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
+7 -1
View File
@@ -5,7 +5,13 @@ extra-deps:
- hjsonpointer-1.1.1 - hjsonpointer-1.1.1
- hjsonschema-1.5.0.1 - hjsonschema-1.5.0.1
- Ranged-sets-0.3.0 - Ranged-sets-0.3.0
- protolude-0.2 - protolude-0.2.2
- hasql-1.3
- hasql-pool-0.5
- hasql-transaction-0.7
- text-builder-0.5.1.1
- jose-0.7.0.0
- postgresql-libpq-0.9.4.1
ghc-options: ghc-options:
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints
nix: nix:
+7 -26
View File
@@ -27,13 +27,13 @@ spec =
context "embedded levels" $ do context "embedded levels" $ do
it "can do logic on the second level" $ it "can do logic on the second level" $
get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities{id}" `shouldRespondWith` get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities(id)" `shouldRespondWith`
[json|[ [json|[
{"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []}, {"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []},
{"id": 3, "child_entities": []}, {"id": 4, "child_entities": []} {"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
]|] { matchHeaders = [matchContentTypeJson] } ]|] { matchHeaders = [matchContentTypeJson] }
it "can do logic on the third level" $ it "can do logic on the third level" $
get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities{id,grandchild_entities{id}}" `shouldRespondWith` get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities(id,grandchild_entities(id))" `shouldRespondWith`
[json|[ [json|[
{"id": 1, "child_entities": [ { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]}, { "id": 2, "grandchild_entities": []}]}, {"id": 1, "child_entities": [ { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]}, { "id": 2, "grandchild_entities": []}]},
{"id": 2, "child_entities": [ { "id": 3, "grandchild_entities": []} ]}, {"id": 2, "child_entities": [ { "id": 3, "grandchild_entities": []} ]},
@@ -79,15 +79,9 @@ spec =
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }, {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
{"text_search_vector": "'art':4 'spass':5 'unmog':7"} {"text_search_vector": "'art':4 'spass':5 'unmog':7"}
]|] { matchHeaders = [matchContentTypeJson] } ]|] { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated it "can handle cs and cd" $
get "/entities?or=(text_search_vector.@@.bar,text_search_vector.@@.baz)&select=id" `shouldRespondWith`
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle cs and cd" $ do
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith` get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] } [json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated
get "/entities?or=(arr.@>.{1,2,3},arr.<@.{1})&select=id" `shouldRespondWith`
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle range operators" $ do it "can handle range operators" $ do
get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith` get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith`
@@ -120,24 +114,15 @@ spec =
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] } [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
context "operators with not" $ do context "operators with not" $ do
it "eq, cs, like can be negated" $ 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` get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
[json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] } [json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated it "in, is, fts can be negated" $
get "/entities?and=(arr.not.@>.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
[json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
it "in, is, fts can be negated" $ do
get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith` get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith`
[json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] } [json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated it "lt, gte, cd can be negated" $
get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.@@.foo))&select=id" `shouldRespondWith`
[json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
it "lt, gte, cd can be negated" $ do
get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith` get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
[json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] } [json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated
get "/entities?and=(arr.not.<@.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
[json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
it "gt, lte, ilike can be negated" $ it "gt, lte, ilike can be negated" $
get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith` get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`
[json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] } [json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
@@ -182,7 +167,7 @@ spec =
context "used with POST" $ context "used with POST" $
it "includes related data with filters" $ it "includes related data with filters" $
request methodPost "/child_entities?entities.or=(id.eq.2,id.eq.3)&select=id,entities{id}" request methodPost "/child_entities?select=id,entities(id)&entities.or=(id.eq.2,id.eq.3)&entities.order=id"
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
[json|[{"id":4,"name":"entity 4","parent_id":1}, [json|[{"id":4,"name":"entity 4","parent_id":1},
{"id":5,"name":"entity 5","parent_id":2}, {"id":5,"name":"entity 5","parent_id":2},
@@ -208,10 +193,6 @@ spec =
it "can query columns that begin with and/or reserved words" $ it "can query columns that begin with and/or reserved words" $
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200 get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
it "can query jsonb columns" $
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
[json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
it "fails when using IN without () and provides meaningful error message" $ it "fails when using IN without () and provides meaningful error message" $
get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith` get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`
[json|{ [json|{
+1 -1
View File
@@ -8,7 +8,7 @@ import Network.HTTP.Types
import SpecHelper import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Protolude hiding (get) import Protolude
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
+1 -1
View File
@@ -8,7 +8,7 @@ import Network.HTTP.Types
import SpecHelper import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Protolude hiding (get) import Protolude
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
+1 -1
View File
@@ -8,7 +8,7 @@ import Control.Monad.Base
import Control.Monad.Trans.Control import Control.Monad.Trans.Control
import Control.Concurrent.Async (mapConcurrently) import Control.Concurrent.Async (mapConcurrently)
import Test.Hspec hiding (pendingWith) import Test.Hspec
import Test.Hspec.Wai.Internal import Test.Hspec.Wai.Internal
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
+2 -2
View File
@@ -11,7 +11,7 @@ import SpecHelper
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai (Application) import Network.Wai (Application)
import Protolude hiding (get) import Protolude
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
@@ -45,7 +45,7 @@ spec =
"true" "true"
respHeaders `shouldSatisfy` matchHeader respHeaders `shouldSatisfy` matchHeader
"Access-Control-Allow-Methods" "Access-Control-Allow-Methods"
"GET, POST, PATCH, DELETE, OPTIONS, HEAD" "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"
respHeaders `shouldSatisfy` matchHeader respHeaders `shouldSatisfy` matchHeader
"Access-Control-Allow-Headers" "Access-Control-Allow-Headers"
"Authentication, Foo, Bar, Accept, Accept-Language, Content-Language" "Authentication, Foo, Bar, Accept, Accept-Language, Content-Language"
+1 -1
View File
@@ -36,7 +36,7 @@ spec =
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] "" request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|] `shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
it "can embed (parent) entities" $ it "can embed (parent) entities" $
request methodDelete "/tasks?id=eq.8&select=id,name,project{id}" [("Prefer", "return=representation")] "" request methodDelete "/tasks?id=eq.8&select=id,name,project(id)" [("Prefer", "return=representation")] ""
`shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|] `shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"] , matchHeaders = ["Content-Range" <:> "*/*"]
+37
View File
@@ -0,0 +1,37 @@
module Feature.ExtraSearchPathSpec where
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application)
import Protolude
spec :: SpecWith Application
spec = describe "extra search path" $ do
it "finds the ltree <@ operator on the public schema" $
request methodGet "/ltree_sample?path=cd.Top.Science.Astronomy" [] ""
`shouldRespondWith` [json|[
{"path":"Top.Science.Astronomy"},
{"path":"Top.Science.Astronomy.Astrophysics"},
{"path":"Top.Science.Astronomy.Cosmology"}]|]
{ matchHeaders = [matchContentTypeJson] }
it "finds the ltree nlevel function on the public schema, used through a computed column" $
request methodGet "/ltree_sample?select=number_of_labels&path=eq.Top.Science" [] ""
`shouldRespondWith` [json|[{"number_of_labels":2}]|]
{ matchHeaders = [matchContentTypeJson] }
it "finds the isn = operator on the extensions schema" $
request methodGet "/isn_sample?id=eq.978-0-393-04002-9&select=name" [] ""
`shouldRespondWith` [json|[{"name":"Mathematics: From the Birth of Numbers"}]|]
{ matchHeaders = [matchContentTypeJson] }
it "finds the isn is_valid function on the extensions schema" $
request methodGet "/rpc/is_valid_isbn?input=978-0-393-04002-9" [] ""
`shouldRespondWith` [json|true|]
{ matchHeaders = [matchContentTypeJson] }
+16 -12
View File
@@ -49,9 +49,15 @@ spec = do
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
context "non uniform json array" $ do
it "rejects json array that isn't exclusivily composed of objects" $
post "/articles" [json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |] `shouldRespondWith` 400
it "rejects json array that has objects with different keys" $
post "/articles" [json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |] `shouldRespondWith` 400
context "requesting full representation" $ do context "requesting full representation" $ do
it "includes related data after insert" $ it "includes related data after insert" $
request methodPost "/projects?select=id,name,clients{id,name}" request methodPost "/projects?select=id,name,clients(id,name)"
[("Prefer", "return=representation"), ("Prefer", "count=exact")] [("Prefer", "return=representation"), ("Prefer", "count=exact")]
[str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [str|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|] [str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [str|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]
{ matchStatus = 201 { matchStatus = 201
@@ -384,16 +390,6 @@ spec = do
[json| [{ a: "keepme", b: null }] |] [json| [{ a: "keepme", b: null }] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "can set a json column to escaped value" $ do
_ <- post "/json" [json| { data: {"escaped":"bar"} } |]
request methodPatch "/json?data->>escaped=eq.bar"
[("Prefer", "return=representation")]
[json| { "data": { "escaped":" \"bar" } } |]
`shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |]
{ matchStatus = 200
, matchHeaders = []
}
it "can update based on a computed column" $ it "can update based on a computed column" $
request methodPatch request methodPatch
"/items?always_true=eq.false" "/items?always_true=eq.false"
@@ -416,13 +412,21 @@ spec = do
-- put value back for other tests -- put value back for other tests
void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |] void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]
it "makes no updates and returns 204, when patching with an empty json object" $ do it "makes no updates and returns 204, when patching with an empty json object/array" $ do
request methodPatch "/items" [] [json| {} |] request methodPatch "/items" [] [json| {} |]
`shouldRespondWith` "" `shouldRespondWith` ""
{ {
matchStatus = 204, matchStatus = 204,
matchHeaders = ["Content-Range" <:> "*/*"] matchHeaders = ["Content-Range" <:> "*/*"]
} }
request methodPatch "/items" [] [json| [] |]
`shouldRespondWith` ""
{
matchStatus = 204,
matchHeaders = ["Content-Range" <:> "*/*"]
}
get "/items" `shouldRespondWith` get "/items" `shouldRespondWith`
[json|[{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15},{id:16},{"id":2},{"id":1}]|] [json|[{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15},{id:16},{"id":2},{"id":1}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
+189
View File
@@ -0,0 +1,189 @@
module Feature.JsonOperatorSpec where
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get)
spec :: SpecWith Application
spec = describe "json and jsonb operators" $ do
context "Shaping response with select parameter" $ do
it "obtains a json subfield one level with casting" $
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
[json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
{ matchHeaders = [matchContentTypeJson] }
it "renames json subfield one level with casting" $
get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
[json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
{ matchHeaders = [matchContentTypeJson] }
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\""} |]
{ matchStatus = 400 , matchHeaders = [] }
it "obtains a json subfield two levels (string)" $
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
[json| [{"bar":"baz"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "renames json subfield two levels (string)" $
get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
[json| [{"myBar":"baz"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "obtains a json subfield two levels with casting (int)" $
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
[json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
{ matchHeaders = [matchContentTypeJson] }
it "renames json subfield two levels with casting (int)" $
get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
{ matchHeaders = [matchContentTypeJson] }
-- TODO the status code for the error is 404, this is because 42883 represents undefined function
-- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate
it "fails when a double arrow ->> is followed with a single arrow ->" $ do
get "/json_arr?select=data->>c->1"
`shouldRespondWith` [json|
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
{ matchStatus = 404 , matchHeaders = [] }
get "/json_arr?select=data->>c->b"
`shouldRespondWith` [json|
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
{ matchStatus = 404 , matchHeaders = [] }
context "with array index" $ do
it "can get array of ints and alias/cast it" $ do
get "/json_arr?select=data->>0::int&id=in.(1,2)" `shouldRespondWith`
[json| [{"data":1}, {"data":4}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=idx0:data->>0::int,idx1:data->>1::int&id=in.(1,2)" `shouldRespondWith`
[json| [{"idx0":1,"idx1":2}, {"idx0":4,"idx1":5}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can get nested array of ints" $ do
get "/json_arr?select=data->0->>1::int&id=in.(3,4)" `shouldRespondWith`
[json| [{"data":8}, {"data":7}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->0->>1::int&id=in.(3,4)" `shouldRespondWith`
[json| [{"data":null}, {"data":6}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can get array of objects" $ do
get "/json_arr?select=data->0->>a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->a->>2&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":null}, {"a":"3"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can get array in object keys" $ do
get "/json_arr?select=data->c->>0::json&id=in.(7,8)" `shouldRespondWith`
[json| [{"c":1}, {"c":{"d": [4,5,6,7,8]}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->c->0->d->>4::int&id=in.(7,8)" `shouldRespondWith`
[json| [{"d":null}, {"d":8}] |]
{ matchHeaders = [matchContentTypeJson] }
it "only treats well formed numbers as indexes" $
get "/json_arr?select=data->0->0xy1->1->23-xy-45->1->xy-6->>0::int&id=eq.9" `shouldRespondWith`
[json| [{"xy-6":3}] |]
{ matchHeaders = [matchContentTypeJson] }
context "finishing json path with single arrow ->" $ do
it "works when finishing with a key" $ do
get "/json_arr?select=data->c&id=in.(7,8)" `shouldRespondWith`
[json| [{"c":[1,2,3]}, {"c":[{"d": [4,5,6,7,8]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "works when finishing with an index" $ do
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->c->0->d&id=eq.8" `shouldRespondWith`
[json| [{"d":[4,5,6,7,8]}] |]
{ matchHeaders = [matchContentTypeJson] }
context "filtering response" $ do
it "can filter by properties inside json column" $ do
get "/json?data->foo->>bar=eq.baz" `shouldRespondWith`
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json?data->foo->>bar=eq.fake" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter by properties inside json column using not" $
get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter by properties inside json column using ->>" $
get "/json?data->>id=eq.1" `shouldRespondWith`
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can be filtered with and/or" $
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
[json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
it "can filter by array indexes" $ do
get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`
[json| [{"data":[1, 2, 3]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->1->>2=eq.13" `shouldRespondWith`
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->1->>b=eq.B" `shouldRespondWith`
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->1->b->>1=eq.5" `shouldRespondWith`
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter jsonb" $ do
get "/jsonb_test?data=eq.{\"e\":1}" `shouldRespondWith`
[json| [{"id":4,"data":{"e": 1}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/jsonb_test?data->a=eq.{\"b\":2}" `shouldRespondWith`
[json| [{"id":1,"data":{"a": {"b": 2}}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/jsonb_test?data->c=eq.[1,2,3]" `shouldRespondWith`
[json| [{"id":2,"data":{"c": [1, 2, 3]}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/jsonb_test?data->0=eq.{\"d\":\"test\"}" `shouldRespondWith`
[json| [{"id":3,"data":[{"d": "test"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
context "ordering response" $ do
it "orders by a json column property asc" $
get "/json?order=data->>id.asc" `shouldRespondWith`
[json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "orders by a json column with two level property nulls first" $
get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
[json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
context "Patching record, in a nonempty table" $
it "can set a json column to escaped value" $ do
_ <- post "/json" [json| { data: {"escaped":"bar"} } |]
request methodPatch "/json?data->>escaped=eq.bar"
[("Prefer", "return=representation")]
[json| { "data": { "escaped":" \"bar" } } |]
`shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |]
{ matchStatus = 200 , matchHeaders = [] }
+1 -1
View File
@@ -8,7 +8,7 @@ import Network.HTTP.Types
import SpecHelper import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Protolude hiding (get) import Protolude
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
+55
View File
@@ -0,0 +1,55 @@
module Feature.PgVersion95Spec where
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get)
spec :: SpecWith Application
spec = describe "features supported on PostgreSQL 9.5" $
context "json array negative index" $ do
it "can select with negative indexes" $ do
get "/json_arr?select=data->>-1::int&id=in.(1,2)" `shouldRespondWith`
[json| [{"data":3}, {"data":6}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->>-2::int&id=in.(3,4)" `shouldRespondWith`
[json| [{"data":8}, {"data":7}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->-2->>a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter with negative indexes" $ do
get "/json_arr?select=data&data->>-3=eq.1" `shouldRespondWith`
[json| [{"data":[1, 2, 3]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->-1->>-3=eq.11" `shouldRespondWith`
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->-1->>b=eq.B" `shouldRespondWith`
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->-1->b->>-1=eq.5" `shouldRespondWith`
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "should fail on badly formed negatives" $ do
get "/json_arr?select=data->>-78xy" `shouldRespondWith`
[json|
{"details": "unexpected 'x' expecting digit, \"->\", \"::\" or end of input",
"message": "\"failed to parse select parameter (data->>-78xy)\" (line 1, column 11)"} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->>--34" `shouldRespondWith`
[json|
{"details": "unexpected \"-\" expecting digit",
"message": "\"failed to parse select parameter (data->>--34)\" (line 1, column 9)"} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->>-xy-4" `shouldRespondWith`
[json|
{"details":"unexpected \"x\" expecting digit",
"message":"\"failed to parse select parameter (data->>-xy-4)\" (line 1, column 9)"} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+1 -1
View File
@@ -1,6 +1,6 @@
module Feature.PgVersion96Spec where module Feature.PgVersion96Spec where
import Test.Hspec hiding (pendingWith) import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
+2 -2
View File
@@ -1,12 +1,12 @@
module Feature.ProxySpec where module Feature.ProxySpec where
import Test.Hspec hiding (pendingWith) import Test.Hspec
import SpecHelper import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Protolude hiding (get) import Protolude
spec :: SpecWith Application spec :: SpecWith Application
spec = spec =
+3 -3
View File
@@ -1,6 +1,6 @@
module Feature.QueryLimitedSpec where module Feature.QueryLimitedSpec where
import Test.Hspec hiding (pendingWith) import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
@@ -29,14 +29,14 @@ spec =
simpleStatus r `shouldBe` ok200 simpleStatus r `shouldBe` ok200
it "limit works on all levels" $ it "limit works on all levels" $
get "/users?select=id,tasks{id}&order=id.asc&tasks.order=id.asc" get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
`shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|] `shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"] , matchHeaders = ["Content-Range" <:> "0-1/*"]
} }
it "limit is not applied to parent embeds" $ it "limit is not applied to parent embeds" $
get "/tasks?select=id,project{id}&id=gt.5" get "/tasks?select=id,project(id)&id=gt.5"
`shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|] `shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"] , matchHeaders = ["Content-Range" <:> "0-1/*"]
+417 -205
View File
@@ -1,6 +1,6 @@
module Feature.QuerySpec where module Feature.QuerySpec where
import Test.Hspec hiding (pendingWith) import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
@@ -50,12 +50,12 @@ spec = do
{ matchHeaders = ["Content-Range" <:> "0-1/*"] } { matchHeaders = ["Content-Range" <:> "0-1/*"] }
it "matches items IN" $ it "matches items IN" $
get "/items?id=in.1,3,5" get "/items?id=in.(1,3,5)"
`shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |] `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
{ matchHeaders = ["Content-Range" <:> "0-2/*"] } { matchHeaders = ["Content-Range" <:> "0-2/*"] }
it "matches items NOT IN using not operator" $ it "matches items NOT IN using not operator" $
get "/items?id=not.in.2,4,6,7,8,9,10,11,12,13,14,15" get "/items?id=not.in.(2,4,6,7,8,9,10,11,12,13,14,15)"
`shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |] `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
{ matchHeaders = ["Content-Range" <:> "0-2/*"] } { matchHeaders = ["Content-Range" <:> "0-2/*"] }
@@ -145,22 +145,6 @@ spec = do
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|] {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated
it "Deprecated @@ operator, pending to remove" $ do
get "/tsearch?text_search_vector=@@.impossible" `shouldRespondWith`
[json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |]
{ matchHeaders = [matchContentTypeJson] }
get "/tsearch?text_search_vector=not.@@.impossible%7Cfat%7Cfun" `shouldRespondWith`
[json| [
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/tsearch?text_search_vector=not.@@(english).impossible%7Cfat%7Cfun" `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" $ it "matches with computed column" $
get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith` get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |] [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
@@ -171,36 +155,30 @@ spec = do
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |] [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "cannot access a computed column that is outside of the config schema" $
get "/items?always_false=is.false" `shouldRespondWith` 400
it "matches filtering nested items 2" $ it "matches filtering nested items 2" $
get "/clients?select=id,projects{id,tasks2{id,name}}&projects.tasks.name=like.Design*" get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*"
`shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities, No relation found between projects and tasks2"}|] `shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities, No relation found between projects and tasks2"}|]
{ matchStatus = 400 { matchStatus = 400
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
it "matches filtering nested items" $ it "matches filtering nested items" $
get "/clients?select=id,projects{id,tasks{id,name}}&projects.tasks.name=like.Design*" `shouldRespondWith` get "/clients?select=id,projects(id,tasks(id,name))&projects.tasks.name=like.Design*" `shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|] [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "matches with cs operator" $ do it "matches with cs operator" $
get "/complex_items?select=id&arr_data=cs.{2}" `shouldRespondWith` get "/complex_items?select=id&arr_data=cs.{2}" `shouldRespondWith`
[json|[{"id":2},{"id":3}]|] [json|[{"id":2},{"id":3}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated
get "/complex_items?select=id&arr_data=@>.{2}" `shouldRespondWith`
[json|[{"id":2},{"id":3}]|]
{ matchHeaders = [matchContentTypeJson] }
it "matches with cd operator" $ do it "matches with cd operator" $
get "/complex_items?select=id&arr_data=cd.{1,2,4}" `shouldRespondWith` get "/complex_items?select=id&arr_data=cd.{1,2,4}" `shouldRespondWith`
[json|[{"id":1},{"id":2}]|] [json|[{"id":1},{"id":2}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated
get "/complex_items?select=id&arr_data=<@.{1,2,4}" `shouldRespondWith`
[json|[{"id":1},{"id":2}]|]
{ matchHeaders = [matchContentTypeJson] }
describe "Shaping response with select parameter" $ do describe "Shaping response with select parameter" $ do
@@ -238,23 +216,6 @@ spec = do
[json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |] [json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "json subfield one level with casting (json)" $
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
[json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
{ matchHeaders = [matchContentTypeJson] }
it "rename json subfield one level with casting (json)" $
get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
[json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
{ matchHeaders = [matchContentTypeJson] }
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\""} |]
{ matchStatus = 400
, matchHeaders = []
}
it "fails on bad casting (wrong cast type)" $ it "fails on bad casting (wrong cast type)" $
get "/complex_items?select=id::fakecolumntype" get "/complex_items?select=id::fakecolumntype"
`shouldRespondWith` [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |] `shouldRespondWith` [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]
@@ -262,35 +223,13 @@ spec = do
, matchHeaders = [] , matchHeaders = []
} }
it "json subfield two levels (string)" $
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
[json| [{"bar":"baz"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "rename json subfield two levels (string)" $
get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
[json| [{"myBar":"baz"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "json subfield two levels with casting (int)" $
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
[json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
{ matchHeaders = [matchContentTypeJson] }
it "rename json subfield two levels with casting (int)" $
get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
{ matchHeaders = [matchContentTypeJson] }
it "requesting parents and children" $ it "requesting parents and children" $
get "/projects?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith` get "/projects?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|] [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "requesting parent without specifying primary key" $ do it "requesting parent without specifying primary key" $
get "/projects?select=name,client{name}" `shouldRespondWith` get "/projects?select=name,client(name)" `shouldRespondWith`
[json|[ [json|[
{"name":"Windows 7","client":{"name": "Microsoft"}}, {"name":"Windows 7","client":{"name": "Microsoft"}},
{"name":"Windows 10","client":{"name": "Microsoft"}}, {"name":"Windows 10","client":{"name": "Microsoft"}},
@@ -299,12 +238,9 @@ spec = do
{"name":"Orphan","client":null} {"name":"Orphan","client":null}
]|] ]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
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 "requesting parent and renaming primary key" $ it "requesting parent and renaming primary key" $
get "/projects?select=name,client{clientId:id,name}" `shouldRespondWith` get "/projects?select=name,client(clientId:id,name)" `shouldRespondWith`
[json|[ [json|[
{"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}}, {"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},
{"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}}, {"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},
@@ -315,115 +251,343 @@ spec = do
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "requesting parent and specifying/renaming one key of the composite primary key" $ do it "requesting parent and specifying/renaming one key of the composite primary key" $ do
get "/comments?select=*,users_tasks{userId:user_id}" `shouldRespondWith` get "/comments?select=*,users_tasks(userId:user_id)" `shouldRespondWith`
[json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"userId": 2}}]|] [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"userId": 2}}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/comments?select=*,users_tasks{taskId:task_id}" `shouldRespondWith` get "/comments?select=*,users_tasks(taskId:task_id)" `shouldRespondWith`
[json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|] [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "embed data with two fk pointing to the same table" $ 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` 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}}]|] [str|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
it "requesting parents and children while renaming them" $ it "requesting parents and children while renaming them" $
get "/projects?id=eq.1&select=myId:id, name, project_client:client_id{*}, project_tasks:tasks{id, name}" `shouldRespondWith` get "/projects?id=eq.1&select=myId:id, name, project_client:client_id(*), project_tasks:tasks(id, name)" `shouldRespondWith`
[json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|] [json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "requesting parents two levels up while using FK to specify the link" $ 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` 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"}}}]|] [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)" $ 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` 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"}}}]|] [str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
it "requesting parents and filtering parent columns" $ it "requesting parents and filtering parent columns" $
get "/projects?id=eq.1&select=id, name, clients{id}" `shouldRespondWith` get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|] [str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
it "rows with missing parents are included" $ it "rows with missing parents are included" $
get "/projects?id=in.1,5&select=id,clients{id}" `shouldRespondWith` get "/projects?id=in.(1,5)&select=id,clients(id)" `shouldRespondWith`
[json|[{"id":1,"clients":{"id":1}},{"id":5,"clients":null}]|] [json|[{"id":1,"clients":{"id":1}},{"id":5,"clients":null}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "rows with no children return [] instead of null" $ it "rows with no children return [] instead of null" $
get "/projects?id=in.5&select=id,tasks{id}" `shouldRespondWith` get "/projects?id=in.(5)&select=id,tasks(id)" `shouldRespondWith`
[str|[{"id":5,"tasks":[]}]|] [str|[{"id":5,"tasks":[]}]|]
it "requesting children 2 levels" $ it "requesting children 2 levels" $
get "/clients?id=eq.1&select=id,projects{id,tasks{id}}" `shouldRespondWith` get "/clients?id=eq.1&select=id,projects(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 "requesting children 2 levels (with relation path fixed)" $
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}]}]}]|] [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "requesting many<->many relation" $ it "requesting many<->many relation" $
get "/tasks?select=id,users{id}" `shouldRespondWith` get "/tasks?select=id,users(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] }
it "requesting many<->many relation (with relation path fixed)" $
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":[]}]|] [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] } { matchHeaders = [matchContentTypeJson] }
it "requesting many<->many relation with rename" $ it "requesting many<->many relation with rename" $
get "/tasks?id=eq.1&select=id,theUsers:users{id}" `shouldRespondWith` get "/tasks?id=eq.1&select=id,theUsers:users(id)" `shouldRespondWith`
[json|[{"id":1,"theUsers":[{"id":1},{"id":3}]}]|] [json|[{"id":1,"theUsers":[{"id":1},{"id":3}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "requesting many<->many relation reverse" $ it "requesting many<->many relation reverse" $
get "/users?select=id,tasks{id}" `shouldRespondWith` get "/users?select=id,tasks(id)" `shouldRespondWith`
[json|[{"id":1,"tasks":[{"id":1},{"id":2},{"id":3},{"id":4}]},{"id":2,"tasks":[{"id":5},{"id":6},{"id":7}]},{"id":3,"tasks":[{"id":1},{"id":5}]}]|] [json|[{"id":1,"tasks":[{"id":1},{"id":2},{"id":3},{"id":4}]},{"id":2,"tasks":[{"id":5},{"id":6},{"id":7}]},{"id":3,"tasks":[{"id":1},{"id":5}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "requesting parents and children on views" $
get "/projects_view?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
[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 parents and children on views with renamed keys" $
get "/projects_view_alt?t_id=eq.1&select=t_id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
[json|[{"t_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 children with composite key" $ it "requesting children with composite key" $
get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments{content}" `shouldRespondWith` get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments(content)" `shouldRespondWith`
[json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|] [json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "detect relations in views from exposed schema that are based on tables in private schema and have columns renames" $
get "/articles?id=eq.1&select=id,articleStars{users{*}}" `shouldRespondWith`
[json|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed by FK column name" $ it "can embed by FK column name" $
get "/projects?id=in.1,3&select=id,name,client_id{id,name}" `shouldRespondWith` 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"}}]|] [json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "can embed by FK column name and select the FK value at the same time, if aliased" $ 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` 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"}}]|] [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] } { matchHeaders = [matchContentTypeJson] }
it "can select by column name sans id" $ it "can select by column name sans id" $
get "/projects?id=in.1,3&select=id,name,client_id,client{id,name}" `shouldRespondWith` 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"}}]|] [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] } { matchHeaders = [matchContentTypeJson] }
it "can detect fk relations through views to tables in the public schema" $ describe "view embedding" $ do
get "/consumers_view?select=*,orders_view{*}" `shouldRespondWith` 200 it "can detect fk relations through views to tables in the public schema" $
get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200
it "can request parent without specifying primary key" $
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 detect relations in views from exposed schema that are based on tables in private schema and have columns renames" $
get "/articles?id=eq.1&select=id,articleStars(users(*))" `shouldRespondWith`
[json|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "works when requesting parents and children on views" $
get "/projects_view?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`
[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 "works when requesting parents and children on views with renamed keys" $
get "/projects_view_alt?t_id=eq.1&select=t_id, name, clients(*), tasks(id, name)" `shouldRespondWith`
[json|[{"t_id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
{ 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`
[json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]
{ matchHeaders = [matchContentTypeJson] }
get "/forties_books?select=title,author(name)&limit=1" `shouldRespondWith`
[json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]
{ matchHeaders = [matchContentTypeJson] }
get "/fifties_books?select=title,author(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`
[json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with views that have subselects" $
get "/authors_books_number?select=*,books(title)&id=eq.1" `shouldRespondWith`
[json|[ {"id":1, "name":"George Orwell","num_in_forties":1,"num_in_fifties":0,"num_in_sixties":0,"num_in_all_decades":1,
"books":[{"title":"1984"}]} ]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with views that have case subselects" $
get "/authors_have_book_in_decade?select=*,books(title)&id=eq.3" `shouldRespondWith`
[json|[ {"id":3,"name":"Antoine de Saint-Exupéry","has_book_in_forties":true,"has_book_in_fifties":false,"has_book_in_sixties":false,
"books":[{"title":"The Little Prince"}]} ]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with views that have subselect in the FROM clause" $
get "/forties_and_fifties_books?select=title,first_publisher,author:authors(name)&id=eq.1" `shouldRespondWith`
[json|[{"title":"1984","first_publisher":"Secker & Warburg","author":{"name":"George Orwell"}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with views that have CTE" $
get "/odd_years_publications?select=title,publication_year,first_publisher,author:authors(name)&id=in.(1,2,3)" `shouldRespondWith`
[json|[
{"title":"1984","publication_year":1949,"first_publisher":"Secker & Warburg","author":{"name":"George Orwell"}},
{"title":"The Diary of a Young Girl","publication_year":1947,"first_publisher":"Contact Publishing","author":{"name":"Anne Frank"}},
{"title":"The Little Prince","publication_year":1947,"first_publisher":"Reynal & Hitchcock","author":{"name":"Antoine de Saint-Exupéry"}} ]|]
{ matchHeaders = [matchContentTypeJson] }
it "works when having a capitalized table name and camelCase fk column" $
get "/foos?select=*,bars(*)" `shouldRespondWith` 200
it "works when embedding a view with a table that has a long compound pk" $ do
get "/player_view?select=id,contract(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`
[json|
[{"id":1,"contract":[{"purchase_price":10}]},
{"id":3,"contract":[{"purchase_price":30}]},
{"id":5,"contract":[{"purchase_price":50}]},
{"id":7,"contract":[]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/contract?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`
[json|
[{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},
{"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},
{"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "works when embedding a view with a view that referes to a table that has a long compound pk" $ do
get "/player_view?select=id,contract_view(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`
[json|
[{"id":1,"contract_view":[{"purchase_price":10}]},
{"id":3,"contract_view":[{"purchase_price":30}]},
{"id":5,"contract_view":[{"purchase_price":50}]},
{"id":7,"contract_view":[]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/contract_view?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`
[json|
[{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},
{"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},
{"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
{ 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`
[json|
[{"number_of_projects":1,"client":null},
{"number_of_projects":2,"client":{"name":"Microsoft"}},
{"number_of_projects":2,"client":{"name":"Apple"}}] |]
{ matchHeaders = [matchContentTypeJson] }
describe "path fixed" $ do
it "works when requesting children 2 levels" $
get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with parent relation" $
get "/message?select=id,body,sender:person.sender(name),recipient:person.recipient(name)&id=lt.4" `shouldRespondWith`
[json|
[{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
{"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "works with a parent view relation" $
get "/message?select=id,body,sender:person_detail.sender(name,sent),recipient:person_detail.recipient(name,received)&id=lt.4" `shouldRespondWith`
[json|
[{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
{"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
{"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "works with many<->many relation" $
get "/tasks?select=id,users:users.users_tasks(id)" `shouldRespondWith`
[json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
{ matchHeaders = [matchContentTypeJson] }
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`
[json|[
{ "id":1,
"zones": [ {"id":1,"name":"zone 1"}, {"id":2,"name":"zone 2"}],
"stores": [ {"id":3,"name":"store 3"}, {"id":4,"name":"store 4"}]}
]|] { matchHeaders = [matchContentTypeJson] }
it "works with many to many relation" $
get "/users?select=id,designTasks:tasks(id,name),codeTasks:tasks(id,name)&designTasks.name=like.*Design*&codeTasks.name=like.*Code*" `shouldRespondWith`
[json|[
{ "id":1,
"designTasks":[ { "id":1, "name":"Design w7" }, { "id":3, "name":"Design w10" } ],
"codeTasks":[ { "id":2, "name":"Code w7" }, { "id":4, "name":"Code w10" } ] },
{ "id":2,
"designTasks":[ { "id":5, "name":"Design IOS" }, { "id":7, "name":"Design OSX" } ],
"codeTasks":[ { "id":6, "name":"Code IOS" } ] },
{ "id":3,
"designTasks":[ { "id":1, "name":"Design w7" }, { "id":5, "name":"Design IOS" } ],
"codeTasks":[ ] }
]|] { matchHeaders = [matchContentTypeJson] }
it "works with an aliased child plus non aliased child" $
get "/projects?select=id,name,designTasks:tasks(name,users(id,name))&designTasks.name=like.*Design*&designTasks.users.id=in.(1,2)" `shouldRespondWith`
[json|[
{
"id":1, "name":"Windows 7",
"designTasks":[ { "name":"Design w7", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },
{
"id":2, "name":"Windows 10",
"designTasks":[ { "name":"Design w10", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },
{
"id":3, "name":"IOS",
"designTasks":[ { "name":"Design IOS", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },
{
"id":4, "name":"OSX",
"designTasks":[ { "name":"Design OSX", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },
{
"id":5, "name":"Orphan",
"designTasks":[ ] }
]|] { matchHeaders = [matchContentTypeJson] }
it "works with two aliased childs embeds plus and/or" $
get "/entities?select=id,childs:child_entities(id,gChilds:grandchild_entities(id))&childs.and=(id.in.(1,2,3))&childs.gChilds.or=(id.eq.1,id.eq.2)" `shouldRespondWith`
[json|[
{ "id":1,
"childs":[
{"id":1,"gChilds":[{"id":1}, {"id":2}]},
{"id":2,"gChilds":[]}]},
{ "id":2,
"childs":[
{"id":3,"gChilds":[]}]},
{ "id":3,"childs":[]},
{ "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.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] }
describe "ordering response" $ do describe "ordering response" $ do
it "by a column asc" $ it "by a column asc" $
@@ -432,6 +596,8 @@ spec = do
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"] , matchHeaders = ["Content-Range" <:> "0-1/*"]
} }
it "by a column desc" $ it "by a column desc" $
get "/items?id=lte.2&order=id.desc" get "/items?id=lte.2&order=id.desc"
`shouldRespondWith` [json| [{"id":2},{"id":1}] |] `shouldRespondWith` [json| [{"id":2},{"id":1}] |]
@@ -476,43 +642,96 @@ spec = do
, matchHeaders = ["Content-Range" <:> "0-2/*"] , matchHeaders = ["Content-Range" <:> "0-2/*"]
} }
it "by a json column property asc" $ it "by two columns with nulls and direction specified" $
get "/json?order=data->>id.asc" `shouldRespondWith` get "/projects?select=client_id,id,name&order=client_id.desc.nullslast,id.desc"
[json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |] `shouldRespondWith` [json|
{ matchHeaders = [matchContentTypeJson] } [{"client_id":2,"id":4,"name":"OSX"},
{"client_id":2,"id":3,"name":"IOS"},
{"client_id":1,"id":2,"name":"Windows 10"},
{"client_id":1,"id":1,"name":"Windows 7"},
{"client_id":null,"id":5,"name":"Orphan"}]
|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-4/*"]
}
it "by a json column with two level property nulls first" $ it "by a column with no direction or nulls specified" $
get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith` get "/items?id=lte.2&order=id"
[json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |] `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
{ matchHeaders = [matchContentTypeJson] } { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"]
}
it "without other constraints" $ it "without other constraints" $
get "/items?order=id.asc" `shouldRespondWith` 200 get "/items?order=id.asc" `shouldRespondWith` 200
it "ordering embeded entities" $ it "ordering embeded entities" $
get "/projects?id=eq.1&select=id, name, tasks{id, name}&tasks.order=name.asc" `shouldRespondWith` get "/projects?id=eq.1&select=id, name, tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|] [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "ordering embeded entities with alias" $ it "ordering embeded entities with alias" $
get "/projects?id=eq.1&select=id, name, the_tasks:tasks{id, name}&tasks.order=name.asc" `shouldRespondWith` get "/projects?id=eq.1&select=id, name, the_tasks:tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|] [json|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "ordering embeded entities, two levels" $ it "ordering embeded entities, two levels" $
get "/projects?id=eq.1&select=id, name, tasks{id, name, users{id, name}}&tasks.order=name.asc&tasks.users.order=name.desc" `shouldRespondWith` get "/projects?id=eq.1&select=id, name, tasks(id, name, users(id, name))&tasks.order=name.asc&tasks.users.order=name.desc" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7","users":[{"id":1,"name":"Angela Martin"}]},{"id":1,"name":"Design w7","users":[{"id":3,"name":"Dwight Schrute"},{"id":1,"name":"Angela Martin"}]}]}]|] [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7","users":[{"id":1,"name":"Angela Martin"}]},{"id":1,"name":"Design w7","users":[{"id":3,"name":"Dwight Schrute"},{"id":1,"name":"Angela Martin"}]}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "ordering embeded parents does not break things" $ it "ordering embeded parents does not break things" $
get "/projects?id=eq.1&select=id, name, clients{id, name}&clients.order=name.asc" `shouldRespondWith` get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|] [str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]
it "ordering embeded parents does not break things when using ducktape names" $ 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` 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"}}]|] [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`
[json|{"details":"unexpected \"c\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.ac)\" (line 1, column 4)"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
get "/items?order=id.descc" `shouldRespondWith`
[json|{"details":"unexpected 'c' expecting delimiter (.), \",\" or end of input","message":"\"failed to parse order (id.descc)\" (line 1, column 8)"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
get "/items?order=id.nulsfist" `shouldRespondWith`
[json|{"details":"unexpected \"n\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.nulsfist)\" (line 1, column 4)"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
get "/items?order=id.nullslasttt" `shouldRespondWith`
[json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.nullslasttt)\" (line 1, column 13)"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
get "/items?order=id.smth34" `shouldRespondWith`
[json|{"details":"unexpected \"s\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.smth34)\" (line 1, column 4)"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
it "gives meaningful error messages when nulls{first,last} are misspelled after asc/desc" $ do
get "/items?order=id.asc.nlsfst" `shouldRespondWith`
[json|{"details":"unexpected \"l\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.nlsfst)\" (line 1, column 8)"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
get "/items?order=id.asc.nullslasttt" `shouldRespondWith`
[json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.asc.nullslasttt)\" (line 1, column 17)"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
get "/items?order=id.asc.smth34" `shouldRespondWith`
[json|{"details":"unexpected \"s\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.smth34)\" (line 1, column 8)"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
describe "Accept headers" $ do describe "Accept headers" $ do
it "should respond an unknown accept type with 415" $ it "should respond an unknown accept type with 415" $
@@ -574,22 +793,6 @@ spec = do
respHeaders `shouldSatisfy` matchHeader respHeaders `shouldSatisfy` matchHeader
"Content-Location" "/simple_pk" "Content-Location" "/simple_pk"
describe "jsonb" $ do
it "can filter by properties inside json column" $ do
get "/json?data->foo->>bar=eq.baz" `shouldRespondWith`
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json?data->foo->>bar=eq.fake" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter by properties inside json column using not" $
get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter by properties inside json column using ->>" $
get "/json?data->>id=eq.1" `shouldRespondWith`
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
describe "weird requests" $ do describe "weird requests" $ do
it "can query as normal" $ do it "can query as normal" $ do
@@ -607,15 +810,29 @@ spec = do
} }
it "will embed a collection" $ it "will embed a collection" $
get "/Escap3e;?select=ghostBusters{*}" `shouldRespondWith` get "/Escap3e;?select=ghostBusters(*)" `shouldRespondWith`
[json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |] [json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "will embed using a column" $ it "will embed using a column" $
get "/ghostBusters?select=escapeId{*}" `shouldRespondWith` get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |] [json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
{ matchHeaders = [matchContentTypeJson] } { 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|[
{"Just A Server Model":" IBM,9113-550 (P5-550)"},
{"Just A Server Model":" IBM,9113-550 (P5-550)"},
{"Just A Server Model":" IBM,9131-52A (P5-52A)"},
{"Just A Server Model":" IBM,9133-55A (P5-55A)"}]|]
{ matchHeaders = [matchContentTypeJson] }
it "will select and filter a quoted column that has PostgREST reserved characters" $
get "/pgrst_reserved_chars?select=%22:arr-%3Eow::cast%22,%22(inside,parens)%22,%22a.dotted.column%22,%22%20%20col%20%20w%20%20space%20%20%22&%22*id*%22=eq.1" `shouldRespondWith`
[json|[{":arr->ow::cast":" arrow-1 ","(inside,parens)":" parens-1 ","a.dotted.column":" dotted-1 "," col w space ":" space-1"}]|]
{ matchHeaders = [matchContentTypeJson] }
describe "binary output" $ do describe "binary output" $ do
context "on GET" $ do context "on GET" $ do
it "can query if a single column is selected" $ it "can query if a single column is selected" $
@@ -634,7 +851,7 @@ spec = do
`shouldRespondWith` 406 `shouldRespondWith` 406
it "concatenates results if more than one row is returned" $ it "concatenates results if more than one row is returned" $
request methodGet "/images_base64?select=img&name=in.A.png,B.png" (acceptHdrs "application/octet-stream") "" request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII=" `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
@@ -696,7 +913,6 @@ spec = do
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = [] , matchHeaders = []
} }
it "multiple cookies ends up as claims" $ it "multiple cookies ends up as claims" $
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")] request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
[json| {"name":"request.cookie.secondcookie"} |] [json| {"name":"request.cookie.secondcookie"} |]
@@ -705,99 +921,82 @@ spec = do
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = [] , matchHeaders = []
} }
it "app settings available" $
request methodPost "/rpc/get_guc_value" []
[json| { "name": "app.settings.app_host" } |]
`shouldRespondWith`
[str|"localhost"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
describe "values with quotes in IN and NOT IN" $ do describe "values with quotes in IN and NOT IN" $ do
it "succeeds when only quoted values are present" $ do it "succeeds when only quoted values are present" $ do
get "/w_or_wo_comma_names?name=in.\"Hebdon, John\"" `shouldRespondWith` get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`
[json| [{"name":"Hebdon, John"}] |] [json| [{"name":"Hebdon, John"}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/w_or_wo_comma_names?name=in.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith` get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")" `shouldRespondWith`
[json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |] [json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/w_or_wo_comma_names?name=not.in.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith` get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")" `shouldRespondWith`
[json| [{"name":"David White"},{"name":"Larry Thompson"}] |] [json| [{"name":"David White"},{"name":"Larry Thompson"}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "succeeds w/ and w/o quoted values" $ do it "succeeds w/ and w/o quoted values" $ do
get "/w_or_wo_comma_names?name=in.David White,\"Hebdon, John\"" `shouldRespondWith` get "/w_or_wo_comma_names?name=in.(David White,\"Hebdon, John\")" `shouldRespondWith`
[json| [{"name":"Hebdon, John"},{"name":"David White"}] |] [json| [{"name":"Hebdon, John"},{"name":"David White"}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/w_or_wo_comma_names?name=not.in.\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\"" `shouldRespondWith` get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\")" `shouldRespondWith`
[json| [{"name":"Williams, Mary"},{"name":"David White"}] |] [json| [{"name":"Williams, Mary"},{"name":"David White"}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "checks well formed quoted values" $ do it "checks well formed quoted values" $ do
get "/w_or_wo_comma_names?name=in.\"\"Hebdon, John\"" `shouldRespondWith` get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\")" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
get "/w_or_wo_comma_names?name=in.\"\"Hebdon, John\"\"Mary" `shouldRespondWith` get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\"\"Mary)" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
get "/w_or_wo_comma_names?name=in.Williams\"Hebdon, John\"" `shouldRespondWith` get "/w_or_wo_comma_names?name=in.(Williams\"Hebdon, John\")" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
describe "IN and NOT IN empty set" $ do describe "IN and NOT IN empty set" $ do
context "returns an empty result for IN when no value is present" $ do context "returns an empty result for IN when no value is present" $ do
it "works for integer" $ it "works for integer" $
get "/items_with_different_col_types?int_data=in." `shouldRespondWith` get "/items_with_different_col_types?int_data=in.()" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "works for text" $ it "works for text" $
get "/items_with_different_col_types?text_data=in." `shouldRespondWith` get "/items_with_different_col_types?text_data=in.()" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "works for bool" $ it "works for bool" $
get "/items_with_different_col_types?bool_data=in." `shouldRespondWith` get "/items_with_different_col_types?bool_data=in.()" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "works for bytea" $ it "works for bytea" $
get "/items_with_different_col_types?bin_data=in." `shouldRespondWith` get "/items_with_different_col_types?bin_data=in.()" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "works for char" $ it "works for char" $
get "/items_with_different_col_types?char_data=in." `shouldRespondWith` get "/items_with_different_col_types?char_data=in.()" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "works for date" $ it "works for date" $
get "/items_with_different_col_types?date_data=in." `shouldRespondWith` get "/items_with_different_col_types?date_data=in.()" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "works for real" $ it "works for real" $
get "/items_with_different_col_types?real_data=in." `shouldRespondWith` get "/items_with_different_col_types?real_data=in.()" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "works for time" $ it "works for time" $
get "/items_with_different_col_types?time_data=in." `shouldRespondWith` get "/items_with_different_col_types?time_data=in.()" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "returns all results for not.in when no value is present" $ it "returns all results for not.in when no value is present" $
get "/items_with_different_col_types?int_data=not.in.&select=int_data" `shouldRespondWith`
[json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }
it "returns an empty result ignoring spaces" $
get "/items_with_different_col_types?int_data=in. " `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] }
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` 400
it "returns empty result when the in value is empty between parentheses" $
get "/items_with_different_col_types?int_data=in.()" `shouldRespondWith`
[json| [] |] { matchHeaders = [matchContentTypeJson] }
it "returns all results when the not.in value is empty between parentheses" $
get "/items_with_different_col_types?int_data=not.in.()&select=int_data" `shouldRespondWith` get "/items_with_different_col_types?int_data=not.in.()&select=int_data" `shouldRespondWith`
[json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] } [json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }
describe "Transition to url safe characters" $ do it "returns an empty result ignoring spaces" $
context "top level in operator" $ do get "/items_with_different_col_types?int_data=in.( )" `shouldRespondWith`
it "works with parentheses" $ [json| [] |] { matchHeaders = [matchContentTypeJson] }
get "/entities?id=in.(1,2,3)&select=id" `shouldRespondWith`
[json| [{"id": 1}, {"id": 2}, {"id": 3}] |] { matchHeaders = [matchContentTypeJson] }
it "works without parentheses" $
get "/entities?id=in.1,2,3&select=id" `shouldRespondWith`
[json| [{"id": 1}, {"id": 2}, {"id": 3}] |] { matchHeaders = [matchContentTypeJson] }
context "select query param" $ do it "only returns an empty result set if the in value is empty" $
it "works with parentheses" $ get "/items_with_different_col_types?int_data=in.( ,3,4)" `shouldRespondWith` 400
get "/entities?id=eq.2&select=id,child_entities(id)" `shouldRespondWith`
[json| [{"id": 2, "child_entities": [{"id": 3}]}] |] { matchHeaders = [matchContentTypeJson] }
it "works with brackets" $
get "/entities?id=eq.2&select=id,child_entities{id}" `shouldRespondWith`
[json| [{"id": 2, "child_entities": [{"id": 3}]}] |] { matchHeaders = [matchContentTypeJson] }
context "Embedding when column name = table name" $ do describe "Embedding when column name = table name" $ do
it "works with child embeds" $ it "works with child embeds" $
get "/being?select=*,descendant(*)&limit=1" `shouldRespondWith` get "/being?select=*,descendant(*)&limit=1" `shouldRespondWith`
[json|[{"being":1,"descendant":[{"descendant":1,"being":1},{"descendant":2,"being":1},{"descendant":3,"being":1}]}]|] [json|[{"being":1,"descendant":[{"descendant":1,"being":1},{"descendant":2,"being":1},{"descendant":3,"being":1}]}]|]
@@ -806,3 +1005,16 @@ spec = do
get "/being?select=*,part(*)&limit=1" `shouldRespondWith` get "/being?select=*,part(*)&limit=1" `shouldRespondWith`
[json|[{"being":1,"part":[{"part":1}]}]|] [json|[{"being":1,"part":[{"part":1}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
describe "Foreign table" $ do
it "can be queried by using regular filters" $
get "/projects_dump?id=in.(1,2,3)" `shouldRespondWith`
[json| [{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}, {"id":3,"name":"IOS","client_id":2}]|]
{ matchHeaders = [matchContentTypeJson] }
it "can be queried with select, order and limit" $
get "/projects_dump?select=id,name&order=id.desc&limit=3" `shouldRespondWith`
[json| [{"id":5,"name":"Orphan"}, {"id":4,"name":"OSX"}, {"id":3,"name":"IOS"}] |]
{ matchHeaders = [matchContentTypeJson] }
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
+1 -1
View File
@@ -149,7 +149,7 @@ spec = do
} }
it "limit works on all levels" $ it "limit works on all levels" $
get "/clients?select=id,projects{id,tasks{id}}&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1" get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
`shouldRespondWith` `shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|] [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
{ matchStatus = 200 { matchStatus = 200
+63 -30
View File
@@ -1,6 +1,6 @@
module Feature.RpcSpec where module Feature.RpcSpec where
import Test.Hspec hiding (pendingWith) import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
@@ -78,6 +78,16 @@ spec =
get "/rpc/sayhello" `shouldRespondWith` 404 get "/rpc/sayhello" `shouldRespondWith` 404
get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404 get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
it "works when having uppercase identifiers" $ do
get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith`
[json|{"user": "mscott", "fullName": "Michael Scott", "SSN": "401-32-XXXX"}|]
{ matchHeaders = [matchContentTypeJson] }
post "/rpc/quotedFunction"
[json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
`shouldRespondWith`
[json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
{ matchHeaders = [matchContentTypeJson] }
context "shaping the response returned by a proc" $ do context "shaping the response returned by a proc" $ do
it "returns a project" $ do it "returns a project" $ do
post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith` post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`
@@ -111,24 +121,24 @@ spec =
context "foreign entities embedding" $ do context "foreign entities embedding" $ do
it "can embed if related tables are in the exposed schema" $ do it "can embed if related tables are in the exposed schema" $ do
post "/rpc/getproject?select=id,name,client{id},tasks{id}" [json| { "id": 1} |] `shouldRespondWith` post "/rpc/getproject?select=id,name,client(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|] [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/rpc/getproject?id=1&select=id,name,client{id},tasks{id}" `shouldRespondWith` get "/rpc/getproject?id=1&select=id,name,client(id),tasks(id)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|] [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "cannot embed if the related table is not in the exposed schema" $ do it "cannot embed if the related table is not in the exposed schema" $ do
post "/rpc/single_article?select=*,article_stars{*}" [json|{ "id": 1}|] post "/rpc/single_article?select=*,article_stars(*)" [json|{ "id": 1}|]
`shouldRespondWith` 400 `shouldRespondWith` 400
get "/rpc/single_article?id=1&select=*,article_stars{*}" get "/rpc/single_article?id=1&select=*,article_stars(*)"
`shouldRespondWith` 400 `shouldRespondWith` 400
it "can embed if the related tables are in a hidden schema but exposed as views" $ do it "can embed if the related tables are in a hidden schema but exposed as views" $ do
post "/rpc/single_article?select=id,articleStars{userId}" [json|{ "id": 2}|] post "/rpc/single_article?select=id,articleStars(userId)" [json|{ "id": 2}|]
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|] `shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/rpc/single_article?id=2&select=id,articleStars{userId}" get "/rpc/single_article?id=2&select=id,articleStars(userId)"
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|] `shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
@@ -275,8 +285,14 @@ spec =
[json|[{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}]|] { matchHeaders = [matchContentTypeJson] } [json|[{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}]|] { matchHeaders = [matchContentTypeJson] }
it "returns a row result when there are many INOUT params" $ it "returns a row result when there are many INOUT params" $
get "/rpc/many_inout_params?num=1&str=two" `shouldRespondWith` get "/rpc/many_inout_params?num=1&str=two&b=false" `shouldRespondWith`
[json| [{"num":1,"str":"two","b":true}]|] { matchHeaders = [matchContentTypeJson] } [json| [{"num":1,"str":"two","b":false}]|] { matchHeaders = [matchContentTypeJson] }
it "can handle procs with args that have a DEFAULT value" $ do
get "/rpc/many_inout_params?num=1&str=two" `shouldRespondWith`
[json| [{"num":1,"str":"two","b":true}]|] { matchHeaders = [matchContentTypeJson] }
get "/rpc/three_defaults?b=4" `shouldRespondWith`
[json|8|] { matchHeaders = [matchContentTypeJson] }
it "can map a RAISE error code and message to a http status" $ it "can map a RAISE error code and message to a http status" $
get "/rpc/raise_pt402" get "/rpc/raise_pt402"
@@ -288,23 +304,42 @@ spec =
it "defaults to status 500 if RAISE code is PT not followed by a number" $ it "defaults to status 500 if RAISE code is PT not followed by a number" $
get "/rpc/raise_bad_pt" `shouldRespondWith` 500 get "/rpc/raise_bad_pt" `shouldRespondWith` 500
context "only for POST rpc" $ do context "expects a single json object" $ do
context "expects a single json object" $ do it "does not expand posted json into parameters" $
it "does not expand posted json into parameters" $ request methodPost "/rpc/singlejsonparam"
request methodPost "/rpc/singlejsonparam" [("prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
[("Prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith` [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
[json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] { matchHeaders = [matchContentTypeJson] }
{ matchHeaders = [matchContentTypeJson] }
it "accepts parameters from an html form" $ it "accepts parameters from an html form" $
request methodPost "/rpc/singlejsonparam" request methodPost "/rpc/singlejsonparam"
[("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")] [("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]
("integer=7&double=2.71828&varchar=forms+are+fun&" <> ("integer=7&double=2.71828&varchar=forms+are+fun&" <>
"boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith` "boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
[json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun" [json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
, "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |] , "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "works with GET" $
request methodGet "/rpc/singlejsonparam?p1=1&p2=text" [("Prefer","params=single-object")] ""
`shouldRespondWith` [json|{ "p1": "1", "p2": "text"}|]
{ matchHeaders = [matchContentTypeJson] }
it "should work with an overloaded function" $ do
get "/rpc/overloaded" `shouldRespondWith`
[json|[{ "overloaded": 1 },
{ "overloaded": 2 },
{ "overloaded": 3 }]|]
{ matchHeaders = [matchContentTypeJson] }
request methodPost "/rpc/overloaded" [("Prefer","params=single-object")]
[json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
`shouldRespondWith`
[json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]
get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [str|"123"|]
context "only for POST rpc" $
it "gives a parse filter error if GET style proc args are specified" $ it "gives a parse filter error if GET style proc args are specified" $
post "/rpc/sayhello?name=John" [json|{}|] `shouldRespondWith` 400 post "/rpc/sayhello?name=John" [json|{}|] `shouldRespondWith` 400
@@ -336,10 +371,8 @@ spec =
get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith` 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"}]|] [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
-- TODO: '@@' deprecated
get "/rpc/get_tsearch?text_search_vector=@@(english).impossible" `shouldRespondWith` it "should work with an argument of custom type in public schema" $
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|] get "/rpc/test_arg?my_arg=something" `shouldRespondWith`
{ matchHeaders = [matchContentTypeJson] } [json|"foobar"|]
get "/rpc/get_tsearch?text_search_vector=not.@@(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] } { matchHeaders = [matchContentTypeJson] }
+1 -1
View File
@@ -37,7 +37,7 @@ spec =
`shouldRespondWith` [str|{"id":5}|] `shouldRespondWith` [str|{"id":5}|]
it "can shape plurality singular object routes" $ it "can shape plurality singular object routes" $
request methodGet "/projects_view?id=eq.1&select=id,name,clients{*},tasks{id,name}" [singular] "" request methodGet "/projects_view?id=eq.1&select=id,name,clients(*),tasks(id,name)" [singular] ""
`shouldRespondWith` `shouldRespondWith`
[json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|] [json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]
{ matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"] } { matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"] }
+107 -11
View File
@@ -1,6 +1,6 @@
module Feature.StructureSpec where module Feature.StructureSpec where
import Test.Hspec hiding (pendingWith) import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Network.HTTP.Types import Network.HTTP.Types
@@ -29,12 +29,12 @@ spec = do
(acceptHdrs "application/openapi+json") "" (acceptHdrs "application/openapi+json") ""
`shouldRespondWith` 415 `shouldRespondWith` 415
it "includes postgrest.com current version api docs" $ do it "includes postgrest.org current version api docs" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
let docsUrl = r ^? key "externalDocs" . key "url" let docsUrl = r ^? key "externalDocs" . key "url"
liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.com/en/" <> docsVersion <> "/api.html")) liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
describe "table" $ do describe "table" $ do
@@ -85,6 +85,27 @@ spec = do
deleteResponse `shouldBe` Just "No Content" deleteResponse `shouldBe` Just "No Content"
it "includes an array type for GET responses" $ do
r <- simpleBody <$> get "/"
let childGetSchema = r ^? key "paths"
. key "/child_entities"
. key "get"
. key "responses"
. key "200"
. key "schema"
liftIO $
childGetSchema `shouldBe` Just
[aesonQQ|
{
"items": {
"$ref": "#/definitions/child_entities"
},
"type": "array"
}
|]
it "includes definitions to tables" $ do it "includes definitions to tables" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
@@ -113,7 +134,10 @@ spec = do
"format": "integer", "format": "integer",
"type": "integer" "type": "integer"
} }
} },
"required": [
"id"
]
} }
|] |]
@@ -131,15 +155,86 @@ spec = do
. nth 0 . nth 0
liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|] liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]
describe "Foreign table" $
it "includes foreign table properties" $ do
r <- simpleBody <$> get "/"
let method s = key "paths" . key "/projects_dump" . key s
getSummary = r ^? method "get" . key "summary"
getDescription = r ^? method "get" . key "description"
getParameters = r ^? method "get" . key "parameters"
liftIO $ do
getSummary `shouldBe` Just "A temporary projects dump"
getDescription `shouldBe` Just "Just a test for foreign tables"
getParameters `shouldBe` Just
[aesonQQ|
[
{ "$ref": "#/parameters/rowFilter.projects_dump.id" },
{ "$ref": "#/parameters/rowFilter.projects_dump.name" },
{ "$ref": "#/parameters/rowFilter.projects_dump.client_id" },
{ "$ref": "#/parameters/select" },
{ "$ref": "#/parameters/order" },
{ "$ref": "#/parameters/range" },
{ "$ref": "#/parameters/rangeUnit" },
{ "$ref": "#/parameters/offset" },
{ "$ref": "#/parameters/limit" },
{ "$ref": "#/parameters/preferCount" }
]
|]
describe "Materialized view" $
it "includes materialized view properties" $ do
r <- simpleBody <$> get "/"
let method s = key "paths" . key "/materialized_projects" . key s
summary = r ^? method "get" . key "summary"
description = r ^? method "get" . key "description"
parameters = r ^? method "get" . key "parameters"
liftIO $ do
summary `shouldBe` Just "A materialized view for projects"
description `shouldBe` Just "Just a test for materialized views"
parameters `shouldBe` Just
[aesonQQ|
[
{ "$ref": "#/parameters/rowFilter.materialized_projects.id" },
{ "$ref": "#/parameters/rowFilter.materialized_projects.name" },
{ "$ref": "#/parameters/rowFilter.materialized_projects.client_id" },
{ "$ref": "#/parameters/select" },
{ "$ref": "#/parameters/order" },
{ "$ref": "#/parameters/range" },
{ "$ref": "#/parameters/rangeUnit" },
{ "$ref": "#/parameters/offset" },
{ "$ref": "#/parameters/limit" },
{ "$ref": "#/parameters/preferCount" }
]
|]
describe "RPC" $ do describe "RPC" $ do
it "includes body schema for arguments" $ do it "includes function summary/description and body schema for arguments" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
let args = r ^? key "paths" . key "/rpc/varied_arguments"
. key "post" . key "parameters"
. nth 0 . key "schema"
liftIO $ let method s = key "paths" . key "/rpc/varied_arguments" . key s
args = r ^? method "post" . key "parameters" . nth 0 . key "schema"
summary = r ^? method "post" . key "summary"
description = r ^? method "post" . key "description"
liftIO $ do
summary `shouldBe` Just "An RPC function"
description `shouldBe` Just "Just a test for RPC function arguments"
args `shouldBe` Just args `shouldBe` Just
[aesonQQ| [aesonQQ|
{ {
@@ -173,7 +268,7 @@ spec = do
"type": "string" "type": "string"
}, },
"enum": { "enum": {
"format": "test.enum_menagerie_type", "format": "enum_menagerie_type",
"type": "string" "type": "string"
}, },
"integer": { "integer": {
@@ -181,7 +276,8 @@ spec = do
"type": "integer" "type": "integer"
} }
}, },
"type": "object" "type": "object",
"description": "An RPC function\n\nJust a test for RPC function arguments"
} }
|] |]
+227
View File
@@ -0,0 +1,227 @@
module Feature.UpsertSpec where
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get, put)
import Text.Heredoc
spec :: SpecWith Application
spec =
describe "UPSERT" $ do
context "with POST" $ do
context "when Prefer: resolution=merge-duplicates is specified" $ do
it "INSERTs and UPDATEs rows on pk conflict" $
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "name": "Javascript", "rank": 6 },
{ "name": "Java", "rank": 2 },
{ "name": "C", "rank": 1 }
]|] `shouldRespondWith` [json| [
{ "name": "Javascript", "rank": 6 },
{ "name": "Java", "rank": 2 },
{ "name": "C", "rank": 1 }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
}
it "INSERTs and UPDATEs row on composite pk conflict" $
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "30000" },
{ "first_name": "Peter S.", "last_name": "Yang", "salary": 42000 }
]|] `shouldRespondWith` [json| [
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "$30,000.00", "company": "One-Up Realty", "occupation": "Author" },
{ "first_name": "Peter S.", "last_name": "Yang", "salary": "$42,000.00", "company": null, "occupation": null }
]|]
{ 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" $
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[
{ "name": "PHP", "rank": 9 },
{ "name": "Python", "rank": 10 }
]|] `shouldRespondWith` [json|[
{ "name": "PHP", "rank": 9 }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
}
it "INSERTs and ignores rows on composite pk conflict" $
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[
{ "first_name": "Daniel B.", "last_name": "Lyon", "salary": "72000", "company": null, "occupation": null },
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": 60000, "company": "Burstein-Applebee", "occupation": "Soil scientist" }
]|] `shouldRespondWith` [json|[
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": "$60,000.00", "company": "Burstein-Applebee", "occupation": "Soil scientist" }
]|]
{ 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} ]|]
`shouldRespondWith`
[json|[ { "id": 3} ]|]
{ matchStatus = 201 ,
matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates",
matchContentTypeJson] }
request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
`shouldRespondWith`
[json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
{ matchStatus = 201 ,
matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates",
matchContentTypeJson] }
it "succeeds and ignores the Prefer: resolution header(no Preference-Applied present) if the table has no PK" $
request methodPost "/no_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json|[ { "a": "1", "b": "0" } ]|]
`shouldRespondWith`
[json|[ { "a": "1", "b": "0" } ]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
it "succeeds if not a single resource is created" $ do
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[ { "name": "Java", "rank": 1 } ]|] `shouldRespondWith`
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[ { "name": "Java", "rank": 1 }, { "name": "C", "rank": 2 } ]|] `shouldRespondWith`
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
context "with PUT" $ do
context "Restrictions" $ do
it "fails if Range is specified" $
request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]
[str| [ { "name": "Javascript", "rank": 1 } ]|] `shouldRespondWith` 400
it "fails if limit is specified" $
put "/tiobe_pls?name=eq.Javascript&limit=1"
[str| [ { "name": "Javascript", "rank": 1 } ]|] `shouldRespondWith` 400
it "fails if offset is specified" $
put "/tiobe_pls?name=eq.Javascript&offset=1"
[str| [ { "name": "Javascript", "rank": 1 } ]|] `shouldRespondWith` 400
it "fails if the payload has more than one row" $
put "/tiobe_pls?name=eq.Go"
[str| [ { "name": "Go", "rank": 19 }, { "name": "Swift", "rank": 12 } ]|] `shouldRespondWith` 400
it "fails if not all columns are specified" $ do
put "/tiobe_pls?name=eq.Go"
[str| [ { "name": "Go" } ]|] `shouldRespondWith` 400
put "/employees?first_name=eq.Susan&last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000" } ]|] `shouldRespondWith` 400
it "rejects every other filter than pk cols eq's" $ do
put "/tiobe_pls?rank=eq.19" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405
put "/tiobe_pls?id=not.eq.Java" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405
put "/tiobe_pls?id=in.(Go)" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405
put "/tiobe_pls?and=(id.eq.Go)" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405
it "fails if not all composite key cols are specified as eq filters" $ do
put "/employees?first_name=eq.Susan"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 405
put "/employees?last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 405
it "fails if the uri primary key doesn't match the payload primary key" $ do
put "/tiobe_pls?name=eq.MATLAB"
[str| [ { "name": "Perl", "rank": 17 } ]|] `shouldRespondWith` 400
put "/employees?first_name=eq.Wendy&last_name=eq.Anderson"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|] `shouldRespondWith` 400
it "fails if the table has no PK" $
put "/no_pk?a=eq.one&b=eq.two" [str| [ { "a": "one", "b": "two" } ]|] `shouldRespondWith` 405
context "Inserting row" $ do
it "succeeds on table with single pk col" $ do
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` "[]"
put "/tiobe_pls?name=eq.Go" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 204
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json| [ { "name": "Go", "rank": 19 } ]|] { matchHeaders = [matchContentTypeJson] }
it "succeeds on table with composite pk" $ do
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
`shouldRespondWith` "[]"
put "/employees?first_name=eq.Susan&last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 204
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
`shouldRespondWith`
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
{ matchHeaders = [matchContentTypeJson] }
it "succeeds if the table has only PK cols and no other cols" $ do
get "/only_pk?id=eq.10" `shouldRespondWith` "[]"
put "/only_pk?id=eq.10" [str|[ { "id": 10 } ]|] `shouldRespondWith` 204
get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }
context "Updating row" $ do
it "succeeds on table with single pk col" $ do
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json|[ { "name": "Go", "rank": 19 } ]|] { matchHeaders = [matchContentTypeJson] }
put "/tiobe_pls?name=eq.Go" [str| [ { "name": "Go", "rank": 13 } ]|] `shouldRespondWith` 204
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json| [ { "name": "Go", "rank": 13 } ]|] { matchHeaders = [matchContentTypeJson] }
it "succeeds on table with composite pk" $ do
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
`shouldRespondWith`
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
{ matchHeaders = [matchContentTypeJson] }
put "/employees?first_name=eq.Susan&last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "60000", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 204
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
`shouldRespondWith`
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
{ matchHeaders = [matchContentTypeJson] }
it "succeeds if the table has only PK cols and no other cols" $ do
get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }
put "/only_pk?id=eq.10" [str|[ { "id": 10 } ]|] `shouldRespondWith` 204
get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }
it "works with return=representation and vnd.pgrst.object+json" $
request methodPut "/tiobe_pls?name=eq.Ruby"
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
[str| [ { "name": "Ruby", "rank": 11 } ]|]
`shouldRespondWith` [json|{ "name": "Ruby", "rank": 11 }|] { matchHeaders = [matchContentTypeSingular] }
context "with a camel case pk column" $ do
it "works with POST and merge-duplicates/ignore-duplicates headers" $ do
request methodPost "/UnitTest" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
]|] `shouldRespondWith` [json|[
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
}
request methodPost "/UnitTest" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json| [
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
]|] `shouldRespondWith` [json|[]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
}
it "works with PUT" $ do
put "/UnitTest?idUnitTest=eq.1" [str| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] `shouldRespondWith` 204
get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`
[json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] { matchHeaders = [matchContentTypeJson] }
+41 -17
View File
@@ -4,13 +4,15 @@ import Test.Hspec
import SpecHelper import SpecHelper
import qualified Hasql.Pool as P import qualified Hasql.Pool as P
import qualified Hasql.Transaction.Sessions as HT
import PostgREST.App (postgrest) import PostgREST.App (postgrest)
import PostgREST.Config (pgVersion96)
import PostgREST.DbStructure (getDbStructure, getPgVersion) import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Types (DbStructure(..)) import PostgREST.Types (DbStructure(..), pgVersion95, pgVersion96)
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate, updateAction)
import Data.Function (id) import Data.Function (id)
import Data.IORef import Data.IORef
import Data.Time.Clock (getCurrentTime)
import qualified Feature.AuthSpec import qualified Feature.AuthSpec
import qualified Feature.AsymmetricJwtSpec import qualified Feature.AsymmetricJwtSpec
@@ -19,7 +21,9 @@ import qualified Feature.AudienceJwtSecretSpec
import qualified Feature.ConcurrentSpec import qualified Feature.ConcurrentSpec
import qualified Feature.CorsSpec import qualified Feature.CorsSpec
import qualified Feature.DeleteSpec import qualified Feature.DeleteSpec
import qualified Feature.ExtraSearchPathSpec
import qualified Feature.InsertSpec import qualified Feature.InsertSpec
import qualified Feature.JsonOperatorSpec
import qualified Feature.NoJwtSpec import qualified Feature.NoJwtSpec
import qualified Feature.QueryLimitedSpec import qualified Feature.QueryLimitedSpec
import qualified Feature.QuerySpec import qualified Feature.QuerySpec
@@ -31,7 +35,9 @@ import qualified Feature.ProxySpec
import qualified Feature.AndOrParamsSpec import qualified Feature.AndOrParamsSpec
import qualified Feature.RpcSpec import qualified Feature.RpcSpec
import qualified Feature.NonexistentSchemaSpec import qualified Feature.NonexistentSchemaSpec
import qualified Feature.PgVersion95Spec
import qualified Feature.PgVersion96Spec import qualified Feature.PgVersion96Spec
import qualified Feature.UpsertSpec
import Protolude import Protolude
@@ -42,26 +48,36 @@ main = do
pool <- P.acquire (3, 10, toS testDbConn) pool <- P.acquire (3, 10, toS testDbConn)
result <- P.use pool $ getDbStructure "test" =<< getPgVersion result <- P.use pool $ do
ver <- getPgVersion
HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver
dbStructure <- pure $ either (panic.show) id result dbStructure <- pure $ either (panic.show) id result
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
refDbStructure <- newIORef $ Just dbStructure refDbStructure <- newIORef $ Just dbStructure
let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool $ pure () let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime $ pure ()
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool $ pure () ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime $ pure ()
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool $ pure () unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime $ pure ()
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool $ pure () proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime $ pure ()
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool $ pure () noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime $ pure ()
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool $ pure () binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime $ pure ()
audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool $ pure () audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool getTime $ pure ()
asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool $ pure () asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool getTime $ pure ()
nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool $ 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 ()
let reset :: IO ()
reset = resetDb testDbConn
let reset = resetDb testDbConn
actualPgVersion = pgVersion dbStructure actualPgVersion = pgVersion dbStructure
pg96spec | actualPgVersion >= pgVersion96 = [("Feature.PgVersion96Spec" , Feature.PgVersion96Spec.spec)] extraSpecs =
| otherwise = [] [("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95] ++
[("Feature.PgVersion96Spec", Feature.PgVersion96Spec.spec) | actualPgVersion >= pgVersion96]
specs = uncurry describe <$> [ specs = uncurry describe <$> [
("Feature.AuthSpec" , Feature.AuthSpec.spec) ("Feature.AuthSpec" , Feature.AuthSpec.spec)
@@ -69,14 +85,14 @@ main = do
, ("Feature.CorsSpec" , Feature.CorsSpec.spec) , ("Feature.CorsSpec" , Feature.CorsSpec.spec)
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec) , ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
, ("Feature.InsertSpec" , Feature.InsertSpec.spec) , ("Feature.InsertSpec" , Feature.InsertSpec.spec)
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec)
, ("Feature.QuerySpec" , Feature.QuerySpec.spec) , ("Feature.QuerySpec" , Feature.QuerySpec.spec)
, ("Feature.RpcSpec" , Feature.RpcSpec.spec) , ("Feature.RpcSpec" , Feature.RpcSpec.spec)
, ("Feature.RangeSpec" , Feature.RangeSpec.spec) , ("Feature.RangeSpec" , Feature.RangeSpec.spec)
, ("Feature.SingularSpec" , Feature.SingularSpec.spec) , ("Feature.SingularSpec" , Feature.SingularSpec.spec)
, ("Feature.StructureSpec" , Feature.StructureSpec.spec) , ("Feature.StructureSpec" , Feature.StructureSpec.spec)
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec) , ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)
, ("Feature.NonexistentSchemaSpec" , Feature.NonexistentSchemaSpec.spec) ] ++ extraSpecs
] ++ pg96spec
hspec $ do hspec $ do
mapM_ (beforeAll_ reset . before withApp) specs mapM_ (beforeAll_ reset . before withApp) specs
@@ -109,6 +125,14 @@ main = do
beforeAll_ reset . before asymJwkApp $ beforeAll_ reset . before asymJwkApp $
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-- this test runs with asymmetric JWKSet
beforeAll_ reset . before asymJwkSetApp $
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-- this test runs with a nonexistent db-schema -- this test runs with a nonexistent db-schema
beforeAll_ reset . before nonexistentSchemaApp $ beforeAll_ reset . before nonexistentSchemaApp $
describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
-- this test runs with an extra search path
beforeAll_ reset . before extraSearchPathApp $
describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec
+21 -5
View File
@@ -17,8 +17,9 @@ import System.Process (readProcess)
import Text.Heredoc import Text.Heredoc
import PostgREST.Config (AppConfig(..)) import PostgREST.Config (AppConfig(..))
import PostgREST.Types (JSPathExp(..))
import Test.Hspec hiding (pendingWith) import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Network.HTTP.Types import Network.HTTP.Types
@@ -60,9 +61,8 @@ validateOpenApiResponse headers = do
D4.fetchFilesystemAndValidate schemaContext ((fromJust . decode) respBody) `shouldReturn` Right () D4.fetchFilesystemAndValidate schemaContext ((fromJust . decode) respBody) `shouldReturn` Right ()
getEnvVarWithDefault :: Text -> Text -> IO Text getEnvVarWithDefault :: Text -> Text -> IO Text
getEnvVarWithDefault var def = do getEnvVarWithDefault var def = toS <$>
varValue <- getEnv (toS var) `E.catchIOError` const (return $ toS def) getEnv (toS var) `E.catchIOError` const (return $ toS def)
return $ toS varValue
_baseCfg :: AppConfig _baseCfg :: AppConfig
_baseCfg = -- Connection Settings _baseCfg = -- Connection Settings
@@ -73,6 +73,13 @@ _baseCfg = -- Connection Settings
10 Nothing (Just "test.switch_role") 10 Nothing (Just "test.switch_role")
-- Debug Settings -- Debug Settings
True True
[ ("app.settings.app_host", "localhost")
, ("app.settings.external_api_secret", "0123456789abcdef")
]
-- Default role claim key
(Right [JSPKey "role"])
-- Empty db-extra-search-path
[]
testCfg :: Text -> AppConfig testCfg :: Text -> AppConfig
testCfg testDbConn = _baseCfg { configDatabase = testDbConn } testCfg testDbConn = _baseCfg { configDatabase = testDbConn }
@@ -108,9 +115,18 @@ testCfgAsymJWK testDbConn = (testCfg testDbConn) {
[str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|] [str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|]
} }
testCfgAsymJWKSet :: Text -> AppConfig
testCfgAsymJWKSet testDbConn = (testCfg testDbConn) {
configJwtSecret = Just $ encodeUtf8
[str|{"keys": [{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}]}|]
}
testNonexistentSchemaCfg :: Text -> AppConfig testNonexistentSchemaCfg :: Text -> AppConfig
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" } testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" }
testCfgExtraSearchPath :: Text -> AppConfig
testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }
setupDb :: Text -> IO () setupDb :: Text -> IO ()
setupDb dbConn = do setupDb dbConn = do
loadFixture dbConn "database" loadFixture dbConn "database"
@@ -160,5 +176,5 @@ isErrorFormat s =
S.null (S.difference keys validKeys) S.null (S.difference keys validKeys)
where where
obj = decode s :: Maybe (M.Map Text Value) obj = decode s :: Maybe (M.Map Text Value)
keys = fromMaybe S.empty (M.keysSet <$> obj) keys = maybe S.empty M.keysSet obj
validKeys = S.fromList ["message", "details", "hint", "code"] validKeys = S.fromList ["message", "details", "hint", "code"]
+1 -1
View File
@@ -1,4 +1,4 @@
#! /bin/bash #! /usr/bin/env bash
if [ -z "$1" ] if [ -z "$1" ]
then then
echo "Please supply the connection uri for the user with create database privileges" echo "Please supply the connection uri for the user with create database privileges"
+1 -1
View File
@@ -1,4 +1,4 @@
#! /bin/bash #! /usr/bin/env bash
if [ -z "$1" ] if [ -z "$1" ]
then then
echo "Please supply the connection uri for the user with create database privileges" echo "Please supply the connection uri for the user with create database privileges"
+155 -3
View File
@@ -334,6 +334,158 @@ INSERT INTO part VALUES (1), (2), (3), (4);
TRUNCATE TABLE being_part CASCADE; TRUNCATE TABLE being_part CASCADE;
INSERT INTO being_part VALUES (1,1), (2,1), (3,2), (4,3); INSERT INTO being_part VALUES (1,1), (2,1), (3,2), (4,3);
--
-- PostgreSQL database dump complete TRUNCATE TABLE employees CASCADE;
-- INSERT INTO employees VALUES
('Frances M.', 'Roe', '24000', 'One-Up Realty', 'Author'),
('Daniel B.', 'Lyon', '36000', 'Dubrow''s Cafeteria', 'Packer'),
('Edwin S.', 'Smith', '48000', 'Pro Garden Management', 'Marine biologist');
TRUNCATE TABLE tiobe_pls CASCADE;
INSERT INTO tiobe_pls VALUES ('Java', 1), ('C', 2), ('Python', 4);
TRUNCATE TABLE only_pk CASCADE;
INSERT INTO only_pk VALUES (1), (2);
TRUNCATE TABLE family_tree CASCADE;
INSERT INTO family_tree VALUES ('1', 'Parental Unit', NULL);
INSERT INTO family_tree VALUES ('2', 'Kid One', '1');
INSERT INTO family_tree VALUES ('3', 'Kid Two', '1');
INSERT INTO family_tree VALUES ('4', 'Grandkid One', '2');
INSERT INTO family_tree VALUES ('5', 'Grandkid Two', '3');
TRUNCATE TABLE organizations CASCADE;
INSERT INTO organizations VALUES (1, 'Referee Org', null, null);
INSERT INTO organizations VALUES (2, 'Auditor Org', null, null);
INSERT INTO organizations VALUES (3, 'Acme', 1, 2);
INSERT INTO organizations VALUES (4, 'Umbrella', 1, 2);
SET search_path = private, pg_catalog;
TRUNCATE TABLE authors CASCADE;
INSERT INTO authors VALUES (1, 'George Orwell');
INSERT INTO authors VALUES (2, 'Anne Frank');
INSERT INTO authors VALUES (3, 'Antoine de Saint-Exupéry');
INSERT INTO authors VALUES (4, 'J.D. Salinger');
INSERT INTO authors VALUES (5, 'Ray Bradbury');
INSERT INTO authors VALUES (6, 'William Golding');
INSERT INTO authors VALUES (7, 'Harper Lee');
INSERT INTO authors VALUES (8, 'Kurt Vonnegut');
INSERT INTO authors VALUES (9, 'Ken Kesey');
TRUNCATE TABLE publishers CASCADE;
INSERT INTO publishers VALUES (1, 'Secker & Warburg');
INSERT INTO publishers VALUES (2, 'Contact Publishing');
INSERT INTO publishers VALUES (3, 'Reynal & Hitchcock');
INSERT INTO publishers VALUES (4, 'Little, Brown and Company');
INSERT INTO publishers VALUES (5, 'Ballantine Books');
INSERT INTO publishers VALUES (6, 'Faber and Faber');
INSERT INTO publishers VALUES (7, 'J. B. Lippincott & Co.');
INSERT INTO publishers VALUES (8, 'Delacorte');
INSERT INTO publishers VALUES (9, 'Viking Press & Signet Books');
TRUNCATE TABLE books CASCADE;
INSERT INTO books VALUES (1, '1984', 1949, 1, 1);
INSERT INTO books VALUES (2, 'The Diary of a Young Girl', 1947, 2, 2);
INSERT INTO books VALUES (3, 'The Little Prince', 1947, 3, 3);
INSERT INTO books VALUES (4, 'The Catcher in the Rye', 1951, 4, 4);
INSERT INTO books VALUES (5, 'Farenheit 451', 1953, 5, 5);
INSERT INTO books VALUES (6, 'Lord of the Flies', 1954, 6, 6);
INSERT INTO books VALUES (7, 'To Kill a Mockingbird', 1960, 7, 7);
INSERT INTO books VALUES (8, 'Slaughterhouse-Five', 1969, 8, 8);
INSERT INTO books VALUES (9, 'One Flew Over the Cuckoo''s Nest', 1962, 9, 9);
SET search_path = test, pg_catalog;
TRUNCATE TABLE person CASCADE;
INSERT INTO person VALUES (1, 'John');
INSERT INTO person VALUES (2, 'Jane');
INSERT INTO person VALUES (3, 'Jake');
INSERT INTO person VALUES (4, 'Julie');
TRUNCATE TABLE message CASCADE;
INSERT INTO message VALUES (1, 'Hello Jane', 1, 2);
INSERT INTO message VALUES (2, 'Hi John', 2, 1);
INSERT INTO message VALUES (3, 'How are you doing?', 1, 2);
INSERT INTO message VALUES (4, 'Hey Julie', 3, 4);
INSERT INTO message VALUES (5, 'What''s up Jake', 4, 3);
TRUNCATE TABLE space CASCADE;
INSERT INTO space VALUES (1, 'space 1');
TRUNCATE TABLE zone CASCADE;
INSERT INTO zone VALUES (1, 'zone 1', 2, 1);
INSERT INTO zone VALUES (2, 'zone 2', 2, 1);
INSERT INTO zone VALUES (3, 'store 3', 3, 1);
INSERT INTO zone VALUES (4, 'store 4', 3, 1);
-- for foreign table projects_dump
copy (select id, name, client_id from projects) to '/tmp/projects_dump.csv' with csv;
TRUNCATE TABLE "UnitTest" CASCADE;
INSERT INTO "UnitTest" VALUES (1, 'unit test 1');
TRUNCATE TABLE json_arr CASCADE;
INSERT INTO json_arr VALUES (1, '[1, 2, 3]');
INSERT INTO json_arr VALUES (2, '[4, 5, 6]');
INSERT INTO json_arr VALUES (3, '[[9, 8, 7], [11, 12, 13]]');
INSERT INTO json_arr VALUES (4, '[[[5, 6], 7, 8]]');
INSERT INTO json_arr VALUES (5, '[{"a": "A"}, {"b": "B"}]');
INSERT INTO json_arr VALUES (6, '[{"a": [1,2,3]}, {"b": [4,5]}]');
INSERT INTO json_arr VALUES (7, '{"c": [1,2,3], "d": [4,5]}');
INSERT INTO json_arr VALUES (8, '{"c": [{"d": [4,5,6,7,8]}]}');
INSERT INTO json_arr VALUES (9, '[{"0xy1": [1,{"23-xy-45": [2, {"xy-6": [3]}]}]}]');
TRUNCATE TABLE jsonb_test CASCADE;
INSERT INTO jsonb_test VALUES (1, '{ "a": {"b": 2} }');
INSERT INTO jsonb_test VALUES (2, '{ "c": [1,2,3] }');
INSERT INTO jsonb_test VALUES (3, '[{ "d": "test" }]');
INSERT INTO jsonb_test VALUES (4, '{ "e": 1 }');
TRUNCATE TABLE private.player CASCADE;
INSERT into private.player
SELECT
generate_series,
'first_name_' || generate_series,
'last_name_' || generate_series,
'2018-10-11'
FROM generate_series(1, 12);
TRUNCATE TABLE contract CASCADE;
insert into contract
select
'tournament_' || generate_series,
tsrange(now()::timestamp, null),
10*generate_series,
generate_series,
'first_name_' || generate_series,
'last_name_' || generate_series,
'2018-10-11'
from generate_series(1, 6);
TRUNCATE TABLE ltree_sample CASCADE;
INSERT INTO ltree_sample VALUES ('Top');
INSERT INTO ltree_sample VALUES ('Top.Science');
INSERT INTO ltree_sample VALUES ('Top.Science.Astronomy');
INSERT INTO ltree_sample VALUES ('Top.Science.Astronomy.Astrophysics');
INSERT INTO ltree_sample VALUES ('Top.Science.Astronomy.Cosmology');
TRUNCATE TABLE isn_sample CASCADE;
INSERT INTO isn_sample VALUES ('978-0-393-04002-9', 'Mathematics: From the Birth of Numbers');
TRUNCATE TABLE "Server Today" CASCADE;
COPY "Server Today" ("cHostname", "Just A Server Model") FROM STDIN CSV DELIMITER '|';
argnim1 | IBM,9113-550 (P5-550)
argnim2 | IBM,9113-550 (P5-550)
daaa2nim71 | IBM,9131-52A (P5-52A)
daah3nim71 | IBM,8406-71Y (P7-PS701)
hbnim1 | IBM,9133-55A (P5-55A)
\.
TRUNCATE TABLE pgrst_reserved_chars CASCADE;
COPY pgrst_reserved_chars ("*id*", ":arr->ow::cast", "(inside,parens)", "a.dotted.column", " col w space ") FROM STDIN CSV DELIMITER '|';
1 | arrow-1 | parens-1 | dotted-1 | space-1
2 | arrow-2 | parens-2 | dotted-2 | space-2
3 | arrow-3 | parens-3 | dotted-3 | space-3
\.
+1 -1
View File
@@ -1,3 +1,3 @@
set client_min_messages to warning; set client_min_messages to warning;
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست CASCADE; DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions CASCADE;
DROP TYPE IF EXISTS jwt_token CASCADE; DROP TYPE IF EXISTS jwt_token CASCADE;
+1 -2
View File
@@ -4,7 +4,6 @@ set client_min_messages to warning;
DROP SCHEMA IF EXISTS jwt CASCADE; DROP SCHEMA IF EXISTS jwt CASCADE;
CREATE SCHEMA jwt; CREATE SCHEMA jwt;
CREATE OR REPLACE FUNCTION jwt.url_encode(data bytea) RETURNS text LANGUAGE sql AS $$ CREATE OR REPLACE FUNCTION jwt.url_encode(data bytea) RETURNS text LANGUAGE sql AS $$
SELECT translate(encode(data, 'base64'), E'+/=\n', '-_'); SELECT translate(encode(data, 'base64'), E'+/=\n', '-_');
$$; $$;
@@ -31,7 +30,7 @@ WITH
WHEN algorithm = 'HS384' THEN 'sha384' WHEN algorithm = 'HS384' THEN 'sha384'
WHEN algorithm = 'HS512' THEN 'sha512' WHEN algorithm = 'HS512' THEN 'sha512'
ELSE '' END) -- hmac throws error ELSE '' END) -- hmac throws error
SELECT jwt.url_encode(hmac(signables, secret, (select * FROM alg))); SELECT jwt.url_encode(public.hmac(signables, secret, (select * FROM alg)));
$$; $$;
+40
View File
@@ -5,6 +5,7 @@ GRANT USAGE ON SCHEMA
, jwt , jwt
, public , public
, "تست" , "تست"
, extensions
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
-- Schema test objects -- Schema test objects
@@ -61,6 +62,42 @@ GRANT ALL ON TABLE
, descendant , descendant
, being_part , being_part
, part , part
, leak
, perf_articles
, employees
, tiobe_pls
, only_pk
, family_tree
, organizations
, authors
, books
, forties_books
, fifties_books
, sixties_books
, person
, message
, person_detail
, space
, zone
, projects_dump
, "UnitTest"
, json_arr
, jsonb_test
, authors_books_number
, authors_have_book_in_decade
, forties_and_fifties_books
, odd_years_publications
, foos
, bars
, materialized_projects
, contract
, player_view
, contract_view
, ltree_sample
, isn_sample
, projects_count_grouped_by
, "Server Today"
, pgrst_reserved_chars
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
@@ -69,6 +106,7 @@ GRANT USAGE ON SEQUENCE
auto_incrementing_pk_id_seq auto_incrementing_pk_id_seq
, items_id_seq , items_id_seq
, callcounter_count , callcounter_count
, leak_id_seq
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
-- Privileges for non anonymous users -- Privileges for non anonymous users
@@ -81,3 +119,5 @@ GRANT UPDATE (article_id, user_id) ON TABLE limited_article_stars TO postgrest_t
REVOKE EXECUTE ON FUNCTION privileged_hello(text) FROM PUBLIC; -- All functions are available to every role(PUBLIC) by default REVOKE EXECUTE ON FUNCTION privileged_hello(text) FROM PUBLIC; -- All functions are available to every role(PUBLIC) by default
GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author; GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author;
GRANT USAGE ON SCHEMA test TO postgrest_test_default_role;
+334 -20
View File
@@ -17,7 +17,7 @@ CREATE SCHEMA postgrest;
CREATE SCHEMA private; CREATE SCHEMA private;
CREATE SCHEMA test; CREATE SCHEMA test;
CREATE SCHEMA تست; CREATE SCHEMA تست;
CREATE SCHEMA extensions;
-- --
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: - -- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
@@ -112,16 +112,19 @@ CREATE TABLE items (
); );
SET search_path = public, pg_catalog;
--
-- Name: always_true(test.items); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION always_true(test.items) RETURNS boolean CREATE FUNCTION always_true(test.items) RETURNS boolean
LANGUAGE sql STABLE LANGUAGE sql STABLE
AS $$ SELECT true $$; AS $$ SELECT true $$;
CREATE FUNCTION anti_id(test.items) RETURNS bigint
LANGUAGE sql STABLE
AS $_$ SELECT $1.id * -1 $_$;
SET search_path = public, pg_catalog;
CREATE FUNCTION always_false(test.items) RETURNS boolean
LANGUAGE sql STABLE
AS $$ SELECT false $$;
create table public_consumers ( create table public_consumers (
id serial not null unique, id serial not null unique,
@@ -136,16 +139,6 @@ create table public_orders (
primary key (id) primary key (id)
); );
--
-- Name: anti_id(test.items); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION anti_id(test.items) RETURNS bigint
LANGUAGE sql STABLE
AS $_$ SELECT $1.id * -1 $_$;
SET search_path = تست, pg_catalog; SET search_path = تست, pg_catalog;
CREATE TABLE موارد ( CREATE TABLE موارد (
@@ -229,6 +222,10 @@ AS $_$
SELECT 'Hi'::text; SELECT 'Hi'::text;
$_$; $_$;
COMMENT ON FUNCTION varied_arguments(double precision, character varying, boolean, date, money, enum_menagerie_type, integer) IS
$_$An RPC function
Just a test for RPC function arguments$_$;
-- --
-- Name: jwt_test(); Type: FUNCTION; Schema: test; Owner: - -- Name: jwt_test(); Type: FUNCTION; Schema: test; Owner: -
@@ -638,6 +635,9 @@ CREATE TABLE no_pk (
b character varying b character varying
); );
CREATE TABLE only_pk (
id integer primary key
);
-- --
-- Name: nullable_integer; Type: TABLE; Schema: test; Owner: - -- Name: nullable_integer; Type: TABLE; Schema: test; Owner: -
@@ -1332,6 +1332,320 @@ $$ language sql;
create or replace function test.set_cookie_twice() returns void as $$ create or replace function test.set_cookie_twice() returns void as $$
set local "response.headers" = '[{"Set-Cookie": "sessionid=38afes7a8; HttpOnly; Path=/"}, {"Set-Cookie": "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"}]'; set local "response.headers" = '[{"Set-Cookie": "sessionid=38afes7a8; HttpOnly; Path=/"}, {"Set-Cookie": "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"}]';
$$ language sql; $$ language sql;
--
-- PostgreSQL database dump complete create or replace function test.three_defaults(a int default 1, b int default 2, c int default 3) returns int as $$
-- select a + b + c
$$ language sql;
create or replace function test.overloaded() returns setof int as $$
values (1), (2), (3);
$$ language sql;
create or replace function test.overloaded(pg_catalog.json) returns table(x int, y text) as $$
select * from json_to_recordset($1) as r(x int, y text);
$$ language sql;
create or replace function test.overloaded(a int, b int) returns int as $$
select a + b
$$ language sql;
create or replace function test.overloaded(a text, b text, c text) returns text as $$
select a || b || c
$$ language sql;
create table test.leak(
id serial primary key,
blob bytea
);
create function test.leak(blob bytea) returns void as $$ begin end; $$ language plpgsql;
create table test.perf_articles(
id integer not null,
body text not null
);
create table test.employees(
first_name text,
last_name text,
salary money,
company text,
occupation text,
primary key(first_name, last_name)
);
create table test.tiobe_pls(
name text primary key,
rank smallint
);
create table test.family_tree (
id text not null primary key,
name text not null,
parent text
);
alter table only test.family_tree add constraint pptr foreign key (parent) references test.family_tree(id);
create table test.organizations (
id integer primary key,
name text,
referee integer,
auditor integer
);
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);
create table private.authors(
id integer primary key,
name text
);
create table private.publishers(
id integer primary key,
name text
);
create table private.books(
id integer primary key,
title text,
publication_year smallint,
author_id integer references private.authors(id),
first_publisher_id integer references private.publishers(id)
);
create view test.authors as select id, name from private.authors;
create view test.books as select id, title, publication_year, author_id from private.books;
create view test.forties_books as select id, title, publication_year, author_id from private.books where publication_year >= 1940 and publication_year < 1950;
create view test.fifties_books as select id, title, publication_year, author_id from private.books where publication_year >= 1950 and publication_year < 1960;
create view test.sixties_books as select id, title, publication_year, author_id from private.books where publication_year >= 1960 and publication_year < 1970;
create table person (
id integer primary key,
name character varying not null);
create table message (
id integer primary key,
body text not null default '',
sender bigint not null references person(id),
recipient bigint not null references person(id));
create view person_detail as
select p.id, p.name, s.count as sent, r.count as received
from person p
join lateral (select message.sender, count(message.id) as count from message group by message.sender) s on s.sender = p.id
join lateral (select message.recipient, count(message.id) as count from message group by message.recipient) r on r.recipient = p.id;
create table space(
id integer primary key,
name text);
create table zone(
id integer primary key,
name text,
zone_type_id integer,
space_id integer references space(id));
-- foreign table tests
create extension file_fdw;
create server import_csv foreign data wrapper file_fdw;
create foreign table projects_dump (
id integer,
name text,
client_id integer
) server import_csv options ( filename '/tmp/projects_dump.csv', format 'csv');
comment on foreign table projects_dump is
$$A temporary projects dump
Just a test for foreign tables$$;
create table "UnitTest"(
"idUnitTest" integer primary key,
"nameUnitTest" text
);
create table json_arr(
id integer primary key,
data pg_catalog.json
);
create table jsonb_test(
id integer primary key,
data jsonb
);
create view test.authors_books_number as
select
id,
name,
(
select
count(*)
from forties_books where author_id = authors.id
) as num_in_forties,
(
select
count(*)
from fifties_books where author_id = authors.id
) as num_in_fifties,
(
select
count(*)
from sixties_books where author_id = authors.id
) as num_in_sixties,
(
select
count(*)
from (
select id
from forties_books where author_id = authors.id
union
select id
from fifties_books where author_id = authors.id
union
select id
from sixties_books where author_id = authors.id
) _
) as num_in_all_decades
from private.authors;
create view test.authors_have_book_in_decade as
select
id,
name,
case
when (x.id in (select author_id from test.forties_books))
then true
else false
end as has_book_in_forties,
case
when (x.id in (select author_id from test.fifties_books))
then true
else false
end as has_book_in_fifties,
case
when (x.id in (select author_id from test.sixties_books))
then true
else false
end as has_book_in_sixties
from private.authors x;
create view test.forties_and_fifties_books as
select x.id, x.title, x.publication_year, y.name as first_publisher, x.author_id
from (
select id, title, publication_year, author_id, first_publisher_id from private.books
where publication_year >= 1940 and publication_year < 1960) x
join private.publishers y on y.id = x.first_publisher_id;
create view test.odd_years_publications as
with
odd_years_books as(
select id, title, publication_year, author_id, first_publisher_id
from private.books
where publication_year % 2 <> 0
)
select
x.id, x.title, x.publication_year,
y.name as first_publisher, x.author_id
from odd_years_books x
join private.publishers y on y.id = x.first_publisher_id;
create view test.projects_count_grouped_by as
select
client_id,
count(id) as number_of_projects
from projects
group by client_id;
CREATE TABLE test."Foo"(
id int primary key,
name text
);
CREATE TABLE test.bar(
id int primary key,
name text,
"fooId" int references "Foo"(id)
);
CREATE VIEW test.foos as select id,name from "Foo";
CREATE VIEW test.bars as select id, "fooId", name from bar;
create materialized view materialized_projects as
select id, name, client_id from projects;
comment on materialized view materialized_projects is
$$A materialized view for projects
Just a test for materialized views$$;
create or replace function test."quotedFunction"("user" text, "fullName" text, "SSN" text)
returns jsonb AS $$
select format('{"user": "%s", "fullName": "%s", "SSN": "%s"}', "user", "fullName", "SSN")::jsonb;
$$ language sql;
create table private.player (
id integer not null,
first_name text not null,
last_name text not null,
birth_date date,
primary key (last_name, id, first_name, birth_date) -- just for testing a long compound pk
);
create table test.contract (
tournament text not null,
time tsrange not null,
purchase_price int not null,
id integer not null,
first_name text not null,
last_name text not null,
birth_date date,
foreign key (last_name, id, first_name, birth_date) references private.player
);
create view test.player_view as select * from private.player;
create view test.contract_view as select * from test.contract;
create type public.my_type AS enum ('something');
create function test.test_arg(my_arg public.my_type) returns text as $$
select 'foobar'::text;
$$ language sql;
create extension if not exists ltree with schema public;
create table test.ltree_sample (
path public.ltree
);
CREATE FUNCTION test.number_of_labels(test.ltree_sample) RETURNS integer AS $$
SELECT nlevel($1.path)
$$ language sql;
create extension if not exists isn with schema extensions;
create table test.isn_sample (
id extensions.isbn,
name text
);
create function test.is_valid_isbn(input text) returns boolean as $$
select is_valid(input::isbn);
$$ language sql;
create table "Server Today"(
"cHostname" text,
"Just A Server Model" text
);
create table test.pgrst_reserved_chars (
"*id*" integer,
":arr->ow::cast" text,
"(inside,parens)" text,
"a.dotted.column" text,
" col w space " text
);
+122 -4
View File
@@ -40,9 +40,10 @@ pgrStopAll(){ pkill -f "$(stack path --local-install-root)/bin/postgrest"; }
rootStatus(){ rootStatus(){
curl -s -o /dev/null -w '%{http_code}' "http://localhost:$pgrPort/" curl -s -o /dev/null -w '%{http_code}' "http://localhost:$pgrPort/"
} }
authorsStatus(){ authorsStatus(){
curl -s -o /dev/null -w '%{http_code}' \ curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $( cat "$1" )" \ -H "Authorization: Bearer $1" \
"http://localhost:$pgrPort/authors_only" "http://localhost:$pgrPort/authors_only"
} }
@@ -68,7 +69,7 @@ readSecretFromFile(){
if pgrStarted if pgrStarted
then then
authorsJwt="./secrets/${1%.*}.jwt" authorsJwt="./secrets/${1%.*}.jwt"
httpStatus="$( authorsStatus "$authorsJwt" )" httpStatus="$( authorsStatus $(cat "$authorsJwt") )"
if test "$httpStatus" -eq 200 if test "$httpStatus" -eq 200
then then
ok "authentication with $2 secret read from a file" ok "authentication with $2 secret read from a file"
@@ -81,6 +82,105 @@ readSecretFromFile(){
pgrStop pgrStop
} }
readDbUriFromFile(){
pgrConfig="dburi-from-file.config"
pgrStartRead "./configs/$pgrConfig" "./dburis/$1"
while pgrStarted && test "$( rootStatus )" -ne 200
do
# wait for the server to start
sleep 0.1 \
|| sleep 1 # fallback: subsecond sleep is not standard and may fail
done
if pgrStarted
then
ok "connection with $2 dburi read from a file"
else
ko "failed to read $2 dburi from a file"
fi
pgrStop
}
reqWithRoleClaimKey(){
export ROLE_CLAIM_KEY=$1
pgrStart "./configs/role-claim-key.config"
while pgrStarted && test "$( rootStatus )" -ne 200
do
# wait for the server to start
sleep 0.1 \
|| sleep 1 # fallback: subsecond sleep is not standard and may fail
done
authorsJwt=$(psql -qtAX postgrest_test -c "select jwt.sign('$2', 'reallyreallyreallyreallyverysafe');")
httpStatus="$( authorsStatus "$authorsJwt" )"
if test "$httpStatus" -eq $3
then
ok "request with \"$1\" role-claim-key for $2 jwt gave $3"
else
ko "request with \"$1\" role-claim-key for $2 jwt gave $httpStatus"
fi
pgrStop
}
invalidRoleClaimKey(){
export ROLE_CLAIM_KEY=$1
pgrStart "./configs/role-claim-key.config"
while pgrStarted && test "$( rootStatus )" -ne 200
do
# wait for the server to start
sleep 0.1 \
|| sleep 1 # fallback: subsecond sleep is not standard and may fail
done
if pgrStarted
then
ko "invalid jspath \"$1\" accepted"
else
ok "invalid jspath \"$1\" rejected"
fi
pgrStop
}
# ensure iat claim is successful in the presence of pgrst time cache, see https://github.com/PostgREST/postgrest/issues/1139
ensureIatClaimWorks(){
pgrStart "./configs/simple.config"
while pgrStarted && test "$( rootStatus )" -ne 200
do
# wait for the server to start
sleep 0.1 \
|| sleep 1 # fallback: subsecond sleep is not standard and may fail
done
for i in {1..10}; do \
iatJwt=$(psql -qtAX postgrest_test -c "select jwt.sign(row_to_json(r), 'reallyreallyreallyreallyverysafe') from ( select 'postgrest_test_author' as role, extract(epoch from now()) as iat) r")
httpStatus="$( authorsStatus $iatJwt )"
if test "$httpStatus" -ne 200
then
ko "iat claim rejected with $httpStatus"
return
fi
sleep .5;\
done
ok "accepted iat claim"
pgrStop
}
# ensure app settings don't reset on pool timeout of 10 seconds, see https://github.com/PostgREST/postgrest/issues/1141
ensureAppSettings(){
pgrStart "./configs/app-settings.config"
while pgrStarted && test "$( rootStatus )" -ne 200
do
# wait for the server to start
sleep 0.1 \
|| sleep 1 # fallback: subsecond sleep is not standard and may fail
done
sleep 11
response=$(curl -s "http://localhost:$pgrPort/rpc/get_guc_value?name=app.settings.external_api_secret")
if test "$response" = "\"0123456789abcdef\""
then
ok "GET /rpc/get_guc_value response is $response"
else
ko "GET /rpc/get_guc_value response was $response"
fi
pgrStop
}
# PRE: curl must be available # PRE: curl must be available
test -n "$(command -v curl)" || bailOut 'curl is not available' test -n "$(command -v curl)" || bailOut 'curl is not available'
@@ -89,8 +189,7 @@ psql -l 1>/dev/null 2>/dev/null || bailOut 'postgres is not running'
setUp setUp
totalTests=12 echo "Running IO tests.."
echo "1..$totalTests"
readSecretFromFile word.noeol 'simple (no EOL)' readSecretFromFile word.noeol 'simple (no EOL)'
readSecretFromFile word.txt 'simple' readSecretFromFile word.txt 'simple'
@@ -106,6 +205,25 @@ readSecretFromFile ascii.b64 'Base64 (ASCII)'
readSecretFromFile utf8.b64 'Base64 (UTF-8)' readSecretFromFile utf8.b64 'Base64 (UTF-8)'
readSecretFromFile binary.b64 'Base64 (binary)' readSecretFromFile binary.b64 'Base64 (binary)'
readDbUriFromFile uri.noeol "(no EOL)"
readDbUriFromFile uri.txt "(EOL)"
reqWithRoleClaimKey '.postgrest.a_role' '{"postgrest":{"a_role":"postgrest_test_author"}}' 200
reqWithRoleClaimKey '.customObject.manyRoles[1]' '{"customObject":{"manyRoles": ["other", "postgrest_test_author"]}}' 200
reqWithRoleClaimKey '."https://www.example.com/roles"[0].value' '{"https://www.example.com/roles":[{"value":"postgrest_test_author"}]}' 200
reqWithRoleClaimKey '.myDomain[3]' '{"myDomain":["other","postgrest_test_author"]}' 401
reqWithRoleClaimKey '.myRole' '{"role":"postgrest_test_author"}' 401
invalidRoleClaimKey 'role.other'
invalidRoleClaimKey '.role##'
invalidRoleClaimKey '.my_role;;domain'
invalidRoleClaimKey '.#$%&$%/'
invalidRoleClaimKey ''
invalidRoleClaimKey 1234
ensureIatClaimWorks
ensureAppSettings
cleanUp cleanUp
exit $failedTests exit $failedTests
@@ -0,0 +1,8 @@
db-uri = "postgres:///postgrest_test"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-port = 49421
app.settings.external_api_secret = "0123456789abcdef"
@@ -2,7 +2,7 @@ db-uri = "postgres:///postgrest_test"
db-schema = "test" db-schema = "test"
db-anon-role = "postgrest_test_anonymous" db-anon-role = "postgrest_test_anonymous"
db-pool = 1 db-pool = 1
server-host = "*4" server-host = "127.0.0.1"
server-port = 49421 server-port = 49421
# Read secret from a file: /dev/stdin (alias for standard input) # Read secret from a file: /dev/stdin (alias for standard input)
@@ -0,0 +1,7 @@
db-uri = "@/dev/stdin"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-port = 49421
jwt-secret = "reallyreallyreallyreallyverysafe"
@@ -0,0 +1,8 @@
db-uri = "postgres:///postgrest_test"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-port = 49421
role-claim-key = "$(ROLE_CLAIM_KEY)"
jwt-secret = "reallyreallyreallyreallyverysafe"
@@ -2,7 +2,7 @@ db-uri = "postgres:///postgrest_test"
db-schema = "test" db-schema = "test"
db-anon-role = "postgrest_test_anonymous" db-anon-role = "postgrest_test_anonymous"
db-pool = 1 db-pool = 1
server-host = "*4" server-host = "127.0.0.1"
server-port = 49421 server-port = 49421
# Read secret from a file: /dev/stdin (alias for standard input) # Read secret from a file: /dev/stdin (alias for standard input)
+7
View File
@@ -0,0 +1,7 @@
db-uri = "postgres:///postgrest_test"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-port = 49421
jwt-secret = "reallyreallyreallyreallyverysafe"
+1
View File
@@ -0,0 +1 @@
postgres:///postgrest_test
+1
View File
@@ -0,0 +1 @@
postgres:///postgrest_test
+117
View File
@@ -0,0 +1,117 @@
#! /usr/bin/env bash
currentTest=1
failedTests=0
result(){ echo "$1 $currentTest $2"; currentTest=$(( $currentTest + 1 )); }
ok(){ result 'ok' "- $1"; }
ko(){ result 'not ok' "- $1"; failedTests=$(( $failedTests + 1 )); }
pgrPort=49421
pgrStopAll(){ pkill -f "$(stack path --local-install-root)/bin/postgrest"; }
pgrStart(){ stack exec -- postgrest test/memory-tests/config +RTS -p -h >/dev/null & pgrPID="$!"; }
pgrStop(){ kill "$pgrPID" 2>/dev/null; }
setUp(){ pgrStopAll; }
cleanUp(){ pgrStopAll; }
checkPgrStarted(){
while pgrStarted && test $(rootStatus) -ne 200
do
sleep 1
done
}
pgrStarted(){ kill -0 "$pgrPID" 2>/dev/null; }
rootStatus(){
curl -s -o /dev/null -I -w '%{http_code}' "http://localhost:$pgrPort/"
}
jsonKeyTest(){
pgrStart
checkPgrStarted
factor=$(( 3*$(numfmt --from=si $1)/4 )) # 3/4 on $1 is need to maintain the specified size because of base64
payload="{\"blob\" : \"$(dd if=/dev/zero bs=$factor count=1 status=none | base64)\"}"
httpStatus=$(echo $payload | curl -s -H "Content-Type: application/json" --request $2 -d @- -w '%{http_code}' http://localhost:$pgrPort$3 | tr -d '"')
if test "$httpStatus" -ge 200 && test "$httpStatus" -lt 210
then
pgrStop
while [ ! -s postgrest.prof ]
do
sleep 1
done
BYTES_FMT=$(cat postgrest.prof | grep -o -P '(?<=alloc =).*(?=bytes)' | tr -d ' ')
BYTES=$(echo $BYTES_FMT | tr -d ',')
MAX_BYTES=$(numfmt --from=si $4)
if test $BYTES -le $MAX_BYTES
then
ok "$2 $3: with a json key of $1 the memory usage($BYTES_FMT bytes) is less than $4"
else
ko "$2 $3: with a json key of $1 the memory usage($BYTES_FMT bytes) is more than $4"
fi
else
pgrStop
ko "$2 $3: request failed with http $httpStatus"
fi
}
postJsonArrayTest(){
pgrStart
checkPgrStarted
arr=()
arr+=('[')
for i in $(seq 1 $(expr $1 - 1))
do
arr+=("{\"id\": $i, \"body\": \"xxxxxxx\"},")
done
arr+=("{\"id\": $1, \"body\": \"xxxxxxx\"}")
arr+=(']')
payload=$(echo ${arr[*]})
httpStatus=$(echo $payload | curl -s -H "Content-Type: application/json" -d @- -w '%{http_code}' http://localhost:$pgrPort$2 | tr -d '"')
if test "$httpStatus" -ge 200 && test "$httpStatus" -lt 210
then
pgrStop
while [ ! -s postgrest.prof ]
do
sleep 1
done
BYTES_FMT=$(cat postgrest.prof | grep -o -P '(?<=alloc =).*(?=bytes)' | tr -d ' ')
BYTES=$(echo $BYTES_FMT | tr -d ',')
MAX_BYTES=$(numfmt --from=si $3)
PAYLOAD_SIZE=$(echo $payload | wc -c | numfmt --to=si)
if test $BYTES -le $MAX_BYTES
then
ok "POST $2: with a json payload of $PAYLOAD_SIZE that has $1 array values the memory usage($BYTES_FMT bytes) is less than $3"
else
ko "POST $2: with a json payload of $PAYLOAD_SIZE that has $1 array values the memory usage($BYTES_FMT bytes) is more than $3"
fi
else
pgrStop
ko "POST $2: request failed with http $httpStatus"
fi
}
stack build --profile
setUp
echo "Running memory usage tests.."
jsonKeyTest "1M" "POST" "/rpc/leak" "20M"
jsonKeyTest "1M" "POST" "/leak" "20M"
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1" "20M"
jsonKeyTest "10M" "POST" "/rpc/leak" "105M"
jsonKeyTest "10M" "POST" "/leak" "105M"
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1" "105M"
jsonKeyTest "50M" "POST" "/rpc/leak" "500M"
jsonKeyTest "50M" "POST" "/leak" "500M"
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1" "500M"
postJsonArrayTest "1000" "/perf_articles" "20M"
postJsonArrayTest "10000" "/perf_articles" "150M"
postJsonArrayTest "100000" "/perf_articles" "1.15G"
cleanUp
exit $failedTests
+8
View File
@@ -0,0 +1,8 @@
db-uri = "postgres:///postgrest_test"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-port = 49421
jwt-secret = "reallyreallyreallyreallyverysafe"