Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e732ac94a | ||
|
|
6b2778749f | ||
|
|
36f86827ee | ||
|
|
d78410473e | ||
|
|
501edc718d | ||
|
|
0d6d112b38 | ||
|
|
473ac70789 | ||
|
|
dadfe965b9 | ||
|
|
63ead89470 | ||
|
|
ab23ed7999 | ||
|
|
2da6bd6d1c | ||
|
|
5bfb68b982 | ||
|
|
dc834572d6 | ||
|
|
b48824bddd | ||
|
|
6d326fe341 | ||
|
|
d94cf2ed72 | ||
|
|
27ca6b4e90 | ||
|
|
e0cc4d1571 | ||
|
|
3cef4b70b0 | ||
|
|
6f97c34a86 | ||
|
|
bdac90491d | ||
|
|
5961f7a116 | ||
|
|
17cd2725fd | ||
|
|
5e7606134a | ||
|
|
dfa9055c34 | ||
|
|
6907e7f979 | ||
|
|
8cf68c63d9 | ||
|
|
30b5859b28 | ||
|
|
0a1d83ce8f | ||
|
|
d7511a2637 | ||
|
|
2066220244 | ||
|
|
93f10adb3c | ||
|
|
56bd5d5f91 | ||
|
|
70e95649fd | ||
|
|
2b46afe1ec | ||
|
|
fa1e92fdf2 | ||
|
|
b1a8bd2391 | ||
|
|
69a76a627f | ||
|
|
105671e51a | ||
|
|
ecf0e9213f | ||
|
|
1c6ded16d1 | ||
|
|
6fc9d5191a | ||
|
|
80f09780cc | ||
|
|
9e3454129f | ||
|
|
d34afe861a | ||
|
|
30dfadec7b | ||
|
|
2513c00039 | ||
|
|
100bf494ac | ||
|
|
e8188b0d41 | ||
|
|
3958ebbb05 | ||
|
|
37e7398a85 | ||
|
|
9ea7529f30 | ||
|
|
384767708b | ||
|
|
6bcbb124d2 | ||
|
|
f6c1ff810e | ||
|
|
f80cfbf165 | ||
|
|
d8896be2c1 | ||
|
|
903a8d5f5a | ||
|
|
ca76a8e6be | ||
|
|
28845e0f43 | ||
|
|
30cf1d100a | ||
|
|
05180f6539 | ||
|
|
b00f57ac34 | ||
|
|
e9aaf05335 | ||
|
|
79a7ce49f2 | ||
|
|
3a1213f53b | ||
|
|
f033c2c4b5 | ||
|
|
5c87fe2704 | ||
|
|
58f4b4bc33 | ||
|
|
32c7e32bdf | ||
|
|
062a5581f5 | ||
|
|
50512e1117 | ||
|
|
edae60f8c1 | ||
|
|
243e692192 | ||
|
|
349a5ae076 | ||
|
|
ff709a65e5 | ||
|
|
8e2a0e05ea | ||
|
|
108f3cd651 | ||
|
|
6675821c64 | ||
|
|
85b1dc0eb4 | ||
|
|
102392e4ab | ||
|
|
a46b6f5020 | ||
|
|
70ce1b9329 | ||
|
|
516976e32f | ||
|
|
f7e7834a1c | ||
|
|
38f3bcf4a6 | ||
|
|
f159233de8 | ||
|
|
02a286a4b1 | ||
|
|
85d9feeeab | ||
|
|
f9e770b583 | ||
|
|
effbec234f | ||
|
|
e4183780a9 | ||
|
|
804c0b7f6b | ||
|
|
fef7d949d9 | ||
|
|
be630aa680 | ||
|
|
678b855614 | ||
|
|
546b766022 | ||
|
|
57477749aa | ||
|
|
188f947437 | ||
|
|
b9a591aecb | ||
|
|
38de56de4a | ||
|
|
d9a250d2cb | ||
|
|
2b5ae34c5a | ||
|
|
d1a8c3a6f8 | ||
|
|
7a3f350f1c | ||
|
|
e1cab584a3 | ||
|
|
8f49f731d0 | ||
|
|
7bf384d0f8 | ||
|
|
65c9d549c1 | ||
|
|
a6cce691b5 | ||
|
|
3ccae4bb8b | ||
|
|
4ba27d84a4 | ||
|
|
cf19ad0369 | ||
|
|
32117ba477 | ||
|
|
893b66c969 | ||
|
|
6c2f179b48 | ||
|
|
dff4d766a8 | ||
|
|
d98a05023d | ||
|
|
0d37be9017 |
+149
-10
@@ -52,8 +52,15 @@ 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:
|
||||||
machine: true
|
docker:
|
||||||
|
- image: circleci/buildpack-deps:trusty
|
||||||
|
environment:
|
||||||
|
- PGHOST=localhost
|
||||||
|
- image: circleci/postgres:9.4.14
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=circleci
|
||||||
|
- POSTGRES_DB=circleci
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- restore_cache:
|
- restore_cache:
|
||||||
@@ -65,8 +72,9 @@ jobs:
|
|||||||
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
|
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 mv /tmp/stack-1.1.2-linux-x86_64/stack /usr/bin
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install libgmp-dev
|
sudo apt-get install -y libgmp-dev
|
||||||
sudo apt-get install --only-upgrade binutils
|
sudo apt-get install -y --only-upgrade binutils
|
||||||
|
sudo apt-get install -y postgresql-client
|
||||||
stack setup
|
stack setup
|
||||||
rm -rf $(stack path --dist-dir) $(stack path --local-install-root)
|
rm -rf $(stack path --dist-dir) $(stack path --local-install-root)
|
||||||
stack install hlint packdeps cabal-install
|
stack install hlint packdeps cabal-install
|
||||||
@@ -78,7 +86,6 @@ jobs:
|
|||||||
- run:
|
- run:
|
||||||
name: run tests
|
name: run tests
|
||||||
command: |
|
command: |
|
||||||
sudo service postgresql start
|
|
||||||
POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
||||||
test/io-tests.sh
|
test/io-tests.sh
|
||||||
- run:
|
- run:
|
||||||
@@ -99,6 +106,114 @@ jobs:
|
|||||||
- ".stack-work"
|
- ".stack-work"
|
||||||
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||||
|
|
||||||
|
build-test-9.6:
|
||||||
|
docker:
|
||||||
|
- image: circleci/buildpack-deps:trusty
|
||||||
|
environment:
|
||||||
|
- PGHOST=localhost
|
||||||
|
- image: circleci/postgres:9.6.2
|
||||||
|
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-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
|
||||||
|
|
||||||
@@ -141,13 +256,28 @@ workflows:
|
|||||||
version: 2
|
version: 2
|
||||||
build-test-release:
|
build-test-release:
|
||||||
jobs:
|
jobs:
|
||||||
- build-test:
|
- build-test-9.4:
|
||||||
|
filters:
|
||||||
|
tags:
|
||||||
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
- build-test-9.6:
|
||||||
|
filters:
|
||||||
|
tags:
|
||||||
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
- build-test-10:
|
||||||
|
filters:
|
||||||
|
tags:
|
||||||
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
- build-prof-test:
|
||||||
filters:
|
filters:
|
||||||
tags:
|
tags:
|
||||||
only: /v[0-9]+(\.[0-9]+)*/
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
- centos6:
|
- centos6:
|
||||||
requires:
|
requires:
|
||||||
- build-test
|
- build-test-9.4
|
||||||
|
- 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]+)*/
|
||||||
@@ -155,7 +285,10 @@ workflows:
|
|||||||
ignore: /.*/
|
ignore: /.*/
|
||||||
- centos7:
|
- centos7:
|
||||||
requires:
|
requires:
|
||||||
- build-test
|
- build-test-9.4
|
||||||
|
- 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]+)*/
|
||||||
@@ -163,7 +296,10 @@ workflows:
|
|||||||
ignore: /.*/
|
ignore: /.*/
|
||||||
- ubuntu:
|
- ubuntu:
|
||||||
requires:
|
requires:
|
||||||
- build-test
|
- build-test-9.4
|
||||||
|
- 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]+)*/
|
||||||
@@ -171,7 +307,10 @@ workflows:
|
|||||||
ignore: /.*/
|
ignore: /.*/
|
||||||
- ubuntui386:
|
- ubuntui386:
|
||||||
requires:
|
requires:
|
||||||
- build-test
|
- build-test-9.4
|
||||||
|
- 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]+)*/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
@@ -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
@@ -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
|
||||||
+104
@@ -9,6 +9,110 @@ 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- #887, #601, #1007, Allow specifying dictionary and plain/phrase tsquery in full text search - @steve-chavez
|
||||||
|
- #328, Allow doing GET on rpc - @steve-chavez
|
||||||
|
- #917, Add ability to map RAISE errorcode/message to http status - @steve-chavez
|
||||||
|
- #940, Add ability to map GUC to http response headers - @steve-chavez
|
||||||
|
- #1022, Include git sha in version report - @begriffs
|
||||||
|
- Faster queries using json_agg - @ruslantalpa
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #876, Read secret files as binary, discard final LF if any - @eric-brechemier
|
||||||
|
- #968, Treat blank proxy uri as missing - @begriffs
|
||||||
|
- #933, OpenAPI externals docs url to current version - @steve-chavez
|
||||||
|
- #962, OpenAPI don't err on nonexistent schema - @steve-chavez
|
||||||
|
- #954, make OpenAPI rpc output dependent on user privileges - @steve-chavez
|
||||||
|
- #955, Support configurable aud claim - @statik
|
||||||
|
- #996, Fix embedded column conflicts table name - @grotsev
|
||||||
|
- #974, Fix RPC error when function has single OUT param - @steve-chavez
|
||||||
|
- #1021, Reduce join size in allColumns for faster program start - @nextstopsun
|
||||||
|
- #411, Remove the need for pk in &select for parent embed - @steve-chavez
|
||||||
|
- #1016, Fix anonymous requests when configured with jwt-aud - @ruslantalpa
|
||||||
|
|
||||||
## [0.4.3.0] - 2017-09-06
|
## [0.4.3.0] - 2017-09-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||

|

|
||||||
|
|
||||||
[](https://circleci.com/gh/begriffs/postgrest/tree/master)
|
[](https://www.patreon.com/postgrest)
|
||||||
<a href="https://heroku.com/deploy?template=https://github.com/begriffs/postgrest">
|
[](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>
|
||||||
[](https://gitter.im/begriffs/postgrest)
|
[](https://gitter.im/begriffs/postgrest)
|
||||||
[](http://postgrest.com)
|
[](http://postgrest.org)
|
||||||
|
[](https://hub.docker.com/r/postgrest/postgrest/)
|
||||||
|
[](https://circleci.com/gh/PostgREST/postgrest/tree/master)
|
||||||
|
[](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:
|
||||||
|
|
||||||
@@ -21,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
|
||||||
@@ -49,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
|
||||||
@@ -73,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
|
||||||
@@ -81,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
|
||||||
@@ -93,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
|
||||||
@@ -105,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).
|
||||||
|
|||||||
@@ -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.3.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": {
|
||||||
@@ -43,6 +43,10 @@
|
|||||||
"required": false,
|
"required": false,
|
||||||
"value": "false"
|
"value": "false"
|
||||||
},
|
},
|
||||||
|
"JWT_AUD": {
|
||||||
|
"description": "The audience that should be validated if the JWT token contains an aud claim",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
"MAX_ROWS": {
|
"MAX_ROWS": {
|
||||||
"description": "A hard limit to the number of rows PostgREST will fetch from a view, table, or stored procedure",
|
"description": "A hard limit to the number of rows PostgREST will fetch from a view, table, or stored procedure",
|
||||||
"required": false
|
"required": false
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+5
-3
@@ -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 / && \
|
||||||
@@ -30,11 +30,13 @@ ENV PGRST_DB_URI= \
|
|||||||
PGRST_DB_POOL=100 \
|
PGRST_DB_POOL=100 \
|
||||||
PGRST_SERVER_HOST=*4 \
|
PGRST_SERVER_HOST=*4 \
|
||||||
PGRST_SERVER_PORT=3000 \
|
PGRST_SERVER_PORT=3000 \
|
||||||
PGRST_SERVER_PROXY_URL= \
|
PGRST_SERVER_PROXY_URI= \
|
||||||
PGRST_JWT_SECRET= \
|
PGRST_JWT_SECRET= \
|
||||||
PGRST_SECRET_IS_BASE64=false \
|
PGRST_SECRET_IS_BASE64=false \
|
||||||
|
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
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ server-port = "$(PGRST_SERVER_PORT)"
|
|||||||
server-proxy-uri = "$(PGRST_SERVER_PROXY_URI)"
|
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)"
|
||||||
|
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)"
|
||||||
|
|||||||
+83
-69
@@ -2,55 +2,44 @@
|
|||||||
|
|
||||||
module Main where
|
module Main where
|
||||||
|
|
||||||
import PostgREST.App (postgrest)
|
|
||||||
import PostgREST.Config (AppConfig (..),
|
|
||||||
PgVersion (..),
|
|
||||||
minimumPgVersion,
|
|
||||||
prettyVersion, readOptions)
|
|
||||||
import PostgREST.DbStructure (getDbStructure)
|
|
||||||
import PostgREST.Error (encodeError)
|
|
||||||
import PostgREST.OpenAPI (isMalformedProxyUri)
|
|
||||||
import PostgREST.Types (DbStructure, Schema)
|
|
||||||
import Protolude hiding (replace, hPutStrLn)
|
|
||||||
|
|
||||||
import Control.Retry (RetryStatus, capDelay,
|
import PostgREST.App (postgrest)
|
||||||
exponentialBackoff,
|
import PostgREST.Config (AppConfig (..),
|
||||||
retrying, rsPreviousDelay)
|
prettyVersion, readOptions)
|
||||||
import Data.ByteString.Base64 (decode)
|
import PostgREST.DbStructure (getDbStructure, getPgVersion)
|
||||||
import Data.IORef (IORef, atomicWriteIORef,
|
import PostgREST.Error (encodeError)
|
||||||
newIORef, readIORef)
|
import PostgREST.OpenAPI (isMalformedProxyUri)
|
||||||
import Data.String (IsString (..))
|
import PostgREST.Types (DbStructure, Schema, PgVersion(..), minimumPgVersion)
|
||||||
import Data.Text (pack, replace, stripPrefix, strip)
|
import Protolude hiding (hPutStrLn, replace)
|
||||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
|
||||||
import Data.Text.IO (hPutStrLn, readFile)
|
|
||||||
import qualified Hasql.Decoders as HD
|
import Control.AutoUpdate (defaultUpdateSettings,
|
||||||
import qualified Hasql.Encoders as HE
|
mkAutoUpdate, updateAction)
|
||||||
import qualified Hasql.Pool as P
|
import Control.Retry (RetryStatus, capDelay,
|
||||||
import qualified Hasql.Query as H
|
exponentialBackoff,
|
||||||
import qualified Hasql.Session as H
|
retrying, rsPreviousDelay)
|
||||||
import Network.Wai.Handler.Warp (defaultSettings,
|
import qualified Data.ByteString as BS
|
||||||
runSettings, setHost,
|
import qualified Data.ByteString.Base64 as B64
|
||||||
setPort, setServerName,
|
import Data.IORef (IORef, atomicWriteIORef,
|
||||||
setTimeout)
|
newIORef, readIORef)
|
||||||
import System.IO (BufferMode (..),
|
import Data.String (IsString (..))
|
||||||
hSetBuffering)
|
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)
|
||||||
|
|
||||||
#ifndef mingw32_HOST_OS
|
#ifndef mingw32_HOST_OS
|
||||||
import System.Posix.Signals
|
import System.Posix.Signals
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
{-|
|
|
||||||
Used by connectionWorker to know if it should throw an error and kill the
|
|
||||||
main thread.
|
|
||||||
-}
|
|
||||||
isServerVersionSupported :: H.Session Bool
|
|
||||||
isServerVersionSupported = do
|
|
||||||
ver <- H.query () pgVersion
|
|
||||||
return $ ver >= pgvNum minimumPgVersion
|
|
||||||
where
|
|
||||||
pgVersion =
|
|
||||||
H.statement "SELECT current_setting('server_version_num')::integer"
|
|
||||||
HE.unit (HD.singleRow $ HD.value HD.int4) False
|
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
The purpose of this worker is to fill the refDbStructure created in 'main'
|
The purpose of this worker is to fill the refDbStructure created in 'main'
|
||||||
with the 'DbStructure' returned from calling 'getDbStructure'. This method
|
with the 'DbStructure' returned from calling 'getDbStructure'. This method
|
||||||
@@ -70,7 +59,7 @@ isServerVersionSupported = do
|
|||||||
goes back to 1, otherwise it finishes his work successfully.
|
goes back to 1, otherwise it finishes his work successfully.
|
||||||
-}
|
-}
|
||||||
connectionWorker
|
connectionWorker
|
||||||
:: ThreadId -- ^ This thread is killed if 'isServerVersionSupported' returns false
|
:: ThreadId -- ^ This thread is killed if pg version is unsupported
|
||||||
-> P.Pool -- ^ The PostgreSQL connection pool
|
-> P.Pool -- ^ The PostgreSQL connection pool
|
||||||
-> Schema -- ^ Schema PostgREST is serving up
|
-> Schema -- ^ Schema PostgREST is serving up
|
||||||
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
|
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
|
||||||
@@ -88,13 +77,13 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
|||||||
connected <- connectingSucceeded pool
|
connected <- connectingSucceeded pool
|
||||||
when connected $ do
|
when connected $ do
|
||||||
result <- P.use pool $ do
|
result <- P.use pool $ do
|
||||||
supported <- isServerVersionSupported
|
actualPgVersion <- getPgVersion
|
||||||
unless supported $ liftIO $ do
|
unless (actualPgVersion >= minimumPgVersion) $ liftIO $ do
|
||||||
hPutStrLn stderr
|
hPutStrLn stderr
|
||||||
("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
|
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
|
||||||
@@ -105,7 +94,6 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
|||||||
atomicWriteIORef refIsWorkerOn False
|
atomicWriteIORef refIsWorkerOn False
|
||||||
putStrLn ("Connection successful" :: Text)
|
putStrLn ("Connection successful" :: Text)
|
||||||
|
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Used by 'connectionWorker' to check if the provided db-uri lets
|
Used by 'connectionWorker' to check if the provided db-uri lets
|
||||||
the application access the PostgreSQL database. This method is used
|
the application access the PostgreSQL database. This method is used
|
||||||
@@ -151,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
|
||||||
@@ -206,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
|
||||||
@@ -264,14 +261,16 @@ loadSecretFile conf = extractAndTransform mSecret
|
|||||||
fmap setSecret $
|
fmap setSecret $
|
||||||
transformString isB64 =<<
|
transformString isB64 =<<
|
||||||
case stripPrefix "@" secret of
|
case stripPrefix "@" secret of
|
||||||
Nothing -> return secret
|
Nothing -> return . encodeUtf8 $ secret
|
||||||
Just filename -> readFile (toS filename)
|
Just filename -> chomp <$> BS.readFile (toS filename)
|
||||||
|
where
|
||||||
|
chomp bs = fromMaybe bs (BS.stripSuffix "\n" bs)
|
||||||
--
|
--
|
||||||
-- Turns the Base64url encoded JWT into Base64
|
-- Turns the Base64url encoded JWT into Base64
|
||||||
transformString :: Bool -> Text -> IO ByteString
|
transformString :: Bool -> ByteString -> IO ByteString
|
||||||
transformString False t = return . encodeUtf8 $ t
|
transformString False t = return t
|
||||||
transformString True t =
|
transformString True t =
|
||||||
case decode (encodeUtf8 $ strip $ replaceUrlChars t) of
|
case B64.decode $ encodeUtf8 $ strip $ replaceUrlChars $ decodeUtf8 t of
|
||||||
Left errMsg -> panic $ pack errMsg
|
Left errMsg -> panic $ pack errMsg
|
||||||
Right bs -> return bs
|
Right bs -> return bs
|
||||||
setSecret bs = conf {configJwtSecret = Just bs}
|
setSecret bs = conf {configJwtSecret = Just bs}
|
||||||
@@ -279,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}
|
||||||
|
|||||||
+39
-22
@@ -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.3.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,28 +62,30 @@ library
|
|||||||
, configurator-ng == 0.0.0.1
|
, configurator-ng == 0.0.0.1
|
||||||
, containers
|
, containers
|
||||||
, contravariant
|
, contravariant
|
||||||
|
, contravariant-extras
|
||||||
, either
|
, either
|
||||||
, hasql
|
, gitrev
|
||||||
, hasql-pool == 0.4.1
|
, hasql >= 1.3 && < 1.4
|
||||||
, hasql-transaction == 0.5
|
, hasql-pool >= 0.5 && < 0.6
|
||||||
|
, 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
|
, 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
|
||||||
@@ -110,14 +117,19 @@ Test-Suite spec
|
|||||||
ghc-options: -threaded -rtsopts -with-rtsopts=-N
|
ghc-options: -threaded -rtsopts -with-rtsopts=-N
|
||||||
Hs-Source-Dirs: test
|
Hs-Source-Dirs: test
|
||||||
Main-Is: Main.hs
|
Main-Is: Main.hs
|
||||||
Other-Modules: Feature.AuthSpec
|
Other-Modules: Feature.AudienceJwtSecretSpec
|
||||||
|
, Feature.AuthSpec
|
||||||
, Feature.AsymmetricJwtSpec
|
, Feature.AsymmetricJwtSpec
|
||||||
, Feature.BinaryJwtSecretSpec
|
, Feature.BinaryJwtSecretSpec
|
||||||
, 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.ProxySpec
|
, Feature.ProxySpec
|
||||||
, Feature.QueryLimitedSpec
|
, Feature.QueryLimitedSpec
|
||||||
, Feature.QuerySpec
|
, Feature.QuerySpec
|
||||||
@@ -126,22 +138,26 @@ Test-Suite spec
|
|||||||
, Feature.StructureSpec
|
, Feature.StructureSpec
|
||||||
, Feature.UnicodeSpec
|
, Feature.UnicodeSpec
|
||||||
, Feature.AndOrParamsSpec
|
, Feature.AndOrParamsSpec
|
||||||
|
, Feature.RpcSpec
|
||||||
|
, 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
|
||||||
, hjsonpointer
|
|
||||||
, hjsonschema == 1.5.0.1
|
, hjsonschema == 1.5.0.1
|
||||||
, hspec
|
, hspec
|
||||||
, hspec-wai >= 0.7.0
|
, hspec-wai >= 0.7.0
|
||||||
@@ -152,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
|
||||||
|
|||||||
+84
-71
@@ -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,13 +14,13 @@ 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
|
||||||
import qualified Data.Csv as CSV
|
import qualified Data.Csv as CSV
|
||||||
import qualified Data.List as L
|
import qualified Data.List as L
|
||||||
import Data.List (lookup, last)
|
import Data.List (lookup, last, partition)
|
||||||
import qualified Data.HashMap.Strict as M
|
import qualified Data.HashMap.Strict as M
|
||||||
import qualified Data.Set as S
|
import qualified Data.Set as S
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
@@ -33,12 +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)
|
|
||||||
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)
|
||||||
@@ -46,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
|
| 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
|
||||||
@@ -59,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
|
||||||
@@ -84,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
|
||||||
@@ -105,20 +103,22 @@ data ApiRequest = ApiRequest {
|
|||||||
-- | Examines HTTP request and translates it into user intent.
|
-- | Examines HTTP request and translates it into user intent.
|
||||||
userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||||
userApiRequest schema req reqBody
|
userApiRequest schema req reqBody
|
||||||
| isTargetingProc && method /= "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"
|
||||||
, iFilters = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset", "and", "or"] k) ]
|
, iPreferResolution = if hasPrefer (show MergeDuplicates) then Just MergeDuplicates
|
||||||
|
else if hasPrefer (show IgnoreDuplicates) then Just IgnoreDuplicates
|
||||||
|
else Nothing
|
||||||
|
, iFilters = filters
|
||||||
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
|
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
|
||||||
, iSelect = toS $ fromMaybe "*" $ 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 ]
|
||||||
@@ -129,38 +129,53 @@ 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
|
||||||
isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path
|
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
|
||||||
|
(filters, rpcQParams) =
|
||||||
|
case action of
|
||||||
|
ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
|
||||||
|
_ -> (flts, [])
|
||||||
|
flts = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset", "and", "or"] k) ]
|
||||||
|
hasOperator val = any (`T.isPrefixOf` val) $
|
||||||
|
((<> ".") <$> "not":M.keys operators) ++
|
||||||
|
((<> "(") <$> M.keys ftsOperators)
|
||||||
|
isEmbedPath = T.isInfixOf "."
|
||||||
|
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 = case method of
|
action =
|
||||||
"GET" -> if target == TargetRoot
|
case method of
|
||||||
then ActionInspect
|
"GET" | target == TargetRoot -> ActionInspect
|
||||||
else ActionRead
|
| isTargetingProc -> ActionInvoke{isReadOnly=True}
|
||||||
"POST" -> if isTargetingProc
|
| otherwise -> ActionRead
|
||||||
then ActionInvoke
|
|
||||||
else ActionCreate
|
"POST" -> if isTargetingProc
|
||||||
"PATCH" -> ActionUpdate
|
then ActionInvoke{isReadOnly=False}
|
||||||
"DELETE" -> ActionDelete
|
else ActionCreate
|
||||||
"OPTIONS" -> ActionInfo
|
"PATCH" -> ActionUpdate
|
||||||
_ -> ActionInspect
|
"PUT" -> ActionSingleUpsert
|
||||||
|
"DELETE" -> ActionDelete
|
||||||
|
"OPTIONS" -> ActionInfo
|
||||||
|
_ -> ActionInspect
|
||||||
target = case path of
|
target = case path of
|
||||||
[] -> TargetRoot
|
[] -> TargetRoot
|
||||||
[table] -> TargetIdent
|
[table] -> TargetIdent
|
||||||
@@ -168,10 +183,9 @@ 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]
|
shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke{isReadOnly=False}, ActionInvoke{isReadOnly=True}]
|
||||||
relevantPayload = if shouldParsePayload
|
relevantPayload | shouldParsePayload = rightToMaybe payload
|
||||||
then rightToMaybe payload
|
| otherwise = Nothing
|
||||||
else Nothing
|
|
||||||
path = pathInfo req
|
path = pathInfo req
|
||||||
method = requestMethod req
|
method = requestMethod req
|
||||||
hdrs = requestHeaders req
|
hdrs = requestHeaders req
|
||||||
@@ -200,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
|
||||||
@@ -254,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 ->
|
||||||
@@ -265,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
|
||||||
|
|||||||
+126
-85
@@ -1,15 +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 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
|
||||||
@@ -21,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
|
||||||
@@ -32,7 +35,7 @@ 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
|
||||||
@@ -42,7 +45,7 @@ import PostgREST.DbRequestBuilder( readRequest
|
|||||||
import PostgREST.Error ( simpleError, pgError
|
import PostgREST.Error ( simpleError, pgError
|
||||||
, apiRequestError
|
, apiRequestError
|
||||||
, singularityError, binaryFieldError
|
, singularityError, binaryFieldError
|
||||||
, connectionLostError
|
, connectionLostError, gucHeadersError
|
||||||
)
|
)
|
||||||
import PostgREST.RangeQuery (allRange, rangeOffset)
|
import PostgREST.RangeQuery (allRange, rangeOffset)
|
||||||
import PostgREST.Middleware
|
import PostgREST.Middleware
|
||||||
@@ -58,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
|
||||||
@@ -74,40 +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 (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 (isResponse503 response) worker
|
when (responseStatus response == status503) worker
|
||||||
respond response
|
respond response
|
||||||
|
|
||||||
isResponse503 :: Response -> Bool
|
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
|
||||||
isResponse503 resp = statusCode (responseStatus resp) == 503
|
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 :: DbStructure -> Target -> Action -> H.Mode
|
transactionMode :: Maybe ProcDescription -> Action -> HT.Mode
|
||||||
transactionMode structure target action =
|
transactionMode proc action =
|
||||||
case action of
|
case action of
|
||||||
ActionRead -> HT.Read
|
ActionRead -> HT.Read
|
||||||
ActionInfo -> HT.Read
|
ActionInfo -> HT.Read
|
||||||
ActionInspect -> HT.Read
|
ActionInspect -> HT.Read
|
||||||
ActionInvoke ->
|
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
|
||||||
_ -> 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,34 +271,40 @@ 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, TargetProc qi, Just (PayloadJSON 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
|
||||||
partsField = (,) <$> readSqlParts <*> rpcBinaryField in
|
parts = (,) <$> readSqlParts <*> rpcBinaryField in
|
||||||
case partsField of
|
case parts of
|
||||||
Left errorResponse -> return errorResponse
|
Left errorResponse -> return errorResponse
|
||||||
Right ((q, cq), bField) -> do
|
Right ((q, cq), bField) -> do
|
||||||
let p = V.head payload
|
let isObject = case pjType of
|
||||||
|
PJObject -> True
|
||||||
|
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 p returnsScalar q cq topLevelRange shouldCount
|
callProc qi specifiedPgArgs returnsScalar q cq shouldCount
|
||||||
singular paramsAsSingleObject
|
singular (iPreferSingleObjectParameter apiRequest)
|
||||||
(contentType == CTTextCSV)
|
(contentType == CTTextCSV)
|
||||||
(contentType == CTOctetStream) bField
|
(contentType == CTOctetStream) bField isObject
|
||||||
let (tableTotal, queryTotal, body) =
|
(pgVersion dbStructure)
|
||||||
fromMaybe (Just 0, 0, "[]") row
|
let (tableTotal, queryTotal, body, jsonHeaders) =
|
||||||
|
fromMaybe (Just 0, 0, "[]", "[]") row
|
||||||
(status, contentRange) = rangeHeader queryTotal tableTotal
|
(status, contentRange) = rangeHeader queryTotal tableTotal
|
||||||
if singular && queryTotal /= 1
|
decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]
|
||||||
then do
|
case decodedHeaders of
|
||||||
HT.condemn
|
Left _ -> return gucHeadersError
|
||||||
return $ singularityError (toInteger queryTotal)
|
Right hs ->
|
||||||
else return $ responseLBS status [toHeader contentType, contentRange] (toS body)
|
if singular && queryTotal /= 1
|
||||||
|
then do
|
||||||
|
HT.condemn
|
||||||
|
return $ singularityError (toInteger queryTotal)
|
||||||
|
else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body)
|
||||||
|
|
||||||
(ActionInspect, TargetRoot, Nothing) -> do
|
(ActionInspect, TargetRoot, Nothing) -> do
|
||||||
let host = configHost conf
|
let host = configHost conf
|
||||||
@@ -263,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 = encodeOpenAPI (M.elems allProcs) (toTableInfo ti) uri' sd (dbPrimaryKeys dbStructure)
|
toTableInfo :: [Table] -> [(Table, [Column], [Text])]
|
||||||
body <- encodeApi <$> H.query schema accessibleTables <*> H.query schema schemaDescription
|
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
|
||||||
@@ -294,15 +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)
|
|
||||||
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
|
||||||
@@ -313,9 +353,10 @@ responseContentTypeOrError accepts action = serves contentTypesForRequest accept
|
|||||||
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
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
|
||||||
@@ -356,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
|
||||||
|
|||||||
+59
-40
@@ -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,19 +16,19 @@ module PostgREST.Auth (
|
|||||||
containsRole
|
containsRole
|
||||||
, jwtClaims
|
, jwtClaims
|
||||||
, JWTAttempt(..)
|
, JWTAttempt(..)
|
||||||
, parseJWK
|
, parseSecret
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Protolude hiding ((&))
|
import Control.Lens.Operators
|
||||||
import Control.Lens
|
import Control.Lens (set)
|
||||||
import Data.Aeson (Value (..), decode, toJSON)
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.ByteString.Lazy as BL
|
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 Crypto.JOSE.Compact
|
import qualified Crypto.JOSE.Types as JOSE.Types
|
||||||
import Crypto.JOSE.JWK
|
|
||||||
import Crypto.JOSE.JWS
|
|
||||||
import Crypto.JOSE.Types
|
|
||||||
import Crypto.JWT
|
import Crypto.JWT
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
@@ -35,58 +36,76 @@ 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 (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 -> BL.ByteString -> 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 payload =
|
jwtClaims secret audience payload time jspath =
|
||||||
case secret of
|
case secret of
|
||||||
Nothing -> return JWTMissingSecret
|
Nothing -> return JWTMissingSecret
|
||||||
Just jwk -> do
|
Just s -> do
|
||||||
let validation = defaultJWTValidationSettings
|
let validation = set allowedSkew 1 $ defaultJWTValidationSettings (maybe (const True) (==) audience)
|
||||||
eJwt <- runExceptT $ do
|
eJwt <- runExceptT $ do
|
||||||
jwt <- decodeCompact payload
|
jwt <- decodeCompact payload
|
||||||
validateJWSJWT validation jwk jwt
|
verifyClaimsAt validation s time jwt
|
||||||
return jwt
|
|
||||||
return $ case eJwt of
|
return $ case eJwt of
|
||||||
Left e -> JWTInvalid e
|
Left e -> JWTInvalid e
|
||||||
Right jwt -> JWTClaims . claims2map . jwtClaimsSet $ 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
|
||||||
-}
|
-}
|
||||||
containsRole :: JWTAttempt -> Bool
|
containsRole :: JWTAttempt -> Bool
|
||||||
containsRole (JWTClaims claims) = M.member "role" claims
|
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
|
|
||||||
|
There are three ways to specify `jwt-secret`: text secret, JSON Web Key
|
||||||
|
(JWK), or JSON Web Key Set (JWKS). The first two are converted into a JWKSet
|
||||||
|
with one key and the last is converted as is.
|
||||||
-}
|
-}
|
||||||
claims2map :: ClaimsSet -> M.HashMap Text Value
|
parseSecret :: ByteString -> JWKSet
|
||||||
claims2map = val2map . toJSON
|
parseSecret str =
|
||||||
|
fromMaybe (maybe secret (\jwk' -> JWKSet [jwk']) maybeJWK)
|
||||||
|
maybeJWKSet
|
||||||
where
|
where
|
||||||
val2map (Object o) = o
|
maybeJWKSet = JSON.decode (toS str) :: Maybe JWKSet
|
||||||
val2map _ = M.empty
|
maybeJWK = JSON.decode (toS str) :: Maybe JWK
|
||||||
|
secret = JWKSet [jwkFromSecret str]
|
||||||
parseJWK :: ByteString -> JWK
|
|
||||||
parseJWK str =
|
|
||||||
fromMaybe (hs256jwk str) (decode (toS str) :: Maybe JWK)
|
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
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 Oct (Base64Octets key))
|
km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key))
|
||||||
|
|||||||
+132
-87
@@ -1,3 +1,4 @@
|
|||||||
|
{-# LANGUAGE LambdaCase, TemplateHaskell #-}
|
||||||
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
|
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
|
||||||
{-|
|
{-|
|
||||||
Module : PostgREST.Config
|
Module : PostgREST.Config
|
||||||
@@ -14,38 +15,49 @@ turned in configurable behaviour if needed.
|
|||||||
Other hardcoded options such as the minimum version number also belong here.
|
Other hardcoded options such as the minimum version number also belong here.
|
||||||
-}
|
-}
|
||||||
module PostgREST.Config ( prettyVersion
|
module PostgREST.Config ( prettyVersion
|
||||||
|
, docsVersion
|
||||||
, readOptions
|
, readOptions
|
||||||
, corsPolicy
|
, corsPolicy
|
||||||
, minimumPgVersion
|
|
||||||
, PgVersion (..)
|
|
||||||
, AppConfig (..)
|
, AppConfig (..)
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
|
||||||
import System.IO.Error (IOError)
|
|
||||||
import Control.Applicative
|
import Control.Applicative
|
||||||
import qualified Data.ByteString as B
|
import Control.Monad (fail)
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import Control.Lens (preview)
|
||||||
import qualified Data.CaseInsensitive as CI
|
import Crypto.JWT (StringOrURI,
|
||||||
import qualified Data.Configurator as C
|
stringOrUri)
|
||||||
import qualified Data.Configurator.Parser as C
|
import qualified Data.ByteString as B
|
||||||
import Data.Configurator.Types (Value(..))
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import Data.List (lookup)
|
import qualified Data.CaseInsensitive as CI
|
||||||
|
import qualified Data.Configurator as C
|
||||||
|
import qualified Data.Configurator.Parser as C
|
||||||
|
import Data.Configurator.Types as C
|
||||||
|
import Data.List (lookup)
|
||||||
import Data.Monoid
|
import Data.Monoid
|
||||||
import Data.Scientific (floatingOrInteger)
|
import Data.Scientific (floatingOrInteger)
|
||||||
import Data.Text (strip, intercalate, lines)
|
import Data.String (String)
|
||||||
import Data.Text.Encoding (encodeUtf8)
|
import Data.Text (dropAround,
|
||||||
import Data.Text.IO (hPutStrLn)
|
intercalate, lines,
|
||||||
import Data.Version (versionBranch)
|
strip, take, splitOn)
|
||||||
|
import Data.Text.Encoding (encodeUtf8)
|
||||||
|
import Data.Text.IO (hPutStrLn)
|
||||||
|
import Data.Version (versionBranch)
|
||||||
|
import Development.GitRev (gitHash)
|
||||||
import Network.Wai
|
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 System.IO (hPrint)
|
import PostgREST.Parsers (pRoleClaimKey)
|
||||||
|
import PostgREST.Types (ApiRequestError(..),
|
||||||
|
JSPath, JSPathExp(..))
|
||||||
|
import Protolude hiding (hPutStrLn, take,
|
||||||
|
intercalate, (<>))
|
||||||
|
import System.IO (hPrint)
|
||||||
|
import System.IO.Error (IOError)
|
||||||
import Text.Heredoc
|
import Text.Heredoc
|
||||||
import Text.PrettyPrint.ANSI.Leijen hiding ((<>), (<$>))
|
import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))
|
||||||
import qualified Text.PrettyPrint.ANSI.Leijen as L
|
import qualified Text.PrettyPrint.ANSI.Leijen as L
|
||||||
import Protolude hiding (intercalate, (<>), hPutStrLn)
|
|
||||||
|
|
||||||
-- | Config file settings for the server
|
-- | Config file settings for the server
|
||||||
data AppConfig = AppConfig {
|
data AppConfig = AppConfig {
|
||||||
@@ -58,16 +70,20 @@ data AppConfig = AppConfig {
|
|||||||
|
|
||||||
, configJwtSecret :: Maybe B.ByteString
|
, configJwtSecret :: Maybe B.ByteString
|
||||||
, configJwtSecretIsBase64 :: Bool
|
, configJwtSecretIsBase64 :: Bool
|
||||||
|
, configJwtAudience :: Maybe StringOrURI
|
||||||
|
|
||||||
, configPool :: Int
|
, configPool :: Int
|
||||||
, 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
|
||||||
@@ -90,7 +106,13 @@ corsPolicy req = case lookup "origin" headers of
|
|||||||
|
|
||||||
-- | User friendly version number
|
-- | User friendly version number
|
||||||
prettyVersion :: Text
|
prettyVersion :: Text
|
||||||
prettyVersion = intercalate "." $ map show $ versionBranch version
|
prettyVersion =
|
||||||
|
intercalate "." (map show $ versionBranch version)
|
||||||
|
<> " (" <> take 7 $(gitHash) <> ")"
|
||||||
|
|
||||||
|
-- | Version number used in docs
|
||||||
|
docsVersion :: Text
|
||||||
|
docsVersion = "v" <> dropAround (== '.') (dropAround (/= '.') prettyVersion)
|
||||||
|
|
||||||
-- | Function to read and parse options from the command line
|
-- | Function to read and parse options from the command line
|
||||||
readOptions :: IO AppConfig
|
readOptions :: IO AppConfig
|
||||||
@@ -102,20 +124,24 @@ readOptions = do
|
|||||||
(C.readConfig =<< C.load [C.Required cfgPath])
|
(C.readConfig =<< C.load [C.Required cfgPath])
|
||||||
configNotfoundHint
|
configNotfoundHint
|
||||||
|
|
||||||
let (mAppConf, errs) = flip C.runParserA conf $
|
let (mAppConf, errs) = flip C.runParserM conf $
|
||||||
AppConfig <$>
|
AppConfig
|
||||||
C.key "db-uri"
|
<$> C.key "db-uri"
|
||||||
<*> C.key "db-anon-role"
|
<*> C.key "db-anon-role"
|
||||||
<*> 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")
|
||||||
|
<*> parseJwtAudience "jwt-aud"
|
||||||
<*> (fromMaybe 10 . join . fmap coerceInt <$> C.key "db-pool")
|
<*> (fromMaybe 10 . join . fmap coerceInt <$> C.key "db-pool")
|
||||||
<*> (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
|
||||||
@@ -124,73 +150,92 @@ readOptions = do
|
|||||||
Just appConf ->
|
Just appConf ->
|
||||||
return appConf
|
return appConf
|
||||||
|
|
||||||
where
|
where
|
||||||
coerceInt :: (Read i, Integral i) => Value -> Maybe i
|
parseJwtAudience :: Name -> C.ConfigParserM (Maybe StringOrURI)
|
||||||
coerceInt (Number x) = rightToMaybe $ floatingOrInteger x
|
parseJwtAudience k =
|
||||||
coerceInt (String x) = readMaybe $ toS x
|
C.key k >>= \case
|
||||||
coerceInt _ = Nothing
|
Nothing -> pure Nothing -- no audience in config file
|
||||||
|
Just aud -> case preview stringOrUri (aud :: String) of
|
||||||
|
Nothing -> fail "Invalid Jwt audience. Check your configuration."
|
||||||
|
(Just "") -> pure Nothing
|
||||||
|
aud' -> pure aud'
|
||||||
|
|
||||||
coerceBool :: Value -> Maybe Bool
|
coerceText :: Value -> Text
|
||||||
coerceBool (Bool b) = Just b
|
coerceText (String s) = s
|
||||||
coerceBool (String x) = readMaybe $ toS x
|
coerceText v = show v
|
||||||
coerceBool _ = Nothing
|
|
||||||
|
|
||||||
opts = info (helper <*> pathParser) $
|
coerceInt :: (Read i, Integral i) => Value -> Maybe i
|
||||||
fullDesc
|
coerceInt (Number x) = rightToMaybe $ floatingOrInteger x
|
||||||
<> progDesc (
|
coerceInt (String x) = readMaybe $ toS x
|
||||||
"PostgREST "
|
coerceInt _ = Nothing
|
||||||
<> toS prettyVersion
|
|
||||||
<> " / create a REST API to an existing Postgres database"
|
|
||||||
)
|
|
||||||
<> footerDoc (Just $
|
|
||||||
text "Example Config File:"
|
|
||||||
L.<> nest 2 (hardline L.<> exampleCfg)
|
|
||||||
)
|
|
||||||
|
|
||||||
parserPrefs = prefs showHelpOnError
|
coerceBool :: Value -> Maybe Bool
|
||||||
|
coerceBool (Bool b) = Just b
|
||||||
|
coerceBool (String b) = readMaybe $ toS b
|
||||||
|
coerceBool _ = Nothing
|
||||||
|
|
||||||
configNotfoundHint :: IOError -> IO a
|
parseRoleClaimKey :: Value -> Either ApiRequestError JSPath
|
||||||
configNotfoundHint e = do
|
parseRoleClaimKey (String s) = pRoleClaimKey s
|
||||||
hPutStrLn stderr $
|
parseRoleClaimKey v = pRoleClaimKey $ show v
|
||||||
"Cannot open config file:\n\t" <> show e
|
|
||||||
exitFailure
|
|
||||||
|
|
||||||
exampleCfg :: Doc
|
splitExtraSearchPath :: Value -> [Text]
|
||||||
exampleCfg = vsep . map (text . toS) . lines $
|
splitExtraSearchPath (String s) = strip <$> splitOn "," s
|
||||||
[str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|
splitExtraSearchPath _ = []
|
||||||
|db-schema = "public"
|
|
||||||
|db-anon-role = "postgres"
|
opts = info (helper <*> pathParser) $
|
||||||
|db-pool = 10
|
fullDesc
|
||||||
|
|
<> progDesc (
|
||||||
|server-host = "*4"
|
"PostgREST "
|
||||||
|server-port = 3000
|
<> toS prettyVersion
|
||||||
|
|
<> " / create a REST API to an existing Postgres database"
|
||||||
|## base url for swagger output
|
)
|
||||||
|# server-proxy-uri = ""
|
<> footerDoc (Just $
|
||||||
|
|
text "Example Config File:"
|
||||||
|## choose a secret to enable JWT auth
|
L.<> nest 2 (hardline L.<> exampleCfg)
|
||||||
|## (use "@filename" to load from separate file)
|
)
|
||||||
|# jwt-secret = "foo"
|
|
||||||
|# secret-is-base64 = false
|
parserPrefs = prefs showHelpOnError
|
||||||
|
|
|
||||||
|## limit rows in response
|
configNotfoundHint :: IOError -> IO a
|
||||||
|# max-rows = 1000
|
configNotfoundHint e = do
|
||||||
|
|
hPutStrLn stderr $
|
||||||
|## stored proc to exec immediately after auth
|
"Cannot open config file:\n\t" <> show e
|
||||||
|# pre-request = "stored_proc_name"
|
exitFailure
|
||||||
|]
|
|
||||||
|
exampleCfg :: Doc
|
||||||
|
exampleCfg = vsep . map (text . toS) . lines $
|
||||||
|
[str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|
||||||
|
|db-schema = "public" # this schema gets added to the search_path of every request
|
||||||
|
|db-anon-role = "postgres"
|
||||||
|
|db-pool = 10
|
||||||
|
|
|
||||||
|
|server-host = "127.0.0.1"
|
||||||
|
|server-port = 3000
|
||||||
|
|
|
||||||
|
|## base url for swagger output
|
||||||
|
|# server-proxy-uri = ""
|
||||||
|
|
|
||||||
|
|## choose a secret, JSON Web Key (or set) to enable JWT auth
|
||||||
|
|## (use "@filename" to load from separate file)
|
||||||
|
|# jwt-secret = "foo"
|
||||||
|
|# secret-is-base64 = false
|
||||||
|
|# jwt-aud = "your_audience_claim"
|
||||||
|
|
|
||||||
|
|## limit rows in response
|
||||||
|
|# max-rows = 1000
|
||||||
|
|
|
||||||
|
|## stored proc to exec immediately after auth
|
||||||
|
|# 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
|
||||||
pathParser =
|
pathParser =
|
||||||
strArgument $
|
strArgument $
|
||||||
metavar "FILENAME" <>
|
metavar "FILENAME" <>
|
||||||
help "Path to configuration file"
|
help "Path to configuration file"
|
||||||
|
|
||||||
data PgVersion = PgVersion {
|
|
||||||
pgvNum :: Int32
|
|
||||||
, pgvName :: Text
|
|
||||||
}
|
|
||||||
|
|
||||||
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
|
|
||||||
minimumPgVersion :: PgVersion
|
|
||||||
minimumPgVersion = PgVersion 90300 "9.3"
|
|
||||||
|
|||||||
+191
-173
@@ -1,5 +1,8 @@
|
|||||||
{-# LANGUAGE FlexibleContexts #-}
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
{-# LANGUAGE DuplicateRecordFields#-}
|
||||||
|
{-# LANGUAGE LambdaCase #-}
|
||||||
|
{-# LANGUAGE MultiWayIf #-}
|
||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
module PostgREST.DbRequestBuilder (
|
module PostgREST.DbRequestBuilder (
|
||||||
readRequest
|
readRequest
|
||||||
, mutateRequest
|
, mutateRequest
|
||||||
@@ -13,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)
|
||||||
@@ -29,55 +33,71 @@ 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.QueryBuilder (getJoinFilters, sourceCTEName)
|
|
||||||
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
|
||||||
ActionCreate -> fakeSourceRelations ++ allRels
|
ActionCreate -> fakeSourceRelations ++ allRels
|
||||||
ActionUpdate -> fakeSourceRelations ++ allRels
|
ActionUpdate -> fakeSourceRelations ++ allRels
|
||||||
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,121 +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 Relation{relType=Parent} -> Node node <$> 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}
|
||||||
|
|
||||||
|
getJoinConditions :: Relation -> [JoinCondition]
|
||||||
|
getJoinConditions (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols typ lt lc1 lc2) =
|
||||||
|
if | typ == Child || typ == Parent ->
|
||||||
|
zipWith (toJoinCondition tN ftN) cols fCols
|
||||||
|
| typ == Many ->
|
||||||
|
let ltN = maybe "" tableName lt in
|
||||||
|
zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2)
|
||||||
|
| typ == Root -> witness
|
||||||
|
where
|
||||||
|
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
|
||||||
|
toJoinCondition tb ftb c fc =
|
||||||
|
JoinCondition (QualifiedIdentifier tSchema tb, Nothing, colName c)
|
||||||
|
(QualifiedIdentifier tSchema ftb, Nothing, colName fc)
|
||||||
|
|
||||||
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
|
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
|
||||||
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
||||||
@@ -222,9 +256,11 @@ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
|||||||
logicForest = mapM pRequestLogicTree logFrst
|
logicForest = mapM pRequestLogicTree logFrst
|
||||||
action = iAction apiRequest
|
action = iAction apiRequest
|
||||||
-- there can be no filters on the root table when we are doing insert/update/delete
|
-- there can be no filters on the root table when we are doing insert/update/delete
|
||||||
(flts, logFrst)
|
(flts, logFrst) =
|
||||||
| action == ActionRead || action == ActionInvoke = (iFilters apiRequest, iLogic apiRequest)
|
case action of
|
||||||
| otherwise = join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
|
ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)
|
||||||
|
ActionRead -> (iFilters apiRequest, iLogic apiRequest)
|
||||||
|
_ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
|
||||||
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
|
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
|
||||||
orders = mapM pRequestOrder $ iOrder apiRequest
|
orders = mapM pRequestOrder $ iOrder apiRequest
|
||||||
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
|
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
|
||||||
@@ -237,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
|
||||||
@@ -255,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
|
||||||
@@ -315,7 +333,7 @@ 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
|
||||||
|
|||||||
+301
-230
@@ -3,156 +3,170 @@
|
|||||||
{-# LANGUAGE QuasiQuotes #-}
|
{-# LANGUAGE QuasiQuotes #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
{-# LANGUAGE TypeSynonymInstances #-}
|
{-# LANGUAGE TypeSynonymInstances #-}
|
||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
module PostgREST.DbStructure (
|
module PostgREST.DbStructure (
|
||||||
getDbStructure
|
getDbStructure
|
||||||
, accessibleTables
|
, accessibleTables
|
||||||
|
, accessibleProcs
|
||||||
, schemaDescription
|
, schemaDescription
|
||||||
|
, getPgVersion
|
||||||
) where
|
) where
|
||||||
|
|
||||||
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)
|
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 -> H.Session DbStructure
|
getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure
|
||||||
getDbStructure schema = 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 () $ 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 accessibleProcs
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
accessibleProcs :: H.Query Schema (M.HashMap Text ProcDescription)
|
decodeProcs :: HD.Result (M.HashMap Text [ProcDescription])
|
||||||
accessibleProcs =
|
decodeProcs =
|
||||||
H.statement sql (HE.value HE.text)
|
-- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance
|
||||||
(M.fromList . map addName <$>
|
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addName) <$> HD.rowList tblRow
|
||||||
HD.rowsList (
|
where
|
||||||
ProcDescription <$> HD.value HD.text
|
tblRow = ProcDescription
|
||||||
<*> HD.nullableValue HD.text
|
<$> HD.column HD.text
|
||||||
<*> (parseArgs <$> HD.value HD.text)
|
<*> HD.nullableColumn HD.text
|
||||||
<*> (parseRetType <$>
|
<*> (parseArgs <$> HD.column HD.text)
|
||||||
HD.value HD.text <*>
|
<*> (parseRetType
|
||||||
HD.value HD.text <*>
|
<$> HD.column HD.text
|
||||||
HD.value HD.bool <*>
|
<*> HD.column HD.text
|
||||||
HD.value HD.char)
|
<*> HD.column HD.bool
|
||||||
<*> (parseVolatility <$>
|
<*> HD.column HD.char)
|
||||||
HD.value HD.char)
|
<*> (parseVolatility <$> HD.column HD.char)
|
||||||
)
|
|
||||||
) True
|
|
||||||
where
|
|
||||||
addName :: ProcDescription -> (Text, ProcDescription)
|
|
||||||
addName pd = (pdName pd, pd)
|
|
||||||
|
|
||||||
parseArgs :: Text -> [PgArg]
|
addName :: ProcDescription -> (Text, ProcDescription)
|
||||||
parseArgs = mapMaybe (parseArg . strip) . split (==',')
|
addName pd = (pdName pd, pd)
|
||||||
|
|
||||||
parseArg :: Text -> Maybe PgArg
|
parseArgs :: Text -> [PgArg]
|
||||||
parseArg a =
|
parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')
|
||||||
let (body, def) = breakOn " DEFAULT " a
|
|
||||||
(name, typ) = breakOn " " body in
|
|
||||||
if T.null typ
|
|
||||||
then Nothing
|
|
||||||
else Just $
|
|
||||||
PgArg (dropAround (== '"') name) (strip typ) (T.null def)
|
|
||||||
|
|
||||||
parseRetType :: Text -> Text -> Bool -> Char -> RetType
|
parseArg :: Text -> Maybe PgArg
|
||||||
parseRetType schema name isSetOf typ
|
parseArg a =
|
||||||
| isSetOf = SetOf pgType
|
let arg = lastDef "" $ splitOn "INOUT " a
|
||||||
| otherwise = Single pgType
|
(body, def) = breakOn " DEFAULT " arg
|
||||||
where
|
(name, typ) = breakOn " " body in
|
||||||
qi = QualifiedIdentifier schema name
|
if T.null typ
|
||||||
pgType = case typ of
|
then Nothing
|
||||||
'c' -> Composite qi
|
else Just $
|
||||||
'p' -> if name == "record" -- Only pg pseudo type that is a row type is 'record'
|
PgArg (dropAround (== '"') name) (strip typ) (T.null def)
|
||||||
then Composite qi
|
|
||||||
else Scalar qi
|
|
||||||
_ -> Scalar qi -- 'b'ase, 'd'omain, 'e'num, 'r'ange
|
|
||||||
|
|
||||||
parseVolatility :: Char -> ProcVolatility
|
parseRetType :: Text -> Text -> Bool -> Char -> RetType
|
||||||
parseVolatility 'i' = Immutable
|
parseRetType schema name isSetOf typ
|
||||||
parseVolatility 's' = Stable
|
| isSetOf = SetOf pgType
|
||||||
parseVolatility 'v' = Volatile
|
| otherwise = Single pgType
|
||||||
parseVolatility _ = Volatile -- should not happen, but be pessimistic
|
where
|
||||||
|
qi = QualifiedIdentifier schema name
|
||||||
|
pgType = case typ of
|
||||||
|
'c' -> Composite qi
|
||||||
|
'p' -> if name == "record" -- Only pg pseudo type that is a row type is 'record'
|
||||||
|
then Composite qi
|
||||||
|
else Scalar qi
|
||||||
|
_ -> Scalar qi -- 'b'ase, 'd'omain, 'e'num, 'r'ange
|
||||||
|
|
||||||
sql = [q|
|
parseVolatility :: Char -> ProcVolatility
|
||||||
|
parseVolatility v | v == 'i' = Immutable
|
||||||
|
| v == 's' = Stable
|
||||||
|
| otherwise = Volatile -- only 'v' can happen here
|
||||||
|
|
||||||
|
allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
|
||||||
|
allProcs = H.Statement (toS procsSqlQuery) (HE.param HE.text) decodeProcs True
|
||||||
|
|
||||||
|
accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
|
||||||
|
accessibleProcs = H.Statement (toS sql) (HE.param HE.text) decodeProcs True
|
||||||
|
where
|
||||||
|
sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"
|
||||||
|
|
||||||
|
procsSqlQuery :: SqlQuery
|
||||||
|
procsSqlQuery = [q|
|
||||||
SELECT p.proname as "proc_name",
|
SELECT p.proname as "proc_name",
|
||||||
d.description as "proc_description",
|
d.description as "proc_description",
|
||||||
pg_get_function_arguments(p.oid) as "args",
|
pg_get_function_arguments(p.oid) as "args",
|
||||||
@@ -167,11 +181,12 @@ accessibleProcs =
|
|||||||
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
||||||
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
|
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
|
||||||
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
|
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
|
||||||
WHERE pn.nspname = $1|]
|
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) (HD.singleRow $ 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
|
||||||
@@ -182,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
|
||||||
@@ -202,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)
|
||||||
@@ -211,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
|
||||||
@@ -231,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}
|
||||||
@@ -268,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
|
||||||
@@ -306,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 () [Column]
|
allColumns :: [Table] -> H.Statement Schema [Column]
|
||||||
allColumns tabs =
|
allColumns tabs =
|
||||||
H.statement sql HE.unit (decodeColumns tabs) True
|
H.Statement sql (HE.param HE.text) (decodeColumns tabs) True
|
||||||
where
|
where
|
||||||
sql = [q|
|
sql = [q|
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
@@ -331,9 +373,35 @@ allColumns tabs =
|
|||||||
array_to_string(enum_info.vals, ',') AS enum
|
array_to_string(enum_info.vals, ',') AS enum
|
||||||
FROM (
|
FROM (
|
||||||
/*
|
/*
|
||||||
-- CTE based on information_schema.columns to remove the owner filter
|
-- CTE based on pg_catalog to get only Primary and Foreign key columns outside api schema
|
||||||
*/
|
*/
|
||||||
WITH columns AS (
|
WITH key_columns AS (
|
||||||
|
SELECT
|
||||||
|
r.oid AS r_oid,
|
||||||
|
c.oid AS c_oid,
|
||||||
|
n.nspname,
|
||||||
|
c.relname,
|
||||||
|
r.conname,
|
||||||
|
r.contype,
|
||||||
|
unnest(r.conkey) AS conkey
|
||||||
|
FROM
|
||||||
|
pg_catalog.pg_constraint r,
|
||||||
|
pg_catalog.pg_class c,
|
||||||
|
pg_catalog.pg_namespace n
|
||||||
|
WHERE
|
||||||
|
r.contype IN ('f', 'p')
|
||||||
|
AND c.relkind IN ('r', 'v', 'f', 'm')
|
||||||
|
AND r.conrelid = c.oid
|
||||||
|
AND c.relnamespace = n.oid
|
||||||
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1)
|
||||||
|
),
|
||||||
|
/*
|
||||||
|
-- CTE based on information_schema.columns
|
||||||
|
-- changed:
|
||||||
|
-- remove the owner filter
|
||||||
|
-- limit columns to the ones in the api schema or PK/FK columns
|
||||||
|
*/
|
||||||
|
columns AS (
|
||||||
SELECT current_database()::information_schema.sql_identifier AS table_catalog,
|
SELECT current_database()::information_schema.sql_identifier AS table_catalog,
|
||||||
nc.nspname::information_schema.sql_identifier AS table_schema,
|
nc.nspname::information_schema.sql_identifier AS table_schema,
|
||||||
c.relname::information_schema.sql_identifier AS table_name,
|
c.relname::information_schema.sql_identifier AS table_name,
|
||||||
@@ -411,6 +479,7 @@ allColumns tabs =
|
|||||||
ELSE 'NO'::text
|
ELSE 'NO'::text
|
||||||
END::information_schema.yes_or_no AS is_updatable
|
END::information_schema.yes_or_no AS is_updatable
|
||||||
FROM pg_attribute a
|
FROM pg_attribute a
|
||||||
|
LEFT JOIN key_columns kc ON kc.conkey = a.attnum AND kc.c_oid = a.attrelid
|
||||||
LEFT JOIN pg_catalog.pg_description AS d ON d.objoid = a.attrelid and d.objsubid = a.attnum
|
LEFT JOIN pg_catalog.pg_description AS d ON d.objoid = a.attrelid and d.objsubid = a.attnum
|
||||||
LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
|
LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
|
||||||
JOIN (pg_class c
|
JOIN (pg_class c
|
||||||
@@ -421,7 +490,12 @@ allColumns tabs =
|
|||||||
JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid) ON t.typtype = 'd'::"char" AND t.typbasetype = bt.oid
|
JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid) ON t.typtype = 'd'::"char" AND t.typbasetype = bt.oid
|
||||||
LEFT JOIN (pg_collation co
|
LEFT JOIN (pg_collation co
|
||||||
JOIN pg_namespace nco ON co.collnamespace = nco.oid) ON a.attcollation = co.oid AND (nco.nspname <> 'pg_catalog'::name OR co.collname <> 'default'::name)
|
JOIN pg_namespace nco ON co.collnamespace = nco.oid) ON a.attcollation = co.oid AND (nco.nspname <> 'pg_catalog'::name OR co.collname <> 'default'::name)
|
||||||
WHERE NOT pg_is_other_temp_schema(nc.oid) AND a.attnum > 0 AND NOT a.attisdropped AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char"]))
|
WHERE
|
||||||
|
NOT pg_is_other_temp_schema(nc.oid)
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped
|
||||||
|
AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char", 'm'::"char"]))
|
||||||
|
AND (nc.nspname = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
|
||||||
/*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
|
/*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -464,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,
|
||||||
@@ -507,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|
|
||||||
/*
|
/*
|
||||||
@@ -617,84 +691,81 @@ 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
|
||||||
col2 = findCol s2 t2 c2
|
col2 = findCol s2 t2 c2
|
||||||
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.statement () $ H.Statement sql HE.unit versionRow False
|
||||||
|
where
|
||||||
|
sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')"
|
||||||
|
versionRow = HD.singleRow $ PgVersion <$> HD.column HD.int4 <*> HD.column HD.text
|
||||||
|
|||||||
+29
-9
@@ -10,6 +10,7 @@ module PostgREST.Error (
|
|||||||
, binaryFieldError
|
, binaryFieldError
|
||||||
, connectionLostError
|
, connectionLostError
|
||||||
, encodeError
|
, encodeError
|
||||||
|
, gucHeadersError
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
@@ -22,6 +23,7 @@ import Network.HTTP.Types.Header
|
|||||||
import qualified Network.HTTP.Types.Status as HT
|
import qualified Network.HTTP.Types.Status as HT
|
||||||
import Network.Wai (Response, responseLBS)
|
import Network.Wai (Response, responseLBS)
|
||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
apiRequestError :: ApiRequestError -> Response
|
apiRequestError :: ApiRequestError -> Response
|
||||||
apiRequestError err =
|
apiRequestError err =
|
||||||
@@ -37,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 =
|
||||||
@@ -78,6 +81,11 @@ binaryFieldError =
|
|||||||
simpleError HT.status406 [] (toS (toMime CTOctetStream) <>
|
simpleError HT.status406 [] (toS (toMime CTOctetStream) <>
|
||||||
" requested but a single column was not selected")
|
" requested but a single column was not selected")
|
||||||
|
|
||||||
|
gucHeadersError :: Response
|
||||||
|
gucHeadersError =
|
||||||
|
simpleError HT.status500 []
|
||||||
|
"response.headers guc must be a JSON array composed of objects with a single key and a string value"
|
||||||
|
|
||||||
connectionLostError :: Response
|
connectionLostError :: Response
|
||||||
connectionLostError =
|
connectionLostError =
|
||||||
simpleError HT.status503 [] "Database connection lost, retrying the connection."
|
simpleError HT.status503 [] "Database connection lost, retrying the connection."
|
||||||
@@ -100,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 [
|
||||||
@@ -108,12 +118,21 @@ 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.ResultError (H.ServerError c m d h)) = JSON.object [
|
toJSON (H.QueryError _ _ e) = JSON.toJSON e
|
||||||
"code" .= (toS c::Text),
|
|
||||||
"message" .= (toS m::Text),
|
instance JSON.ToJSON H.CommandError where
|
||||||
"details" .= (fmap toS d::Maybe Text),
|
toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of
|
||||||
"hint" .= (fmap toS h::Maybe Text)]
|
'P':'T':_ ->
|
||||||
|
JSON.object [
|
||||||
|
"details" .= (fmap toS d::Maybe Text),
|
||||||
|
"hint" .= (fmap toS h::Maybe Text)]
|
||||||
|
_ ->
|
||||||
|
JSON.object [
|
||||||
|
"code" .= (toS c::Text),
|
||||||
|
"message" .= (toS m::Text),
|
||||||
|
"details" .= (fmap toS d::Maybe Text),
|
||||||
|
"hint" .= (fmap toS h::Maybe Text)]
|
||||||
toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [
|
toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [
|
||||||
"message" .= (m::Text)]
|
"message" .= (m::Text)]
|
||||||
toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [
|
toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [
|
||||||
@@ -138,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 _ _ _))) =
|
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
|
||||||
@@ -166,6 +185,7 @@ httpStatus authed (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =
|
|||||||
"42883" -> HT.status404 -- undefined function
|
"42883" -> HT.status404 -- undefined function
|
||||||
"42P01" -> HT.status404 -- undefined table
|
"42P01" -> HT.status404 -- undefined table
|
||||||
"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)
|
||||||
_ -> 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
@@ -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
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import Protolude hiding ((&), Proxy, get, intercalate, dr
|
|||||||
import Data.Swagger
|
import Data.Swagger
|
||||||
|
|
||||||
import PostgREST.ApiRequest (ContentType(..))
|
import PostgREST.ApiRequest (ContentType(..))
|
||||||
import PostgREST.Config (prettyVersion)
|
import PostgREST.Config (prettyVersion, docsVersion)
|
||||||
import PostgREST.Types (Table(..), Column(..), PgArg(..), ForeignKey(..),
|
import PostgREST.Types (Table(..), Column(..), PgArg(..), ForeignKey(..),
|
||||||
PrimaryKey(..), Proxy(..), ProcDescription(..), toMime)
|
PrimaryKey(..), Proxy(..), ProcDescription(..), toMime)
|
||||||
|
|
||||||
@@ -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/latest/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)
|
||||||
|
|||||||
+99
-80
@@ -1,27 +1,29 @@
|
|||||||
module PostgREST.Parsers where
|
module PostgREST.Parsers where
|
||||||
|
|
||||||
import Protolude hiding (try, intercalate, replace)
|
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 (pOperation pVText pVTextL) ("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
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree
|
|||||||
path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k
|
path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k
|
||||||
embedPath = fst <$> path
|
embedPath = fst <$> path
|
||||||
op = snd <$> path
|
op = snd <$> path
|
||||||
-- Concat op and v to make pLogicTree argument regular, in the form of "op(.,.)"
|
-- 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)
|
||||||
|
|
||||||
ws :: Parser Text
|
ws :: Parser Text
|
||||||
@@ -53,58 +55,53 @@ 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)
|
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 $> '-'
|
||||||
|
|
||||||
|
pJsonPath :: Parser JsonPath
|
||||||
|
pJsonPath = many pJsonOperation
|
||||||
|
where
|
||||||
|
pJsonOperation :: Parser JsonOperation
|
||||||
|
pJsonOperation = pJsonArrow <*> pJsonOperand
|
||||||
|
|
||||||
pJsonPathStep :: Parser Text
|
pJsonArrow =
|
||||||
pJsonPathStep = toS <$> try (string "->" *> pFieldName)
|
try (string "->>" $> J2Arrow) <|>
|
||||||
|
try (string "->" $> JArrow)
|
||||||
|
|
||||||
pJsonPath :: Parser [Text]
|
pJsonOperand =
|
||||||
pJsonPath = (<>) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) )
|
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 ':')
|
||||||
@@ -129,71 +126,76 @@ pFieldSelect = lexeme $
|
|||||||
)
|
)
|
||||||
<|> do
|
<|> do
|
||||||
s <- pStar
|
s <- pStar
|
||||||
return ((s, Nothing), Nothing, Nothing, Nothing)
|
return ((s, []), Nothing, Nothing, Nothing)
|
||||||
|
|
||||||
pOperation :: Parser Operand -> Parser Operand -> Parser Operation
|
pOpExpr :: Parser SingleVal -> Parser OpExpr
|
||||||
pOperation parserVText parserVTextL = try ( string "not" *> pDelimiter *> (Operation True <$> pExpr)) <|> Operation False <$> pExpr
|
pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
|
||||||
where
|
where
|
||||||
pExpr :: Parser (Operator, Operand)
|
pOperation :: Parser Operation
|
||||||
pExpr =
|
pOperation =
|
||||||
((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys notInOps)) <*> parserVText)
|
Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal
|
||||||
<|> ((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys inOps)) <*> parserVTextL)
|
<|> In <$> (try (string "in" *> pDelimiter) *> pListVal)
|
||||||
|
<|> pFts
|
||||||
<?> "operator (eq, gt, ...)"
|
<?> "operator (eq, gt, ...)"
|
||||||
inOps = M.filterWithKey (const . flip elem ["in", "notin"]) operators
|
|
||||||
notInOps = M.difference operators inOps
|
|
||||||
|
|
||||||
pVText :: Parser Operand
|
pFts = do
|
||||||
pVText = VText . toS <$> many anyChar
|
op <- foldl1 (<|>) (try . string . toS <$> ftsOps)
|
||||||
|
lang <- optionMaybe $ try (between (char '(') (char ')') (many (letter <|> digit <|> oneOf "_")))
|
||||||
|
pDelimiter >> Fts (toS op) (toS <$> lang) <$> pSVal
|
||||||
|
|
||||||
pVTextL :: Parser Operand
|
ops = M.filterWithKey (const . flip notElem ("in":ftsOps)) operators
|
||||||
pVTextL = VTextL <$> try (lexeme (char '(') *> pVTextLElement `sepBy1` char ',' <* lexeme (char ')'))
|
ftsOps = M.keys ftsOperators
|
||||||
<|> VTextL <$> lexeme pVTextLElement `sepBy1` char ','
|
|
||||||
|
|
||||||
pVTextLElement :: Parser Text
|
pSingleVal :: Parser SingleVal
|
||||||
pVTextLElement = try pQuotedValue <|> (toS <$> many (noneOf ",)"))
|
pSingleVal = toS <$> many anyChar
|
||||||
|
|
||||||
|
pListVal :: Parser ListVal
|
||||||
|
pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
|
||||||
|
|
||||||
|
pListElement :: Parser Text
|
||||||
|
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 <*> pOperation pLogicVText pLogicVTextL
|
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)"
|
||||||
|
|
||||||
pLogicVText :: Parser Operand
|
pLogicSingleVal :: Parser SingleVal
|
||||||
pLogicVText = VText <$> (try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",)")))
|
pLogicSingleVal = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> try pPgArray <|> (toS <$> many (noneOf ",)"))
|
||||||
where
|
where
|
||||||
pPgArray :: Parser Text
|
pPgArray :: Parser Text
|
||||||
pPgArray = do
|
pPgArray = do
|
||||||
@@ -202,9 +204,6 @@ pLogicVText = VText <$> (try pQuotedValue <|> try pPgArray <|> (toS <$> many (no
|
|||||||
c <- string "}"
|
c <- string "}"
|
||||||
toS <$> pure (a ++ b ++ c)
|
toS <$> pure (a ++ b ++ c)
|
||||||
|
|
||||||
pLogicVTextL :: Parser Operand
|
|
||||||
pLogicVTextL = VTextL <$> (lexeme (char '(') *> pVTextLElement `sepBy1` char ',' <* lexeme (char ')'))
|
|
||||||
|
|
||||||
pLogicPath :: Parser (EmbedPath, Text)
|
pLogicPath :: Parser (EmbedPath, Text)
|
||||||
pLogicPath = do
|
pLogicPath = do
|
||||||
path <- pFieldName `sepBy1` pDelimiter
|
path <- pFieldName `sepBy1` pDelimiter
|
||||||
@@ -221,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]"
|
||||||
|
|||||||
+213
-204
@@ -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.
|
||||||
@@ -14,32 +17,30 @@ module PostgREST.QueryBuilder (
|
|||||||
callProc
|
callProc
|
||||||
, createReadStatement
|
, createReadStatement
|
||||||
, createWriteStatement
|
, createWriteStatement
|
||||||
, getJoinFilters
|
|
||||||
, pgFmtIdent
|
, pgFmtIdent
|
||||||
, pgFmtLit
|
, pgFmtLit
|
||||||
, requestToQuery
|
, requestToQuery
|
||||||
, requestToCountQuery
|
, requestToCountQuery
|
||||||
, sourceCTEName
|
|
||||||
, 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.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange)
|
import PostgREST.RangeQuery (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
|
||||||
@@ -47,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
|
||||||
@@ -57,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[]"
|
||||||
@@ -75,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
|
||||||
@@ -106,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
|
||||||
@@ -130,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,38 +136,55 @@ createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys
|
|||||||
| wantSingle = asJsonSingleF
|
| wantSingle = asJsonSingleF
|
||||||
| otherwise = asJsonF
|
| otherwise = asJsonF
|
||||||
|
|
||||||
type ProcResults = (Maybe Int64, Int64, ByteString)
|
type ProcResults = (Maybe Int64, Int64, ByteString, ByteString)
|
||||||
callProc :: QualifiedIdentifier -> JSON.Object -> Bool -> SqlQuery -> SqlQuery -> NonnegRange ->
|
callProc :: QualifiedIdentifier -> [PgArg] -> Bool -> SqlQuery -> SqlQuery -> Bool ->
|
||||||
Bool -> Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> H.Query () (Maybe ProcResults)
|
Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> Bool -> PgVersion ->
|
||||||
callProc qi params returnsScalar selectQuery countQuery _ countTotal isSingle paramsAsJson asCsv asBinary binaryField =
|
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 ({_callSql})
|
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,
|
||||||
{scalarBodyF} as body
|
{scalarBodyF} AS body,
|
||||||
|
{responseHeaders} AS response_headers
|
||||||
FROM ({selectQuery}) _postgrest_t;|]
|
FROM ({selectQuery}) _postgrest_t;|]
|
||||||
else [qc|
|
else [qc|
|
||||||
WITH {sourceCTEName} AS ({_callSql})
|
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,
|
||||||
{bodyF} as body
|
{bodyF} AS body,
|
||||||
|
{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
|
|
||||||
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 =
|
||||||
_callSql = [qc|select * from {fromQi qi}({_args}) |] :: Text
|
if pgVer >= pgVersion96
|
||||||
decodeProc = HD.maybeRow procRow
|
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
|
||||||
procRow = (,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8
|
else "'[]'" :: Text
|
||||||
<*> HD.value HD.bytea
|
decodeProc = HD.rowMaybe procRow
|
||||||
|
procRow = (,,,) <$> HD.nullableColumn HD.int8 <*> HD.column HD.int8
|
||||||
|
<*> 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"
|
||||||
@@ -197,120 +208,130 @@ 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 _ Operation{expr=(_, VForeignKey _ _)})) = 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 array_to_json(array_agg(row_to_json("<>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 r@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
|
||||||
node_name = fromMaybe name alias
|
aliasOrName = fromMaybe name alias
|
||||||
local_table_name = table <> "_" <> node_name
|
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
|
||||||
replaceTableName localTableName (Filter a (Operation b (c, VForeignKey (QualifiedIdentifier "" _) d))) = Filter a (Operation b (c, VForeignKey (QualifiedIdentifier "" localTableName) d))
|
sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName
|
||||||
replaceTableName _ x = x
|
joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE "
|
||||||
sel = "row_to_json(" <> pgFmtIdent local_table_name <> ".*) AS " <> pgFmtIdent node_name
|
|
||||||
joi = " LEFT OUTER JOIN ( " <> subquery <> " ) AS " <> pgFmtIdent local_table_name <>
|
|
||||||
" ON " <> intercalate " AND " ( map (pgFmtFilter qi . replaceTableName local_table_name) (getJoinFilters r) )
|
|
||||||
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 array_to_json(array_agg(row_to_json("<>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))
|
||||||
--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,
|
|
||||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest,
|
|
||||||
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
|
|
||||||
]
|
|
||||||
|
|
||||||
sourceCTEName :: SqlFragment
|
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
|
||||||
sourceCTEName = "pg_source"
|
-- otherwise the query will err with a `could not determine data type of parameter $1`.
|
||||||
|
-- This happens because `unknown` relies on the context to determine the value type.
|
||||||
|
-- The error also happens on raw libpq used with C.
|
||||||
|
ignoredBody :: SqlFragment
|
||||||
|
ignoredBody = "ignored_body AS (SELECT $1::text) "
|
||||||
|
|
||||||
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
|
||||||
@@ -338,7 +359,7 @@ asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
|
|||||||
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
|
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
|
||||||
|
|
||||||
asJsonF :: SqlFragment
|
asJsonF :: SqlFragment
|
||||||
asJsonF = "coalesce(array_to_json(array_agg(row_to_json(_postgrest_t))), '[]')::character varying"
|
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
|
||||||
|
|
||||||
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
||||||
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
|
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
|
||||||
@@ -347,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
|
||||||
@@ -372,35 +381,12 @@ fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
|||||||
n = qiName t
|
n = qiName t
|
||||||
s = qiSchema t
|
s = qiSchema t
|
||||||
|
|
||||||
getJoinFilters :: Relation -> [Filter]
|
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
|
||||||
getJoinFilters (Relation t cols ft fcs typ lt lc1 lc2) =
|
unicodeStatement = H.Statement . T.encodeUtf8
|
||||||
case typ of
|
|
||||||
Child -> zipWith (toFilter tN ftN) cols fcs
|
|
||||||
Parent -> zipWith (toFilter tN ftN) cols fcs
|
|
||||||
Many -> zipWith (toFilter tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toFilter ftN ltN) fcs (fromMaybe [] lc2)
|
|
||||||
Root -> undefined --error "undefined getJoinFilters"
|
|
||||||
where
|
|
||||||
s = if typ == Parent then "" else tableSchema t
|
|
||||||
tN = tableName t
|
|
||||||
ftN = tableName ft
|
|
||||||
ltN = fromMaybe "" (tableName <$> lt)
|
|
||||||
toFilter :: Text -> Text -> Column -> Column -> Filter
|
|
||||||
toFilter tb ftb c fc = Filter (colName c, Nothing) (Operation False ("=", VForeignKey (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}})))
|
|
||||||
|
|
||||||
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b
|
|
||||||
unicodeStatement = H.statement . T.encodeUtf8
|
|
||||||
|
|
||||||
emptyOnFalse :: Text -> Bool -> Text
|
emptyOnFalse :: Text -> Bool -> Text
|
||||||
emptyOnFalse val cond = if cond then "" else val
|
emptyOnFalse val cond = if cond then "" else val
|
||||||
|
|
||||||
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
|
||||||
@@ -409,64 +395,87 @@ 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 (Operation hasNot_ ex)) = notOp <> " " <> case ex of
|
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
|
||||||
(op, VText val) -> pgFmtFieldOp op <> " " <> case op of
|
Op op val -> pgFmtFieldOp op <> " " <> case op of
|
||||||
"like" -> unknownLiteral (T.map star val)
|
"like" -> unknownLiteral (T.map star val)
|
||||||
"ilike" -> unknownLiteral (T.map star val)
|
"ilike" -> unknownLiteral (T.map star val)
|
||||||
-- TODO: The '@@' was deprecated, remove in v0.5.0.0
|
"is" -> whiteList val
|
||||||
"@@" -> "to_tsquery(" <> unknownLiteral val <> ") "
|
_ -> unknownLiteral val
|
||||||
"fts" -> "to_tsquery(" <> unknownLiteral val <> ") "
|
|
||||||
"is" -> whiteList val
|
In vals -> pgFmtField table fld <> " " <>
|
||||||
"isnot" -> whiteList val
|
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
|
||||||
_ -> unknownLiteral val
|
case (&&) (length vals == 1) . T.null <$> headMay vals of
|
||||||
(op, VTextL vals) -> pgFmtIn op vals -- in and notin
|
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
|
||||||
(op, VForeignKey fQi (ForeignKey Column{colTable=Table{tableName=fTableName}, colName=fColName})) ->
|
Just True -> emptyValForIn
|
||||||
pgFmtField fQi fld <> " " <> sqlOperator op <> " " <> pgFmtColumn (removeSourceCTESchema (qiSchema fQi) fTableName) fColName
|
Nothing -> emptyValForIn
|
||||||
|
|
||||||
|
Fts op lang val ->
|
||||||
|
pgFmtFieldOp op
|
||||||
|
<> "("
|
||||||
|
<> maybe "" ((<> ", ") . pgFmtLit) lang
|
||||||
|
<> unknownLiteral val
|
||||||
|
<> ") "
|
||||||
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
|
||||||
notOp = if hasNot_ then "NOT" else ""
|
notOp = if hasNot then "NOT" else ""
|
||||||
star c = if c == '*' then '%' else c
|
star c = if c == '*' then '%' else c
|
||||||
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
||||||
whiteList :: Text -> SqlFragment
|
whiteList :: Text -> SqlFragment
|
||||||
whiteList v = fromMaybe
|
whiteList v = fromMaybe
|
||||||
(toS (pgFmtLit v) <> "::unknown ")
|
(toS (pgFmtLit v) <> "::unknown ")
|
||||||
(find ((==) . toLower $ v) ["null","true","false"])
|
(find ((==) . toLower $ v) ["null","true","false"])
|
||||||
pgFmtIn :: Operator -> [Text] -> SqlFragment
|
|
||||||
pgFmtIn op vals =
|
pgFmtJoinCondition :: JoinCondition -> SqlFragment
|
||||||
-- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
|
pgFmtJoinCondition (JoinCondition (qi, al1, col1) (QualifiedIdentifier schema fTable, al2, col2)) =
|
||||||
let emptyValForIn o = (if "not" `isInfixOf` o then "NOT " else "") -- handle case of "notin" operator
|
pgFmtColumn (fromMaybe qi $ aliasToQi al1) col1 <> " = " <>
|
||||||
<> pgFmtField table fld <> " = any('{}') " in
|
pgFmtColumn (fromMaybe (removeSourceCTESchema schema fTable) $ aliasToQi al2) col2
|
||||||
case T.null <$> headMay vals of
|
where
|
||||||
Just isNull -> if isNull && length vals == 1
|
aliasToQi al = QualifiedIdentifier "" <$> al
|
||||||
then emptyValForIn op
|
|
||||||
else pgFmtFieldOp op <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
|
|
||||||
Nothing -> emptyValForIn op
|
|
||||||
|
|
||||||
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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+148
-40
@@ -2,11 +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.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)
|
||||||
|
|
||||||
@@ -22,28 +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
|
||||||
} 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
|
||||||
@@ -53,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
|
||||||
@@ -84,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
|
||||||
@@ -93,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
|
||||||
@@ -110,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)
|
||||||
@@ -119,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
|
||||||
@@ -126,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
|
||||||
@@ -148,7 +187,7 @@ data Proxy = Proxy {
|
|||||||
|
|
||||||
type Operator = Text
|
type Operator = Text
|
||||||
operators :: M.HashMap Operator SqlFragment
|
operators :: M.HashMap Operator SqlFragment
|
||||||
operators = M.fromList [
|
operators = M.union (M.fromList [
|
||||||
("eq", "="),
|
("eq", "="),
|
||||||
("gte", ">="),
|
("gte", ">="),
|
||||||
("gt", ">"),
|
("gt", ">"),
|
||||||
@@ -158,10 +197,7 @@ operators = M.fromList [
|
|||||||
("like", "LIKE"),
|
("like", "LIKE"),
|
||||||
("ilike", "ILIKE"),
|
("ilike", "ILIKE"),
|
||||||
("in", "IN"),
|
("in", "IN"),
|
||||||
("notin", "NOT IN"),
|
|
||||||
("isnot", "IS NOT"),
|
|
||||||
("is", "IS"),
|
("is", "IS"),
|
||||||
("fts", "@@"),
|
|
||||||
("cs", "@>"),
|
("cs", "@>"),
|
||||||
("cd", "<@"),
|
("cd", "<@"),
|
||||||
("ov", "&&"),
|
("ov", "&&"),
|
||||||
@@ -169,13 +205,25 @@ operators = 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 :: M.HashMap Operator SqlFragment
|
||||||
("@>", "@>"),
|
ftsOperators = M.fromList [
|
||||||
("<@", "<@")]
|
("fts", "@@ to_tsquery"),
|
||||||
data Operation = Operation{ hasNot::Bool, expr::(Operator, Operand) } deriving (Eq, Show)
|
("plfts", "@@ plainto_tsquery"),
|
||||||
data Operand = VText Text | VTextL [Text] | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
|
("phfts", "@@ phraseto_tsquery")
|
||||||
|
]
|
||||||
|
|
||||||
|
data OpExpr = OpExpr Bool Operation deriving (Eq, Show)
|
||||||
|
data Operation = Op Operator SingleVal |
|
||||||
|
In ListVal |
|
||||||
|
Fts Operator (Maybe Language) SingleVal deriving (Eq, Show)
|
||||||
|
type Language = Text
|
||||||
|
|
||||||
|
-- | Represents a single value in a filter, e.g. id=eq.singleval
|
||||||
|
type SingleVal = Text
|
||||||
|
-- | Represents a list value in a filter, e.g. id=in.(val1,val2,val3)
|
||||||
|
type ListVal = [Text]
|
||||||
|
|
||||||
data LogicOperator = And | Or deriving Eq
|
data LogicOperator = And | Or deriving Eq
|
||||||
instance Show LogicOperator where
|
instance Show LogicOperator where
|
||||||
@@ -193,12 +241,39 @@ 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
|
||||||
|
|
||||||
|
-- Rpc query param, only used for GET rpcs
|
||||||
|
type RpcQParam = (Text, Text)
|
||||||
|
|
||||||
|
{-|
|
||||||
|
Custom guc header, it's obtained by parsing the json in a:
|
||||||
|
`SET LOCAL "response.headers" = '[{"Set-Cookie": ".."}]'
|
||||||
|
-}
|
||||||
|
newtype GucHeader = GucHeader (Text, Text)
|
||||||
|
|
||||||
|
instance JSON.FromJSON GucHeader where
|
||||||
|
parseJSON (JSON.Object o) = case headMay (M.toList o) of
|
||||||
|
Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (k, s)
|
||||||
|
| otherwise -> mzero
|
||||||
|
_ -> mzero
|
||||||
|
parseJSON _ = mzero
|
||||||
|
|
||||||
|
toHeaders :: [GucHeader] -> [Header]
|
||||||
|
toHeaders = map $ \(GucHeader (k, v)) -> (CI.mk $ toS k, toS v)
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
This type will hold information about which particular 'Relation' between two tables to choose when there are multiple ones.
|
This type will hold information about which particular 'Relation' between two tables to choose when there are multiple ones.
|
||||||
Specifically, it will contain the name of the foreign key or the join table in many to many relations.
|
Specifically, it will contain the name of the foreign key or the join table in many to many relations.
|
||||||
@@ -207,14 +282,18 @@ type RelationDetail = Text
|
|||||||
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe RelationDetail)
|
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, operation::Operation } 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
|
||||||
|
|
||||||
@@ -237,3 +316,32 @@ toMime CTSingularJSON = "application/vnd.pgrst.object+json"
|
|||||||
toMime CTOctetStream = "application/octet-stream"
|
toMime CTOctetStream = "application/octet-stream"
|
||||||
toMime CTAny = "*/*"
|
toMime CTAny = "*/*"
|
||||||
toMime (CTOther ct) = ct
|
toMime (CTOther ct) = ct
|
||||||
|
|
||||||
|
data PgVersion = PgVersion {
|
||||||
|
pgvNum :: Int32
|
||||||
|
, pgvName :: Text
|
||||||
|
} 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 = "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)
|
||||||
|
|||||||
+8
-4
@@ -1,13 +1,17 @@
|
|||||||
resolver: lts-9.0
|
resolver: lts-9.6
|
||||||
extra-deps:
|
extra-deps:
|
||||||
- configurator-ng-0.0.0.1
|
- configurator-ng-0.0.0.1
|
||||||
- critbit-0.2.0.0
|
- critbit-0.2.0.0
|
||||||
- hasql-pool-0.4.1
|
|
||||||
- hjsonpointer-1.1.1
|
- hjsonpointer-1.1.1
|
||||||
- hjsonschema-1.5.0.1
|
- hjsonschema-1.5.0.1
|
||||||
- jose-0.5.0.3
|
|
||||||
- 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:
|
||||||
|
|||||||
@@ -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": []} ]},
|
||||||
@@ -73,15 +73,15 @@ spec =
|
|||||||
it "can handle fts" $ do
|
it "can handle fts" $ do
|
||||||
get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith`
|
get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith`
|
||||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
-- TODO: remove in 0.5.0 as deprecated
|
get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith`
|
||||||
get "/entities?or=(text_search_vector.@@.bar,text_search_vector.@@.baz)&select=id" `shouldRespondWith`
|
[json|[
|
||||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
{"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
|
||||||
it "can handle cs and cd" $ do
|
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||||
|
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
|
||||||
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can handle cs and cd" $
|
||||||
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
|
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`
|
||||||
@@ -114,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] }
|
||||||
@@ -176,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},
|
||||||
@@ -202,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|{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Feature.AudienceJwtSecretSpec where
|
||||||
|
|
||||||
|
-- {{{ Imports
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Network.HTTP.Types
|
||||||
|
|
||||||
|
import SpecHelper
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec = describe "test handling of aud claims in JWT" $ do
|
||||||
|
|
||||||
|
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||||
|
it "succeeds with jwt token containing with an audience claim" $ do
|
||||||
|
{- This is the decoded contents of authHeaderJWT
|
||||||
|
|
||||||
|
{
|
||||||
|
"exp": 9999999999,
|
||||||
|
"role": "postgrest_test_author",
|
||||||
|
"id": "jdoe",
|
||||||
|
"aud": "youraudience"
|
||||||
|
}
|
||||||
|
|
||||||
|
-}
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo"
|
||||||
|
request methodGet "/authors_only" [auth] ""
|
||||||
|
`shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "succeeds with jwt token that does not contain an audience claim" $ do
|
||||||
|
{- This is the decoded contents of authHeaderJWT
|
||||||
|
|
||||||
|
{
|
||||||
|
"exp": 9999999999,
|
||||||
|
"role": "postgrest_test_author",
|
||||||
|
"id": "jdoe"
|
||||||
|
}
|
||||||
|
-}
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||||
|
request methodGet "/authors_only" [auth] ""
|
||||||
|
`shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "requests without JWT token should work" $
|
||||||
|
get "/has_count_column" `shouldRespondWith` 200
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
module Feature.AuthSpec where
|
module Feature.AuthSpec where
|
||||||
|
|
||||||
-- {{{ Imports
|
|
||||||
import Text.Heredoc
|
import Text.Heredoc
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
@@ -11,7 +10,6 @@ import SpecHelper
|
|||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Protolude hiding (get)
|
||||||
-- }}}
|
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith Application
|
||||||
spec = describe "authorization" $ do
|
spec = describe "authorization" $ do
|
||||||
@@ -39,6 +37,17 @@ spec = describe "authorization" $ do
|
|||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "denies execution on functions that anonymous does not own" $
|
||||||
|
post "/rpc/privileged_hello" [json|{"name": "anonymous"}|] `shouldRespondWith` 401
|
||||||
|
|
||||||
|
it "allows execution on a function that postgrest_test_author owns" $
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
|
||||||
|
request methodPost "/rpc/privileged_hello" [auth] [json|{"name": "jdoe"}|]
|
||||||
|
`shouldRespondWith` [json|"Privileged hello to jdoe"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
it "returns jwt functions as jwt tokens" $
|
it "returns jwt functions as jwt tokens" $
|
||||||
request methodPost "/rpc/login" [single]
|
request methodPost "/rpc/login" [single]
|
||||||
[json| { "id": "jdoe", "pass": "1234" } |]
|
[json| { "id": "jdoe", "pass": "1234" } |]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" <:> "*/*"]
|
||||||
|
|||||||
@@ -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] }
|
||||||
+21
-17
@@ -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,7 +412,7 @@ 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` ""
|
||||||
{
|
{
|
||||||
@@ -424,8 +420,16 @@ spec = do
|
|||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
g <- get "/items"
|
request methodPatch "/items" [] [json| [] |]
|
||||||
liftIO $ simpleBody g `shouldBe` [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}] |]
|
`shouldRespondWith` ""
|
||||||
|
{
|
||||||
|
matchStatus = 204,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
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}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $ do
|
it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $ do
|
||||||
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
|
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
|
||||||
@@ -434,9 +438,9 @@ spec = do
|
|||||||
matchStatus = 200,
|
matchStatus = 200,
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
get "/items" `shouldRespondWith`
|
||||||
g <- get "/items"
|
[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}] |]
|
||||||
liftIO $ simpleBody g `shouldBe` [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] }
|
||||||
|
|
||||||
context "with unicode values" $
|
context "with unicode values" $
|
||||||
it "succeeds and returns values intact" $ do
|
it "succeeds and returns values intact" $ do
|
||||||
|
|||||||
@@ -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 = [] }
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module Feature.NonexistentSchemaSpec where
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
import Protolude hiding (get)
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec =
|
||||||
|
describe "Non existent api schema" $ do
|
||||||
|
it "succeeds when requesting root path" $
|
||||||
|
get "/" `shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "gives 404 when requesting a nonexistent table in this nonexistent schema" $
|
||||||
|
get "/nonexistent_table" `shouldRespondWith` 404
|
||||||
@@ -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] }
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
module Feature.PgVersion96Spec 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.6" $ do
|
||||||
|
context "GUC headers" $ do
|
||||||
|
it "succeeds setting the headers" $ do
|
||||||
|
get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"
|
||||||
|
`shouldRespondWith` [json|[{"id": 2}]|]
|
||||||
|
{matchHeaders = [
|
||||||
|
matchContentTypeJson,
|
||||||
|
"X-Test" <:> "key1=val1; someValue; key2=val2",
|
||||||
|
"X-Test-2" <:> "key1=val1"]}
|
||||||
|
get "/rpc/get_int_and_guc_headers?num=1"
|
||||||
|
`shouldRespondWith` [json|1|]
|
||||||
|
{matchHeaders = [
|
||||||
|
matchContentTypeJson,
|
||||||
|
"X-Test" <:> "key1=val1; someValue; key2=val2",
|
||||||
|
"X-Test-2" <:> "key1=val1"]}
|
||||||
|
post "/rpc/get_int_and_guc_headers" [json|{"num": 1}|]
|
||||||
|
`shouldRespondWith` [json|1|]
|
||||||
|
{matchHeaders = [
|
||||||
|
matchContentTypeJson,
|
||||||
|
"X-Test" <:> "key1=val1; someValue; key2=val2",
|
||||||
|
"X-Test-2" <:> "key1=val1"]}
|
||||||
|
|
||||||
|
it "fails when setting headers with wrong json structure" $ do
|
||||||
|
get "/rpc/bad_guc_headers_1" `shouldRespondWith` 500
|
||||||
|
get "/rpc/bad_guc_headers_2" `shouldRespondWith` 500
|
||||||
|
get "/rpc/bad_guc_headers_3" `shouldRespondWith` 500
|
||||||
|
post "/rpc/bad_guc_headers_1" [json|{}|] `shouldRespondWith` 500
|
||||||
|
|
||||||
|
it "can set the same http header twice" $
|
||||||
|
get "/rpc/set_cookie_twice"
|
||||||
|
`shouldRespondWith` "null"
|
||||||
|
{matchHeaders = [
|
||||||
|
matchContentTypeJson,
|
||||||
|
"Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/",
|
||||||
|
"Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"]}
|
||||||
|
|
||||||
|
context "Use of the phraseto_tsquery function" $ do
|
||||||
|
it "finds matches" $
|
||||||
|
get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`
|
||||||
|
[json| [{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "finds matches with different dictionaries" $
|
||||||
|
get "/tsearch?text_search_vector=phfts(german).Art%20Spass" `shouldRespondWith`
|
||||||
|
[json| [{"text_search_vector": "'art':4 'spass':5 'unmog':7" }] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can be negated with not operator" $
|
||||||
|
get "/tsearch?text_search_vector=not.phfts(english).The%20Fat%20Cats" `shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
|
||||||
|
{"text_search_vector": "'also':2 'fun':3 'possibl':8"},
|
||||||
|
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
|
||||||
|
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can be used with or query param" $
|
||||||
|
get "/tsearch?or=(text_search_vector.phfts(german).Art%20Spass, text_search_vector.phfts(french).amusant, text_search_vector.fts(english).impossible)" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
|
||||||
|
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||||
|
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
|
||||||
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "should work when used with GET RPC" $
|
||||||
|
get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith`
|
||||||
|
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
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
|
||||||
import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus))
|
import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus))
|
||||||
import Text.Heredoc
|
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
|
|
||||||
@@ -30,15 +29,15 @@ 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` [str|[{"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` [str|[{"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/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
+542
-440
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus))
|
|||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Text.Heredoc
|
|
||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Protolude hiding (get)
|
||||||
@@ -131,28 +130,28 @@ spec = do
|
|||||||
it "no parameters return everything" $
|
it "no parameters return everything" $
|
||||||
get "/items?select=id&order=id.asc"
|
get "/items?select=id&order=id.asc"
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[str|[{"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}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
||||||
}
|
}
|
||||||
it "top level limit with parameter" $
|
it "top level limit with parameter" $
|
||||||
get "/items?select=id&order=id.asc&limit=3"
|
get "/items?select=id&order=id.asc&limit=3"
|
||||||
`shouldRespondWith` [str|[{"id":1},{"id":2},{"id":3}]|]
|
`shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
it "headers override get parameters" $
|
it "headers override get parameters" $
|
||||||
request methodGet "/items?select=id&order=id.asc&limit=3"
|
request methodGet "/items?select=id&order=id.asc&limit=3"
|
||||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||||
`shouldRespondWith` [str|[{"id":1},{"id":2}]|]
|
`shouldRespondWith` [json|[{"id":1},{"id":2}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
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`
|
||||||
[str|[{"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
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
@@ -160,7 +159,7 @@ spec = do
|
|||||||
|
|
||||||
it "limit and offset works on first level" $
|
it "limit and offset works on first level" $
|
||||||
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
||||||
`shouldRespondWith` [str|[{"id":3},{"id":4},{"id":5}]|]
|
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
module Feature.RpcSpec where
|
||||||
|
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Network.Wai.Test (SResponse(simpleStatus, simpleBody))
|
||||||
|
import qualified Data.ByteString.Lazy as BL (empty)
|
||||||
|
|
||||||
|
import SpecHelper
|
||||||
|
import Text.Heredoc
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec =
|
||||||
|
describe "remote procedure call" $ do
|
||||||
|
context "a proc that returns a set" $ do
|
||||||
|
it "returns paginated results" $ do
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrs (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 4 } |]
|
||||||
|
`shouldRespondWith` [json| [{"id":3}] |]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
|
}
|
||||||
|
request methodGet "/rpc/getitemrange?min=2&max=4"
|
||||||
|
(rangeHdrs (ByteRangeFromTo 0 0)) ""
|
||||||
|
`shouldRespondWith` [json| [{"id":3}] |]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "includes total count if requested" $ do
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrsWithCount (ByteRangeFromTo 0 0))
|
||||||
|
[json| { "min": 2, "max": 4 } |]
|
||||||
|
`shouldRespondWith` [json| [{"id":3}] |]
|
||||||
|
{ matchStatus = 206 -- it now knows the response is partial
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
||||||
|
}
|
||||||
|
request methodGet "/rpc/getitemrange?min=2&max=4"
|
||||||
|
(rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""
|
||||||
|
`shouldRespondWith` [json| [{"id":3}] |]
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "returns proper json" $ do
|
||||||
|
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
|
||||||
|
[json| [ {"id": 3}, {"id":4} ] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/getitemrange?min=2&max=4" `shouldRespondWith`
|
||||||
|
[json| [ {"id": 3}, {"id":4} ] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns CSV" $ do
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(acceptHdrs "text/csv")
|
||||||
|
[json| { "min": 2, "max": 4 } |]
|
||||||
|
`shouldRespondWith` "id\n3\n4"
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||||
|
}
|
||||||
|
request methodGet "/rpc/getitemrange?min=2&max=4"
|
||||||
|
(acceptHdrs "text/csv") ""
|
||||||
|
`shouldRespondWith` "id\n3\n4"
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "unknown function" $ do
|
||||||
|
it "returns 404" $
|
||||||
|
post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404
|
||||||
|
it "should fail with 404 on unknown proc name" $
|
||||||
|
get "/rpc/fake" `shouldRespondWith` 404
|
||||||
|
it "should fail with 404 on unknown proc args" $ do
|
||||||
|
get "/rpc/sayhello" `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
|
||||||
|
it "returns a project" $ do
|
||||||
|
post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Windows 7","client_id":1}]|]
|
||||||
|
get "/rpc/getproject?id=1" `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Windows 7","client_id":1}]|]
|
||||||
|
|
||||||
|
it "can filter proc results" $ do
|
||||||
|
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`
|
||||||
|
[json|[{"id":2},{"id":3},{"id":4}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" `shouldRespondWith`
|
||||||
|
[json|[{"id":2},{"id":3},{"id":4}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can limit proc results" $ do
|
||||||
|
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1" [json| {} |]
|
||||||
|
`shouldRespondWith` [json|[{"id":3},{"id":4}]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "1-2/*"] }
|
||||||
|
get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1"
|
||||||
|
`shouldRespondWith` [json|[{"id":3},{"id":4}]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "1-2/*"] }
|
||||||
|
|
||||||
|
it "select works on the first level" $ do
|
||||||
|
post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Windows 7"}]|]
|
||||||
|
get "/rpc/getproject?id=1&select=id,name" `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Windows 7"}]|]
|
||||||
|
|
||||||
|
context "foreign entities embedding" $ do
|
||||||
|
it "can embed if related tables are in the exposed schema" $ do
|
||||||
|
post "/rpc/getproject?select=id,name,client(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/getproject?id=1&select=id,name,client(id),tasks(id)" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "cannot embed if the related table is not in the exposed schema" $ do
|
||||||
|
post "/rpc/single_article?select=*,article_stars(*)" [json|{ "id": 1}|]
|
||||||
|
`shouldRespondWith` 400
|
||||||
|
get "/rpc/single_article?id=1&select=*,article_stars(*)"
|
||||||
|
`shouldRespondWith` 400
|
||||||
|
|
||||||
|
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}|]
|
||||||
|
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/single_article?id=2&select=id,articleStars(userId)"
|
||||||
|
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "a proc that returns an empty rowset" $
|
||||||
|
it "returns empty json array" $ do
|
||||||
|
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/test_empty_rowset" `shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "proc return types" $ do
|
||||||
|
context "returns text" $ do
|
||||||
|
it "returns proper json" $
|
||||||
|
post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
|
||||||
|
[json|"Hello, world"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can handle unicode" $
|
||||||
|
post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`
|
||||||
|
[json|"Hello, ¥"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns array" $
|
||||||
|
post "/rpc/ret_array" [json|{}|] `shouldRespondWith`
|
||||||
|
[json|[1, 2, 3]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns setof integers" $
|
||||||
|
post "/rpc/ret_setof_integers" [json|{}|] `shouldRespondWith`
|
||||||
|
[json|[{ "ret_setof_integers": 1 },
|
||||||
|
{ "ret_setof_integers": 2 },
|
||||||
|
{ "ret_setof_integers": 3 }]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns enum value" $
|
||||||
|
post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`
|
||||||
|
[json|"foo"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns domain value" $
|
||||||
|
post "/rpc/ret_domain" [json|{ "val": "8" }|] `shouldRespondWith`
|
||||||
|
[json|8|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns range" $
|
||||||
|
post "/rpc/ret_range" [json|{ "low": 10, "up": 20 }|] `shouldRespondWith`
|
||||||
|
[json|"[10,20)"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns row of scalars" $
|
||||||
|
post "/rpc/ret_scalars" [json|{}|] `shouldRespondWith`
|
||||||
|
[json|[{"a":"scalars", "b":"foo", "c":1, "d":"[10,20)"}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns composite type in exposed schema" $
|
||||||
|
post "/rpc/ret_point_2d" [json|{}|] `shouldRespondWith`
|
||||||
|
[json|[{"x": 10, "y": 5}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "cannot return composite type in hidden schema" $
|
||||||
|
post "/rpc/ret_point_3d" [json|{}|] `shouldRespondWith` 401
|
||||||
|
|
||||||
|
it "returns single row from table" $
|
||||||
|
post "/rpc/single_article?select=id" [json|{"id": 2}|] `shouldRespondWith`
|
||||||
|
[json|[{"id": 2}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns null for void" $
|
||||||
|
post "/rpc/ret_void" [json|{}|] `shouldRespondWith`
|
||||||
|
[json|null|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "improper input" $ do
|
||||||
|
it "rejects unknown content type even if payload is good" $ do
|
||||||
|
request methodPost "/rpc/sayhello"
|
||||||
|
(acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]
|
||||||
|
`shouldRespondWith` 415
|
||||||
|
request methodGet "/rpc/sayhello?name=world"
|
||||||
|
(acceptHdrs "audio/mpeg3") ""
|
||||||
|
`shouldRespondWith` 415
|
||||||
|
it "rejects malformed json payload" $ do
|
||||||
|
p <- request methodPost "/rpc/sayhello"
|
||||||
|
(acceptHdrs "application/json") "sdfsdf"
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` badRequest400
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
it "treats simple plpgsql raise as invalid input" $ do
|
||||||
|
p <- post "/rpc/problem" "{}"
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` badRequest400
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
|
context "unsupported verbs" $ do
|
||||||
|
it "DELETE fails" $
|
||||||
|
request methodDelete "/rpc/sayhello" [] ""
|
||||||
|
`shouldRespondWith` 405
|
||||||
|
it "PATCH fails" $
|
||||||
|
request methodPatch "/rpc/sayhello" [] ""
|
||||||
|
`shouldRespondWith` 405
|
||||||
|
it "OPTIONS fails" $
|
||||||
|
-- TODO: should return info about the function
|
||||||
|
request methodOptions "/rpc/sayhello" [] ""
|
||||||
|
`shouldRespondWith` 405
|
||||||
|
|
||||||
|
it "executes the proc exactly once per request" $ do
|
||||||
|
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
|
||||||
|
[json|1|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
|
||||||
|
[json|2|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "a proc that receives no parameters" $ do
|
||||||
|
it "interprets empty string as empty json object on a post request" $
|
||||||
|
post "/rpc/noparamsproc" BL.empty `shouldRespondWith`
|
||||||
|
[json| "Return value of no parameters procedure." |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "interprets empty string as a function with no args on a get request" $
|
||||||
|
get "/rpc/noparamsproc" `shouldRespondWith`
|
||||||
|
[json| "Return value of no parameters procedure." |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns proper output when having the same return col name as the proc name" $ do
|
||||||
|
post "/rpc/test" [json|{}|] `shouldRespondWith`
|
||||||
|
[json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/test" `shouldRespondWith`
|
||||||
|
[json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "procs with OUT/INOUT params" $ do
|
||||||
|
it "returns a scalar result when there is a single OUT param" $ do
|
||||||
|
get "/rpc/single_out_param?num=5" `shouldRespondWith`
|
||||||
|
[json|6|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/single_json_out_param?a=1&b=two" `shouldRespondWith`
|
||||||
|
[json|{"a": 1, "b": "two"}|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns a scalar result when there is a single INOUT param" $
|
||||||
|
get "/rpc/single_inout_param?num=2" `shouldRespondWith`
|
||||||
|
[json|3|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns a row result when there are many OUT params" $
|
||||||
|
get "/rpc/many_out_params" `shouldRespondWith`
|
||||||
|
[json|[{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns a row result when there are many INOUT params" $
|
||||||
|
get "/rpc/many_inout_params?num=1&str=two&b=false" `shouldRespondWith`
|
||||||
|
[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" $
|
||||||
|
get "/rpc/raise_pt402"
|
||||||
|
`shouldRespondWith` [json|{ "hint": "Upgrade your plan", "details": "Quota exceeded" }|]
|
||||||
|
{ matchStatus = 402
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "defaults to status 500 if RAISE code is PT not followed by a number" $
|
||||||
|
get "/rpc/raise_bad_pt" `shouldRespondWith` 500
|
||||||
|
|
||||||
|
context "expects a single json object" $ do
|
||||||
|
it "does not expand posted json into parameters" $
|
||||||
|
request methodPost "/rpc/singlejsonparam"
|
||||||
|
[("prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
|
||||||
|
[json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "accepts parameters from an html form" $
|
||||||
|
request methodPost "/rpc/singlejsonparam"
|
||||||
|
[("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]
|
||||||
|
("integer=7&double=2.71828&varchar=forms+are+fun&" <>
|
||||||
|
"boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
|
||||||
|
[json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
|
||||||
|
, "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
|
||||||
|
{ 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" $
|
||||||
|
post "/rpc/sayhello?name=John" [json|{}|] `shouldRespondWith` 400
|
||||||
|
|
||||||
|
context "only for GET rpc" $ do
|
||||||
|
it "should fail on mutating procs" $ do
|
||||||
|
get "/rpc/callcounter" `shouldRespondWith` 500
|
||||||
|
get "/rpc/setprojects?id_l=1&id_h=5&name=FreeBSD" `shouldRespondWith` 500
|
||||||
|
|
||||||
|
it "should filter a proc that has arg name = filter name" $
|
||||||
|
get "/rpc/get_projects_below?id=5&id=gt.2&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 3 }, { "id": 4 }]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "should work with filters that have the not operator" $ do
|
||||||
|
get "/rpc/get_projects_below?id=5&id=not.gt.2&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/get_projects_below?id=5&id=not.in.(1,3)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 2 }, { "id": 4 }]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "should work with filters that use the plain with language fts operator" $ do
|
||||||
|
get "/rpc/get_tsearch?text_search_vector=fts(english).impossible" `shouldRespondWith`
|
||||||
|
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/get_tsearch?text_search_vector=plfts.impossible" `shouldRespondWith`
|
||||||
|
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`
|
||||||
|
[json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "should work with an argument of custom type in public schema" $
|
||||||
|
get "/rpc/test_arg?my_arg=something" `shouldRespondWith`
|
||||||
|
[json|"foobar"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
@@ -37,9 +37,10 @@ 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`
|
||||||
[str|{"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"] }
|
||||||
|
|
||||||
context "when updating rows" $ do
|
context "when updating rows" $ do
|
||||||
|
|
||||||
|
|||||||
+163
-13
@@ -1,10 +1,12 @@
|
|||||||
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
|
||||||
|
|
||||||
|
import PostgREST.Config (docsVersion)
|
||||||
import Control.Lens ((^?))
|
import Control.Lens ((^?))
|
||||||
|
import Data.Aeson.Types (Value (..))
|
||||||
import Data.Aeson.Lens
|
import Data.Aeson.Lens
|
||||||
import Data.Aeson.QQ
|
import Data.Aeson.QQ
|
||||||
|
|
||||||
@@ -27,7 +29,14 @@ spec = do
|
|||||||
(acceptHdrs "application/openapi+json") ""
|
(acceptHdrs "application/openapi+json") ""
|
||||||
`shouldRespondWith` 415
|
`shouldRespondWith` 415
|
||||||
|
|
||||||
describe "table" $
|
it "includes postgrest.org current version api docs" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let docsUrl = r ^? key "externalDocs" . key "url"
|
||||||
|
|
||||||
|
liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
|
||||||
|
|
||||||
|
describe "table" $ do
|
||||||
|
|
||||||
it "includes paths to tables" $ do
|
it "includes paths to tables" $ do
|
||||||
r <- simpleBody <$> get "/"
|
r <- simpleBody <$> get "/"
|
||||||
@@ -76,7 +85,28 @@ spec = do
|
|||||||
|
|
||||||
deleteResponse `shouldBe` Just "No Content"
|
deleteResponse `shouldBe` Just "No Content"
|
||||||
|
|
||||||
it "includes definitions to tables" $ do
|
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
|
||||||
r <- simpleBody <$> get "/"
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
let def = r ^? key "definitions" . key "child_entities"
|
let def = r ^? key "definitions" . key "child_entities"
|
||||||
@@ -104,19 +134,107 @@ spec = do
|
|||||||
"format": "integer",
|
"format": "integer",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"required": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|]
|
|]
|
||||||
|
|
||||||
describe "RPC" $
|
it "doesn't include privileged table for anonymous" $ do
|
||||||
|
|
||||||
it "includes body schema for arguments" $ do
|
|
||||||
r <- simpleBody <$> get "/"
|
r <- simpleBody <$> get "/"
|
||||||
let args = r ^? key "paths" . key "/rpc/varied_arguments"
|
let tablePath = r ^? key "paths" . key "/authors_only"
|
||||||
. key "post" . key "parameters"
|
|
||||||
. nth 0 . key "schema"
|
liftIO $ tablePath `shouldBe` Nothing
|
||||||
|
|
||||||
|
it "includes table if user has permission" $ do
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
|
||||||
|
r <- simpleBody <$> request methodGet "/" [auth] ""
|
||||||
|
let tableTag = r ^? key "paths" . key "/authors_only"
|
||||||
|
. key "post" . key "tags"
|
||||||
|
. nth 0
|
||||||
|
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
|
||||||
|
|
||||||
|
it "includes function summary/description and body schema for arguments" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
liftIO $
|
|
||||||
args `shouldBe` Just
|
args `shouldBe` Just
|
||||||
[aesonQQ|
|
[aesonQQ|
|
||||||
{
|
{
|
||||||
@@ -150,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": {
|
||||||
@@ -158,10 +276,42 @@ spec = do
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object",
|
||||||
|
"description": "An RPC function\n\nJust a test for RPC function arguments"
|
||||||
}
|
}
|
||||||
|]
|
|]
|
||||||
|
|
||||||
|
it "doesn't include privileged function for anonymous" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
let funcPath = r ^? key "paths" . key "/rpc/privileged_hello"
|
||||||
|
|
||||||
|
liftIO $ funcPath `shouldBe` Nothing
|
||||||
|
|
||||||
|
it "includes function if user has permission" $ do
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
|
||||||
|
r <- simpleBody <$> request methodGet "/" [auth] ""
|
||||||
|
let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"
|
||||||
|
. key "post" . key "tags"
|
||||||
|
. nth 0
|
||||||
|
|
||||||
|
liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]
|
||||||
|
|
||||||
|
it "doesn't include OUT params of function as required parameters" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
let params = r ^? key "paths" . key "/rpc/many_out_params"
|
||||||
|
. key "post" . key "parameters" . nth 0
|
||||||
|
. key "schema". key "required"
|
||||||
|
|
||||||
|
liftIO $ params `shouldBe` Nothing
|
||||||
|
|
||||||
|
it "includes INOUT params(with no DEFAULT) of function as required parameters" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
let params = r ^? key "paths" . key "/rpc/many_inout_params"
|
||||||
|
. key "post" . key "parameters" . nth 0
|
||||||
|
. key "schema". key "required"
|
||||||
|
|
||||||
|
liftIO $ params `shouldBe` Just [aesonQQ|["num", "str"]|]
|
||||||
|
|
||||||
describe "Allow header" $ do
|
describe "Allow header" $ do
|
||||||
|
|
||||||
it "includes read/write verbs for writeable table" $ do
|
it "includes read/write verbs for writeable table" $ do
|
||||||
|
|||||||
@@ -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] }
|
||||||
+73
-24
@@ -4,19 +4,26 @@ 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.DbStructure (getDbStructure)
|
|
||||||
import PostgREST.App (postgrest)
|
import PostgREST.App (postgrest)
|
||||||
|
import PostgREST.DbStructure (getDbStructure, getPgVersion)
|
||||||
|
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
|
||||||
import qualified Feature.BinaryJwtSecretSpec
|
import qualified Feature.BinaryJwtSecretSpec
|
||||||
|
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
|
||||||
@@ -26,6 +33,11 @@ import qualified Feature.SingularSpec
|
|||||||
import qualified Feature.UnicodeSpec
|
import qualified Feature.UnicodeSpec
|
||||||
import qualified Feature.ProxySpec
|
import qualified Feature.ProxySpec
|
||||||
import qualified Feature.AndOrParamsSpec
|
import qualified Feature.AndOrParamsSpec
|
||||||
|
import qualified Feature.RpcSpec
|
||||||
|
import qualified Feature.NonexistentSchemaSpec
|
||||||
|
import qualified Feature.PgVersion95Spec
|
||||||
|
import qualified Feature.PgVersion96Spec
|
||||||
|
import qualified Feature.UpsertSpec
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
@@ -36,17 +48,52 @@ main = do
|
|||||||
|
|
||||||
pool <- P.acquire (3, 10, toS testDbConn)
|
pool <- P.acquire (3, 10, toS testDbConn)
|
||||||
|
|
||||||
result <- P.use pool $ getDbStructure "test"
|
result <- P.use pool $ do
|
||||||
refDbStructure <- newIORef $ Just $ either (panic.show) id result
|
ver <- getPgVersion
|
||||||
let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool $ pure ()
|
HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver
|
||||||
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool $ pure ()
|
|
||||||
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool $ pure ()
|
dbStructure <- pure $ either (panic.show) id result
|
||||||
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool $ pure ()
|
|
||||||
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool $ pure ()
|
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
|
||||||
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool $ pure ()
|
|
||||||
asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool $ pure ()
|
refDbStructure <- newIORef $ Just dbStructure
|
||||||
|
|
||||||
|
let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
asymJwkSetApp = return $ postgrest (testCfgAsymJWKSet testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
extraSearchPathApp = return $ postgrest (testCfgExtraSearchPath testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
|
||||||
|
let reset :: IO ()
|
||||||
|
reset = resetDb testDbConn
|
||||||
|
|
||||||
|
actualPgVersion = pgVersion dbStructure
|
||||||
|
extraSpecs =
|
||||||
|
[("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
|
||||||
|
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95] ++
|
||||||
|
[("Feature.PgVersion96Spec", Feature.PgVersion96Spec.spec) | actualPgVersion >= pgVersion96]
|
||||||
|
|
||||||
|
specs = uncurry describe <$> [
|
||||||
|
("Feature.AuthSpec" , Feature.AuthSpec.spec)
|
||||||
|
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
|
||||||
|
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
|
||||||
|
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
|
||||||
|
, ("Feature.InsertSpec" , Feature.InsertSpec.spec)
|
||||||
|
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec)
|
||||||
|
, ("Feature.QuerySpec" , Feature.QuerySpec.spec)
|
||||||
|
, ("Feature.RpcSpec" , Feature.RpcSpec.spec)
|
||||||
|
, ("Feature.RangeSpec" , Feature.RangeSpec.spec)
|
||||||
|
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
|
||||||
|
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
|
||||||
|
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)
|
||||||
|
] ++ extraSpecs
|
||||||
|
|
||||||
let reset = resetDb testDbConn
|
|
||||||
hspec $ do
|
hspec $ do
|
||||||
mapM_ (beforeAll_ reset . before withApp) specs
|
mapM_ (beforeAll_ reset . before withApp) specs
|
||||||
|
|
||||||
@@ -70,20 +117,22 @@ main = do
|
|||||||
beforeAll_ reset . before binaryJwtApp $
|
beforeAll_ reset . before binaryJwtApp $
|
||||||
describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec
|
describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec
|
||||||
|
|
||||||
|
-- this test runs with a binary JWT secret and an audience claim
|
||||||
|
beforeAll_ reset . before audJwtApp $
|
||||||
|
describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec
|
||||||
|
|
||||||
-- this test runs with asymmetric JWK
|
-- this test runs with asymmetric JWK
|
||||||
beforeAll_ reset . before asymJwkApp $
|
beforeAll_ reset . before asymJwkApp $
|
||||||
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
|
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
|
||||||
|
|
||||||
where
|
-- this test runs with asymmetric JWKSet
|
||||||
specs = map (uncurry describe) [
|
beforeAll_ reset . before asymJwkSetApp $
|
||||||
("Feature.AuthSpec" , Feature.AuthSpec.spec)
|
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
|
||||||
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
|
|
||||||
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
|
-- this test runs with a nonexistent db-schema
|
||||||
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
|
beforeAll_ reset . before nonexistentSchemaApp $
|
||||||
, ("Feature.InsertSpec" , Feature.InsertSpec.spec)
|
describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
|
||||||
, ("Feature.QuerySpec" , Feature.QuerySpec.spec)
|
|
||||||
, ("Feature.RangeSpec" , Feature.RangeSpec.spec)
|
-- this test runs with an extra search path
|
||||||
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
|
beforeAll_ reset . before extraSearchPathApp $
|
||||||
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
|
describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec
|
||||||
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)
|
|
||||||
]
|
|
||||||
|
|||||||
+32
-6
@@ -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,19 +61,25 @@ 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
|
||||||
AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000
|
AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000
|
||||||
-- Jwt settings
|
-- Jwt settings
|
||||||
(Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False
|
(Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False Nothing
|
||||||
-- Connection Modifiers
|
-- Connection Modifiers
|
||||||
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 }
|
||||||
@@ -95,12 +102,31 @@ testCfgBinaryJWT testDbConn = (testCfg testDbConn) {
|
|||||||
"cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU="
|
"cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU="
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testCfgAudienceJWT :: Text -> AppConfig
|
||||||
|
testCfgAudienceJWT testDbConn = (testCfg testDbConn) {
|
||||||
|
configJwtSecret = Just . B64.decodeLenient $
|
||||||
|
"cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=",
|
||||||
|
configJwtAudience = Just "youraudience"
|
||||||
|
}
|
||||||
|
|
||||||
testCfgAsymJWK :: Text -> AppConfig
|
testCfgAsymJWK :: Text -> AppConfig
|
||||||
testCfgAsymJWK testDbConn = (testCfg testDbConn) {
|
testCfgAsymJWK testDbConn = (testCfg testDbConn) {
|
||||||
configJwtSecret = Just $ encodeUtf8
|
configJwtSecret = Just $ encodeUtf8
|
||||||
[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 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"
|
||||||
@@ -150,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
@@ -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,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"
|
||||||
|
|||||||
Vendored
+169
-4
@@ -248,7 +248,9 @@ INSERT INTO nullable_integer VALUES (NULL);
|
|||||||
TRUNCATE TABLE tsearch CASCADE;
|
TRUNCATE TABLE tsearch CASCADE;
|
||||||
INSERT INTO tsearch VALUES (to_tsvector('It''s kind of fun to do the impossible'));
|
INSERT INTO tsearch VALUES (to_tsvector('It''s kind of fun to do the impossible'));
|
||||||
INSERT INTO tsearch VALUES (to_tsvector('But also fun to do what is possible'));
|
INSERT INTO tsearch VALUES (to_tsvector('But also fun to do what is possible'));
|
||||||
|
INSERT INTO tsearch VALUES (to_tsvector('Fat cats ate rats'));
|
||||||
|
INSERT INTO tsearch VALUES (to_tsvector('french', 'C''est un peu amusant de faire l''impossible'));
|
||||||
|
INSERT INTO tsearch VALUES (to_tsvector('german', 'Es ist eine Art Spaß, das Unmögliche zu machen'));
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Data for Name: users_projects; Type: TABLE DATA; Schema: test; Owner: -
|
-- Data for Name: users_projects; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
@@ -321,6 +323,169 @@ INSERT INTO ranges VALUES (2, '[3,6]');
|
|||||||
INSERT INTO ranges VALUES (3, '[6,9]');
|
INSERT INTO ranges VALUES (3, '[6,9]');
|
||||||
INSERT INTO ranges VALUES (4, '[9,12]');
|
INSERT INTO ranges VALUES (4, '[9,12]');
|
||||||
|
|
||||||
--
|
TRUNCATE TABLE being CASCADE;
|
||||||
-- PostgreSQL database dump complete
|
INSERT INTO being VALUES (1), (2), (3), (4);
|
||||||
--
|
|
||||||
|
TRUNCATE TABLE descendant CASCADE;
|
||||||
|
INSERT INTO descendant VALUES (1,1), (2,1), (3,1), (4,2);
|
||||||
|
|
||||||
|
TRUNCATE TABLE part CASCADE;
|
||||||
|
INSERT INTO part VALUES (1), (2), (3), (4);
|
||||||
|
|
||||||
|
TRUNCATE TABLE being_part CASCADE;
|
||||||
|
INSERT INTO being_part VALUES (1,1), (2,1), (3,2), (4,3);
|
||||||
|
|
||||||
|
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
|
||||||
|
\.
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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;
|
||||||
|
|||||||
Vendored
+1
-2
@@ -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)));
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Vendored
+47
@@ -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
|
||||||
@@ -57,6 +58,46 @@ GRANT ALL ON TABLE
|
|||||||
, child_entities
|
, child_entities
|
||||||
, grandchild_entities
|
, grandchild_entities
|
||||||
, ranges
|
, ranges
|
||||||
|
, being
|
||||||
|
, descendant
|
||||||
|
, being_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;
|
||||||
@@ -65,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
|
||||||
@@ -74,3 +116,8 @@ GRANT ALL ON TABLE authors_only TO postgrest_test_author;
|
|||||||
GRANT SELECT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
GRANT SELECT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
||||||
GRANT INSERT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
GRANT INSERT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
||||||
GRANT UPDATE (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
GRANT UPDATE (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
||||||
|
|
||||||
|
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 USAGE ON SCHEMA test TO postgrest_test_default_role;
|
||||||
|
|||||||
Vendored
+425
-20
@@ -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: -
|
||||||
@@ -1096,6 +1096,12 @@ CREATE FUNCTION getproject(id int) RETURNS SETOF projects
|
|||||||
SELECT * FROM test.projects WHERE id = $1;
|
SELECT * FROM test.projects WHERE id = $1;
|
||||||
$_$;
|
$_$;
|
||||||
|
|
||||||
|
CREATE FUNCTION get_projects_below(id int) RETURNS SETOF projects
|
||||||
|
LANGUAGE sql
|
||||||
|
AS $_$
|
||||||
|
SELECT * FROM test.projects WHERE id < $1;
|
||||||
|
$_$;
|
||||||
|
|
||||||
CREATE FUNCTION getallprojects() RETURNS SETOF projects
|
CREATE FUNCTION getallprojects() RETURNS SETOF projects
|
||||||
LANGUAGE sql
|
LANGUAGE sql
|
||||||
AS $_$
|
AS $_$
|
||||||
@@ -1241,6 +1247,405 @@ create function test.test() returns table(test text, value int) as $$
|
|||||||
values ('hello', 1);
|
values ('hello', 1);
|
||||||
$$ language sql;
|
$$ language sql;
|
||||||
|
|
||||||
--
|
create function test.privileged_hello(name text) returns text as $$
|
||||||
-- PostgreSQL database dump complete
|
select 'Privileged hello to ' || $1;
|
||||||
--
|
$$ language sql;
|
||||||
|
|
||||||
|
create function test.get_tsearch() returns setof test.tsearch AS $$
|
||||||
|
SELECT * FROM test.tsearch;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create table test.being (
|
||||||
|
being int primary key not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table test.descendant (
|
||||||
|
descendant int primary key not null,
|
||||||
|
being int references test.being(being)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table test.part (
|
||||||
|
part int primary key not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table test.being_part (
|
||||||
|
being int not null references test.being(being),
|
||||||
|
part int not null references test.part(part)
|
||||||
|
);
|
||||||
|
|
||||||
|
create function test.single_out_param(num int, OUT num_plus_one int) AS $$
|
||||||
|
select num + 1;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create function test.single_json_out_param(a int, b text, OUT my_json pg_catalog.json) AS $$
|
||||||
|
select json_build_object('a', a, 'b', b);
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create function test.many_out_params(OUT my_json pg_catalog.json, OUT num int, OUT str text) AS $$
|
||||||
|
select '{"a": 1, "b": "two"}'::json, 3, 'four'::text;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create function test.single_inout_param(INOUT num int) AS $$
|
||||||
|
select num + 1;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create function test.many_inout_params(INOUT num int, INOUT str text, INOUT b bool DEFAULT true) AS $$
|
||||||
|
select num, str, b;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create or replace function test.raise_pt402() returns void as $$
|
||||||
|
begin
|
||||||
|
raise sqlstate 'PT402' using message = 'Payment Required',
|
||||||
|
detail = 'Quota exceeded',
|
||||||
|
hint = 'Upgrade your plan';
|
||||||
|
end;
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
create or replace function test.raise_bad_pt() returns void as $$
|
||||||
|
begin
|
||||||
|
raise sqlstate 'PT40A' using message = 'Wrong';
|
||||||
|
end;
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
create or replace function test.get_projects_and_guc_headers() returns setof test.projects as $$
|
||||||
|
set local "response.headers" = '[{"X-Test": "key1=val1; someValue; key2=val2"}, {"X-Test-2": "key1=val1"}]';
|
||||||
|
select * from test.projects;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create or replace function test.get_int_and_guc_headers(num int) returns integer as $$
|
||||||
|
set local "response.headers" = '[{"X-Test":"key1=val1; someValue; key2=val2"},{"X-Test-2":"key1=val1"}]';
|
||||||
|
select num;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create or replace function test.bad_guc_headers_1() returns void as $$
|
||||||
|
set local "response.headers" = '{"X-Test": "invalid structure for headers"}';
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create or replace function test.bad_guc_headers_2() returns void as $$
|
||||||
|
set local "response.headers" = '["invalid", "structure", "for", "headers"]';
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create or replace function test.bad_guc_headers_3() returns void as $$
|
||||||
|
set local "response.headers" = '{"X-Test": "invalid", "X-Test-2": "structure", "X-Test-3": "for headers"}';
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
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"}]';
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|||||||
+127
-9
@@ -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,23 +189,41 @@ 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)'
|
||||||
skip readSecretFromFile word.txt 'simple'
|
readSecretFromFile word.txt 'simple'
|
||||||
readSecretFromFile ascii.noeol 'ASCII (no EOL)'
|
readSecretFromFile ascii.noeol 'ASCII (no EOL)'
|
||||||
skip readSecretFromFile ascii.txt 'ASCII'
|
readSecretFromFile ascii.txt 'ASCII'
|
||||||
readSecretFromFile utf8.noeol 'UTF-8 (no EOL)'
|
readSecretFromFile utf8.noeol 'UTF-8 (no EOL)'
|
||||||
skip readSecretFromFile utf8.txt 'UTF-8'
|
readSecretFromFile utf8.txt 'UTF-8'
|
||||||
skip readSecretFromFile binary.noeol 'binary'
|
readSecretFromFile binary.noeol 'binary'
|
||||||
skip readSecretFromFile binary.eol 'binary (+EOL)'
|
readSecretFromFile binary.eol 'binary (+EOL)'
|
||||||
|
|
||||||
readSecretFromFile word.b64 'Base64 (simple)'
|
readSecretFromFile word.b64 'Base64 (simple)'
|
||||||
readSecretFromFile ascii.b64 'Base64 (ASCII)'
|
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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
postgres:///postgrest_test
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
postgres:///postgrest_test
|
||||||
Executable
+117
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user