Compare commits

...
105 Commits
Author SHA1 Message Date
Laurence Isla a4e00ffdf6 bump version to 12.0.2 2023-12-20 18:21:19 -05:00
steve-chavezandLaurence Isla cd62e39cd1 fix: empty row on handler function
Closes https://github.com/PostgREST/postgrest/issues/3126
2023-12-20 16:33:34 -05:00
steve-chavezandLaurence Isla ec0f99c686 fix: aggregates not working for all schemas
Closes https://github.com/PostgREST/postgrest/issues/3124
2023-12-20 16:33:31 -05:00
Laurence Isla b2cd365866 chore: add new sponsor 2023-12-20 16:29:37 -05:00
dependabot[bot]andLaurence Isla fcc330311f build(deps): bump actions/upload-artifact from 3 to 4 (#3123) 2023-12-20 16:29:37 -05:00
dependabot[bot]andLaurence Isla e557161b84 build(deps): bump dawidd6/action-download-artifact from 2 to 3 (#3122) 2023-12-20 16:29:37 -05:00
dependabot[bot]andLaurence Isla 962268fd6b build(deps): bump actions/download-artifact from 3 to 4 (#3121) 2023-12-20 16:29:37 -05:00
Laurence Isla cd38da56d5 bump version to 12.0.1 2023-12-12 19:20:47 -05:00
steve-chavezandLaurence Isla 229a4e4cd6 fix: any handler sets a default application/json
Now it sets application/octet-stream as the generic type.
2023-12-12 18:46:40 -05:00
Laurence Isla c3f7440e33 fix: add jwt_cache_max_lifetime as an in-database configuration option (#3102) 2023-12-12 18:46:40 -05:00
Laurence Isla 4e03ee252b chore: add new sponsor 2023-12-12 18:46:40 -05:00
Andrei DziahelandLaurence Isla 0ac4d0d0a9 fix: replace json parser error with generic msg (#3090) 2023-12-12 18:46:40 -05:00
Andrei DziahelandLaurence Isla ceda77c5de ci: cancel in-progress loadtests for PRs as well 2023-12-12 18:46:40 -05:00
Andrei DziahelandLaurence Isla a6e4a0078b ci: test only head of branch 2023-12-12 18:46:40 -05:00
Laurence Isla 15f4157cc5 changelog: move missplaced unreleased fix (#3094) 2023-12-12 18:46:40 -05:00
Laurence Isla 50ea1999b2 fix: allow using special characters in json keys (#3081)
* increase memory size test
2023-12-12 18:46:40 -05:00
Andrei DziahelandLaurence Isla c92f16a2cb ci: similar jobs use a single cache for Nix stores
Jobs that use the setup-nix action will share a single cache, which is created by a previous warm up Job.
2023-12-12 18:46:40 -05:00
dependabot[bot]andLaurence Isla 1cebc03313 build(deps): bump cachix/cachix-action in /.github/actions/setup-nix (#3085) 2023-12-12 18:46:40 -05:00
steve-chavez 00580bc8cb bump version to 12.0.0 2023-12-01 19:18:07 -05:00
steve-chavez 9fe11249f9 feat: custom SQL handler for the "*/*" media type 2023-11-28 23:14:38 -05:00
Laurence IslaandGitHub 7640de34e2 refactor: use a data type instead of Map for Server Timing 2023-11-28 18:26:56 -05:00
Andrei DziahelandSteve Chavez 31ce39ba36 ci: cabal+GHC: tweak caching
Makes it to cache only relevant directories, adds `dist-newstyle` to prevent needless rebuilding even harder and shortens cache key name by supplying `hashFiles`multiple arguments
2023-11-28 12:48:57 -05:00
Laurence IslaandGitHub ca5eb64deb chore: fix server-timing metric order and doctest 2023-11-28 12:45:39 -05:00
Andrei DziahelandSteve Chavez a72241ad4e ci: fixes job caches not producing after recent cabal upgrade
Recent cabal adopted XDG guidelines and stores data
across multiple directories under $HOME.
Creating ~/.cabal manually returns old behavior and allows caching single directory again.
2023-11-28 11:52:52 -05:00
steve-chavez b080f59bac test: server timing on root and options method 2023-11-28 10:25:25 -05:00
steve-chavez 558e9d40e8 changelog: join server-timing entries 2023-11-28 10:25:25 -05:00
steve-chavez 958339b8d3 chore: change server timing render to response
"render" is a loaded term than might be thought as the generation
of a full HTML page. While for this phase we only process the status
and the headers.

Changing it to "response" so is not misleading at least. Users can check the
docs for clarification.
2023-11-28 10:25:25 -05:00
steve-chavez 3d56f8435d chore: delete dead error codes 2023-11-28 10:25:25 -05:00
Laurence IslaandSteve Chavez dfa875c8c7 fix: do not log internal db errors like 'acquisition timeout' when log-level=crit 2023-11-27 23:06:40 -05:00
Laurence IslaandSteve Chavez 33891e3a73 feat: log all internal database errors to stderr 2023-11-27 23:06:40 -05:00
Laurence IslaandSteve Chavez 8483459d59 chore: add missing --example for server-timing-enabled 2023-11-24 17:41:16 -05:00
Laurence IslaandSteve Chavez b538ab9823 feat: add timing for the api request parse 2023-11-24 16:29:25 -05:00
Laurence IslaandSteve Chavez 850b15fe13 fix: change timing name from 'query' to 'transaction' 2023-11-24 16:29:25 -05:00
Andrei DziahelandGitHub df97a5071f Display an actual TCP port app is bound to (#3034) 2023-11-23 18:17:16 -05:00
Tim AbdullaandGitHub 1c60b50e2e Add aggregate functions (#2925)
The aggregate functions SUM(), MAX(), MIN(), AVG(), and COUNT() are now supported.
2023-11-23 14:03:03 -05:00
Andrei DziahelandGitHub c3301a1653 feat: implement server-timing-enabled config parameter (#3064) 2023-11-22 17:57:50 -05:00
Andrei DziahelandGitHub abcb21c69a ci: cache Nix store with cache-nix-action (#2992)
* ci: try nix-community/cache-nix-action

* ci: add cache-id param to setup-nix action

* ci: tidy up cache keys for non-nix jobs

* ci: merge-nix-caches-linux job

* ci: merge caches other way around

* ci: reduce number of caches

Should prevent disk overflow

* ci: change cache id prefix for merge

* ci: comment out cache merging job

* ci: revert cache id prefix

* ci: setup-nix: use latest cache-nix-action

Among others, makes action logs look more tidy (see https://github.com/nix-community/cache-nix-action/commit/17d19d3d8be918757589635bc1a6830be0b129d2)

* ci: use test-pg cache key for loadtest
2023-11-21 18:56:23 -05:00
steve-chavez f7bf2157f3 feat: apply super settings on impersonated roles
If they have GRANT SET ON PARAMETER <setting> TO authenticator
2023-11-21 10:54:56 -05:00
Taimoor ZaeemandGitHub 125f10a60f feat: add statement_timeout set on functions (#3056) 2023-11-17 12:36:51 -05:00
Taimoor ZaeemandGitHub 3c1a7f2641 feat: add timezone in Prefer header (#3024)
* increase reloading timeout in io-tests
* increase memory test by 1M
2023-11-13 14:16:27 -05:00
omahsandGitHub f10d4139fe nix: fix typos in README 2023-11-08 11:43:42 -05:00
Laurence IslaandSteve Chavez 99b705d44f chore: remove former sponsors 2023-11-07 16:25:47 -05:00
Laurence IslaandGitHub 379d9d6298 chore: add Neon as sponsor 2023-11-06 20:02:58 -05:00
Laurence IslaandSteve Chavez 2c35257eec chore: add prefix info to the PR template 2023-11-03 19:42:06 -05:00
steve-chavez ed90774f53 test: query the empty string 2023-11-03 12:58:54 -05:00
Laurence Isla 96ca177c31 transaction-scoped settings are now shown clearly in the postgres logs 2023-11-02 15:50:22 -05:00
Laurence Isla 226400a5bc break:remove the db-use-legacy-gucs config
BREAKING CHANGE

All PostgreSQL versions will use JSON GUCs for headers, cookies and JWT claims.
2023-11-02 15:50:22 -05:00
Laurence IslaandGitHub b235227119 allow all origins when server-cors-allowed-origins is an empty string 2023-10-27 21:30:56 -05:00
Taimoor ZaeemandGitHub 5c9c7f4ff4 fix: HTTP status responses for upserts
* PUT returns 201 instead of 200 when rows are inserted
* POST with "Prefer: resolution=merge-duplicates" returns 200 instead of 201 when no rows are inserted
2023-10-26 20:49:20 -05:00
steve-chavez 82b38341bf feat: sql handlers for custom media types
* test text/html and drop HtmlRawOutputSpec.hs
* all tests passing, removed all pendingWith
* make functions compatible with pg <= 12
* move custom media types tests to own spec
* anyelement aggregate
* apply aggregates without a final function
* overriding works
* overriding anyelement with particular agg
* cannot override vendored media types
* plan spec works with custom aggregate
* renamed media types to make clear which ones are overridable
* correct content negotiation with same weight
* text/tab-separated-values media type
* text/csv with BOM plus content-disposition header
2023-10-26 09:04:02 -05:00
steve-chavez 4a90e9fbd9 break: db-root-spec default app/openapi+json media
BREAKING CHANGE

Can be done later with custom media types
2023-10-26 09:04:02 -05:00
steve-chavez 14d030b96c break: remove binary field logic, raw-media-types
BREAKING CHANGE

Can be done later with custom media types
2023-10-26 09:04:02 -05:00
Wolfgang Walther 6920a88dc0 nix: Fix postgrest-release on rel-branches pushing to the right branch 2023-10-25 19:09:21 +02:00
Wolfgang Walther 966f8df33f changelog: update to 11.2.2 2023-10-25 19:08:04 +02:00
Wolfgang Walther 98bd1de1cd nix: Allow running postgrest-release on rel- branches 2023-10-25 16:29:03 +02:00
Taimoor ZaeemandGitHub d94286c185 feat: add config to specify CORS origins (#2986) 2023-10-24 09:38:25 -05:00
Taimoor ZaeemandGitHub 618f93dec1 test: fix typos in batch upsert tests 2023-10-21 16:29:43 -05:00
Laurence IslaandGitHub 317619bf62 fix: regression by reverting fix that returned 206 when first-pos=length in Range header 2023-10-21 16:28:08 -05:00
steve-chavez eb238ad678 test: DRY scache/config sleep in io tests
Also increase schema cache sleep to 0.2 as 0.1 tends to fail in CI.
2023-10-20 17:53:54 -05:00
steve-chavez 54786a6c04 fix: unnecessary count() on RPC returning single 2023-10-19 15:54:22 -05:00
steve-chavez 00f3cb3746 test: prove RPC returning single always gives 1 2023-10-19 15:54:22 -05:00
Andrei DziahelandGitHub e977032847 build: add cabal.project.freeze (#3004) 2023-10-19 09:26:46 -05:00
Taimoor ZaeemandGitHub f10b4c3268 test: add tests for batch upserts (#3005) 2023-10-13 09:47:26 -03:00
Andrei DziahelandGitHub dc01c748ae feat: add more perf counters to Server-Timing (#2983) 2023-10-12 09:55:30 -03:00
Laurence IslaandGitHub 056c748c5f refactor: DRY and enforce error format 2023-10-10 11:42:36 -05:00
Kam Ting HoiandGitHub 818387f24e fix: range request with 0 rows and 0 offset return status 416 (#2991) 2023-10-07 00:10:17 -03:00
steve-chavez a9d6c318fd nix: instructions for static binary 2023-10-06 19:54:05 -03:00
steve-chavez 2aa58164ab refactor: move errors to ApiRequestError 2023-10-06 01:16:45 -03:00
steve-chavez df08d7f3ff refactor: rename JSONParseError to PGRSTParseError
The error name is too generic otherwise.
2023-10-06 01:16:45 -03:00
steve-chavez 3dd292be46 refactor: DRY application/json on Error.hs
Small refactor to help with:

https://github.com/PostgREST/postgrest/issues/2901
2023-10-06 01:16:45 -03:00
steve-chavez 0703f27d2b changelog: update to 11.2.1 2023-10-04 12:12:30 -03:00
Laurence IslaandGitHub af0e369c65 fix: regression that rejects URI connection strings with certain unescaped characters in the password 2023-10-03 10:57:21 -05:00
Laurence IslaandGitHub 910950dbac fix: RPCs not embedding correctly when using overloaded functions for computed relationships 2023-10-02 16:00:33 -05:00
Andrei DziahelandSteve Chavez 4c44782d15 refactor: complete purifying Response module 2023-09-30 09:35:50 -03:00
steve-chavez 3b1eb51744 ci: fix MacOS CI 2023-09-29 19:50:15 -03:00
steve-chavez cf7ee67dfe fix: arrow filter on RPC returning TABLE+composite 2023-09-28 20:26:38 -03:00
steve-chavez 290d90609b fix: unnecessary set default_transaction_isolation 2023-09-27 17:48:48 -03:00
Taimoor ZaeemandSteve Chavez 3c1cdd434a feat: add handling=strict/lenient for Prefer header 2023-09-27 15:00:52 -03:00
steve-chavez 2825ac059e refactor: make Response module pure 2023-09-27 11:50:22 -03:00
Taimoor ZaeemandGitHub a6e3eda5b2 feat: implement JWT caching (#2928) 2023-09-25 14:46:55 -03:00
Laurence IslaandSteve Chavez 90e3a5e29f Add test option for PostgreSQL 16 2023-09-21 15:38:46 -03:00
Andrei DziahelandSteve Chavez add4dfeed5 ci: loadtest PRs against latest releases
Leveraging the previously-improved postgrest-loadtest-against
introduces loadtesting PRS against latest release as well.
2023-09-21 14:08:53 -03:00
Andrei DziahelandSteve Chavez fca039a54d nix:postgrest-loadtest-against multiple branches
Improve postgrest-loadtest-against script to accept multiple refs
to perform loadtests against *all* of them
2023-09-21 14:08:53 -03:00
Laurence IslaandSteve Chavez 1b4dae5a7a Add missing changelog entries 2023-09-19 00:15:48 -03:00
Laurence IslaandGitHub 37a3f818dd fix: error when requesting "Prefer: count=<type>" with null filters on embedded resources 2023-09-16 15:02:59 -05:00
Andrei DziahelandSteve Chavez c3169b7dc2 ci: less strict conditions for loadtest inmain 2023-09-15 16:35:20 -03:00
Taimoor ZaeemandSteve Chavez d64b71cbf0 refactor: rename test module NullsStrip.hs to NullsStripSpec.hs 2023-09-15 14:39:12 -03:00
Taimoor ZaeemandSteve Chavez c195eece65 feat: add Server-Timing header with JWT duration 2023-09-15 14:39:12 -03:00
Laurence IslaandGitHub fa182c216e fix: bug when Null Filtering on embedded resources
When doing Null Filtering, the to-one embed resources were not included if they had a NULL value in any of the selected fields.
2023-09-14 21:30:48 -05:00
Andrei DziahelandSteve Chavez 30474c424c ci: loadtest commits to main against latest release
Introduces loadtest job for main branch
that runs loadtest against latest release
2023-09-14 17:10:48 -03:00
Laurence IslaandGitHub 2888f351d1 Remove former sponsor 2023-09-12 22:41:39 -05:00
steve-chavez 8d3c9f8435 refactor: move media type logic to Plan module 2023-09-12 19:24:26 -03:00
steve-chavez d8a91453d1 fix: inconsistent Preference-Applied
* Don't apply `tx=commit` if the transaction doesn't commit
* Apply `count=exact`
* Also simplifies the Preference-Applied logic, removing the need for
  some functions.
2023-09-12 13:52:49 -03:00
dependabot[bot]andGitHub 3f5e840baf build(deps): bump cachix/install-nix-action (#2935) 2023-09-11 03:00:29 -03:00
dependabot[bot]andGitHub 2358b6670f build(deps): bump actions/checkout from 3 to 4 (#2936) 2023-09-11 02:34:35 -03:00
Taimoor ZaeemandSteve Chavez 8eed576826 fix: range request with first position same as length return status 206 2023-09-04 15:50:00 -03:00
Taimoor ZaeemandGitHub 07fef25591 feat: allow full response control when raising exceptions 2023-09-01 14:02:03 -05:00
Taimoor ZaeemandSteve Chavez 7dc6e2b899 fix: duplicate headers in response 2023-08-25 13:52:50 -05:00
Taimoor ZaeemandGitHub 57fa2719dd feat: add db-pool-automatic-recovery configuration to disable connection retrying 2023-08-23 21:08:04 -05:00
Diogo BiazusandGitHub b8b3145c5c fix: schema cache and configuration reloading with NOTIFY not working on Windows 2023-08-21 10:25:44 -05:00
steve-chavez 531a183b44 nix: postgrest-coverage notice 2023-08-17 13:43:48 -05:00
steve-chavez 739f056b0a nix: add postgrest-repl command 2023-08-17 13:43:48 -05:00
Steve Chavez 32b77cae6c Revert "nix: Update nixpkgs to 2023-08-04"
This reverts commit 2434724edd.
2023-08-15 23:55:35 -05:00
Taimoor ZaeemandSteve Chavez 2434724edd nix: Update nixpkgs to 2023-08-04 2023-08-14 15:00:20 -05:00
Taimoor ZaeemandSteve Chavez 87d6a0d0fe fix: application/vnd.pgrst.array not accepted as a valid mediatype 2023-08-11 12:41:04 -05:00
109 changed files with 4501 additions and 1811 deletions
+12
View File
@@ -3,4 +3,16 @@ When submitting a new feature or fix:
- Add a new entry to the CHANGELOG - https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md#unreleased - Add a new entry to the CHANGELOG - https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md#unreleased
- If relevant, update the docs - https://github.com/PostgREST/postgrest-docs - If relevant, update the docs - https://github.com/PostgREST/postgrest-docs
- Use a prefix for the PR title or commits, e.g. "fix: description of the fix".
+ `fix`, bug fixes
+ `feat`, new features added
+ `perf`, performance improvements
+ `nix`, related to the Nix development environment
+ `ci`, related to the Continuous Integration modules
+ `test`, related to the testing modules
+ `refactor`, refactoring code
+ `deprecate`, deprecating a feature
+ `chore`, maintenance (changelog, build process, etc.)
+ Other prefixes may be used if necessary
- If there's a breaking change, add `BREAKING CHANGE` and an explanation to your commit message
--> -->
+12 -3
View File
@@ -7,14 +7,23 @@ inputs:
description: Token to pass to cachix description: Token to pass to cachix
tools: tools:
description: Tools to install with nix-env -iA <tools> description: Tools to install with nix-env -iA <tools>
cache-id:
description: Cache id to use for cache-nix-action
default: "default"
runs: runs:
using: composite using: composite
steps: steps:
- uses: cachix/install-nix-action@v22 - uses: nixbuild/nix-quick-install-action@v26
with: with:
install_url: https://releases.nixos.org/nix/nix-2.13.3/install nix_version: '2.13.6'
- uses: cachix/cachix-action@v12 - name: Restore and cache Nix store
uses: nix-community/cache-nix-action@v4.0.3
with:
key: cache-nix-${{ runner.os }}-id-${{ inputs.cache-id }}-${{ hashFiles('nix/**/*.nix', '.github/actions/setup-nix/*') }}
restore-keys: |
cache-nix-${{ runner.os }}-common-
- uses: cachix/cachix-action@v13
with: with:
name: postgrest name: postgrest
authToken: ${{ inputs.authToken }} authToken: ${{ inputs.authToken }}
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
name: Seed ${{ matrix.os }} name: Seed ${{ matrix.os }}
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
+66 -31
View File
@@ -11,17 +11,39 @@ on:
branches: branches:
- main - main
- rel-* - rel-*
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# Terminate all previous runs of the same workflow and branch/tag, except for main and release branches/tags
cancel-in-progress: "${{ !(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/rel-')) }}"
jobs: jobs:
Prepopulate-Nix-Cache-Linux:
name: Prepopulate Nix cache for Linux runners
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Nix Environment
uses: ./.github/actions/setup-nix
with:
cache-id: common
- name: Put all tools to store to be cached afterwards
run: |
# shellcheck disable=SC2046
nix-store -v --realize $( nix-instantiate default.nix )
shell: bash
Lint-Style: Lint-Style:
name: Lint & check code style name: Lint & check code style
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Prepopulate-Nix-Cache-Linux]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
tools: style tools: style
cache-id: common
- name: Run linter (check locally with `nix-shell --run postgrest-lint`) - name: Run linter (check locally with `nix-shell --run postgrest-lint`)
run: postgrest-lint run: postgrest-lint
- name: Run style check (auto-format with `nix-shell --run postgrest-style`) - name: Run style check (auto-format with `nix-shell --run postgrest-style`)
@@ -31,17 +53,19 @@ jobs:
Test-Nix: Test-Nix:
name: Test (Nix) name: Test (Nix)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Prepopulate-Nix-Cache-Linux]
defaults: defaults:
run: run:
# Hack for enabling color output, see: # Hack for enabling color output, see:
# https://github.com/actions/runner/issues/241#issuecomment-842566950 # https://github.com/actions/runner/issues/241#issuecomment-842566950
shell: script -qec "bash --noprofile --norc -eo pipefail {0}" shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
tools: tests tools: tests
cache-id: common
- name: Run coverage (IO tests and Spec tests against PostgreSQL 15) - name: Run coverage (IO tests and Spec tests against PostgreSQL 15)
run: postgrest-coverage run: postgrest-coverage
@@ -63,20 +87,24 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
pgVersion: [9.6, 10, 11, 12, 13, 14, 15] pgVersion: [9.6, 10, 11, 12, 13, 14, 15, 16]
name: Test PG ${{ matrix.pgVersion }} (Nix) name: Test PG ${{ matrix.pgVersion }} (Nix)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Prepopulate-Nix-Cache-Linux]
defaults: defaults:
run: run:
# Hack for enabling color output, see: # Hack for enabling color output, see:
# https://github.com/actions/runner/issues/241#issuecomment-842566950 # https://github.com/actions/runner/issues/241#issuecomment-842566950
shell: script -qec "bash --noprofile --norc -eo pipefail {0}" shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
tools: tests withTools tools: tests withTools
# It seems like they are installing the same set of derivations, so we can assign them the same cache id.
# This would decrease the amount of caches dowloaded on merge cache step and will prevent disk space issues.
cache-id: common
- name: Run spec tests - name: Run spec tests
if: always() if: always()
@@ -90,12 +118,14 @@ jobs:
Test-Memory-Nix: Test-Memory-Nix:
name: Test memory (Nix) name: Test memory (Nix)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Prepopulate-Nix-Cache-Linux]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
tools: memory tools: memory
cache-id: common
- name: Run memory tests - name: Run memory tests
run: postgrest-test-memory run: postgrest-test-memory
@@ -103,19 +133,21 @@ jobs:
Build-Static-Nix: Build-Static-Nix:
name: Build Linux static (Nix) name: Build Linux static (Nix)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Prepopulate-Nix-Cache-Linux]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
tools: tests tools: tests
cache-id: common
- name: Build static executable - name: Build static executable
run: nix-build -A postgrestStatic run: nix-build -A postgrestStatic
- name: Check static executable - name: Check static executable
run: postgrest-check-static result/bin/postgrest run: postgrest-check-static result/bin/postgrest
- name: Save built executable as artifact - name: Save built executable as artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: postgrest-linux-static-x64 name: postgrest-linux-static-x64
path: result/bin/postgrest path: result/bin/postgrest
@@ -124,18 +156,17 @@ jobs:
- name: Build Docker image - name: Build Docker image
run: nix-build -A docker.image --out-link postgrest-docker.tar.gz run: nix-build -A docker.image --out-link postgrest-docker.tar.gz
- name: Save built Docker image as artifact - name: Save built Docker image as artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: postgrest-docker-x64 name: postgrest-docker-x64
path: postgrest-docker.tar.gz path: postgrest-docker.tar.gz
if-no-files-found: error if-no-files-found: error
Build-Macos-Nix: Build-Macos-Nix:
name: Build MacOS (Nix) name: Build MacOS (Nix)
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
@@ -175,19 +206,19 @@ jobs:
name: Build ${{ matrix.name }} (Stack) name: Build ${{ matrix.name }} (Stack)
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Stack working files cache - name: Stack working files cache
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: ${{ matrix.cache }} path: ${{ matrix.cache }}
key: ${{ runner.os }}-${{ hashFiles('stack.yaml.lock') }} key: cache-stack-${{ runner.os }}-${{ hashFiles('stack.yaml.lock') }}
- name: Install dependencies - name: Install dependencies
if: ${{ matrix.deps }} if: ${{ matrix.deps }}
run: ${{ matrix.deps }} run: ${{ matrix.deps }}
- name: Build with Stack - name: Build with Stack
run: stack build --local-bin-path result --copy-bins run: stack build --local-bin-path result --copy-bins
- name: Save built executable as artifact - name: Save built executable as artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.artifact }} name: ${{ matrix.artifact }}
path: | path: |
@@ -199,7 +230,7 @@ jobs:
name: Get FreeBSD build from CirrusCI name: Get FreeBSD build from CirrusCI
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Get FreeBSD executable from CirrusCI - name: Get FreeBSD executable from CirrusCI
env: env:
# GITHUB_SHA does weird things for pull request, so we roll our own: # GITHUB_SHA does weird things for pull request, so we roll our own:
@@ -207,7 +238,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: .github/get_cirrusci_freebsd run: .github/get_cirrusci_freebsd
- name: Save executable as artifact - name: Save executable as artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: postgrest-freebsd-x64 name: postgrest-freebsd-x64
path: postgrest path: postgrest
@@ -221,7 +252,7 @@ jobs:
name: Build Linux (Cabal, GHC ${{ matrix.ghc }}) name: Build Linux (Cabal, GHC ${{ matrix.ghc }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Workaround runner image issue - name: Workaround runner image issue
# https://github.com/actions/runner-images/issues/7061 # https://github.com/actions/runner-images/issues/7061
run: sudo chown -R "$USER" /usr/local/.ghcup run: sudo chown -R "$USER" /usr/local/.ghcup
@@ -229,16 +260,20 @@ jobs:
run: | run: |
ghcup install ghc ${{ matrix.ghc }} ghcup install ghc ${{ matrix.ghc }}
ghcup set ghc ${{ matrix.ghc }} ghcup set ghc ${{ matrix.ghc }}
- name: Copy cabal.project - name: Copy cabal.project & fix caching
run: | run: |
mkdir ~/.cabal
cp cabal.project.non-nix cabal.project cp cabal.project.non-nix cabal.project
- name: Cache - name: Cache
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: ~/.cabal path: |
key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('**/*.cabal') }}-${{ hashFiles('**/cabal.project') }} ~/.cabal/packages
~/.cabal/store
dist-newstyle
key: cache-cabal-${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('**/*.cabal', '**/cabal.project') }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ matrix.ghc }}- cache-cabal-${{ runner.os }}-${{ matrix.ghc }}-
- name: Install dependencies - name: Install dependencies
run: | run: |
cabal update cabal update
@@ -260,7 +295,7 @@ jobs:
GITHUB_COMMIT: ${{ github.sha }} GITHUB_COMMIT: ${{ github.sha }}
GHC_VERSION: ${{ matrix.ghc }} GHC_VERSION: ${{ matrix.ghc }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- id: Remote-Dir - id: Remote-Dir
name: Unique directory name for the remote build name: Unique directory name for the remote build
run: echo "remotepath=postgrest-build-$(uuidgen)" >> "$GITHUB_OUTPUT" run: echo "remotepath=postgrest-build-$(uuidgen)" >> "$GITHUB_OUTPUT"
@@ -300,7 +335,7 @@ jobs:
- name: Extract downloaded binaries - name: Extract downloaded binaries
run: tar -xvf result.tar.xz && rm result.tar.xz run: tar -xvf result.tar.xz && rm result.tar.xz
- name: Save aarch64 executable as artifact - name: Save aarch64 executable as artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: postgrest-ubuntu-aarch64 name: postgrest-ubuntu-aarch64
path: result/postgrest path: result/postgrest
@@ -324,7 +359,7 @@ jobs:
version: ${{ steps.Identify-Version.outputs.version }} version: ${{ steps.Identify-Version.outputs.version }}
isprerelease: ${{ steps.Identify-Version.outputs.isprerelease }} isprerelease: ${{ steps.Identify-Version.outputs.isprerelease }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- id: Identify-Version - id: Identify-Version
name: Identify the version to be released name: Identify the version to be released
run: | run: |
@@ -361,7 +396,7 @@ jobs:
echo "Relevant extract from CHANGELOG.md:" echo "Relevant extract from CHANGELOG.md:"
cat CHANGES.md cat CHANGES.md
- name: Save CHANGES.md as artifact - name: Save CHANGES.md as artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: release-changes name: release-changes
path: CHANGES.md path: CHANGES.md
@@ -377,9 +412,9 @@ jobs:
env: env:
VERSION: ${{ needs.Prepare-Release.outputs.version }} VERSION: ${{ needs.Prepare-Release.outputs.version }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
path: artifacts path: artifacts
- name: Create release bundle with archives for all builds - name: Create release bundle with archives for all builds
@@ -410,7 +445,7 @@ jobs:
artifacts/postgrest-windows-x64/postgrest.exe artifacts/postgrest-windows-x64/postgrest.exe
- name: Save release bundle - name: Save release bundle
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: release-bundle name: release-bundle
path: release-bundle path: release-bundle
@@ -443,13 +478,13 @@ jobs:
VERSION: ${{ needs.Prepare-Release.outputs.version }} VERSION: ${{ needs.Prepare-Release.outputs.version }}
ISPRERELEASE: ${{ needs.Prepare-Release.outputs.isprerelease }} ISPRERELEASE: ${{ needs.Prepare-Release.outputs.isprerelease }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
tools: release tools: release
- name: Download Docker image - name: Download Docker image
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
name: postgrest-docker-x64 name: postgrest-docker-x64
- name: Publish images on Docker Hub - name: Publish images on Docker Hub
@@ -496,7 +531,7 @@ jobs:
VERSION: ${{ needs.Prepare-Release.outputs.version }} VERSION: ${{ needs.Prepare-Release.outputs.version }}
ISPRERELEASE: ${{ needs.Prepare-Release.outputs.isprerelease }} ISPRERELEASE: ${{ needs.Prepare-Release.outputs.isprerelease }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Publish images for ARM builds on Docker Hub - name: Publish images for ARM builds on Docker Hub
uses: appleboy/ssh-action@master uses: appleboy/ssh-action@master
env: env:
@@ -523,7 +558,7 @@ jobs:
env: env:
REMOTE_DIR: ${{ needs.Build-Cabal-Arm.outputs.remotepath }} REMOTE_DIR: ${{ needs.Build-Cabal-Arm.outputs.remotepath }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Remove uploaded files from server - name: Remove uploaded files from server
uses: appleboy/ssh-action@master uses: appleboy/ssh-action@master
with: with:
+43 -5
View File
@@ -11,24 +11,62 @@ on:
- main - main
jobs: jobs:
Loadtest-Nix: Loadtest-PR-Nix:
name: Loadtest (Nix) name: Loadtest PR (Nix)
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
tools: loadtest tools: loadtest
cache-id: test-loadtest
- uses: actions-ecosystem/action-get-latest-tag@v1
id: get-latest-tag
with:
semver_only: true
- name: Run loadtest - name: Run loadtest
run: | run: |
postgrest-loadtest-against main postgrest-loadtest-against main ${{ steps.get-latest-tag.outputs.tag }}
postgrest-loadtest-report > loadtest/loadtest.md postgrest-loadtest-report > loadtest/loadtest.md
- name: Upload report - name: Upload report
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: loadtest.md name: loadtest.md
path: loadtest/loadtest.md path: loadtest/loadtest.md
if-no-files-found: error if-no-files-found: error
Loadtest-Merge-Nix:
name: Loadtest Merge (Nix)
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions-ecosystem/action-get-latest-tag@v1
id: get-latest-tag
with:
semver_only: true
- name: Setup Nix Environment
uses: ./.github/actions/setup-nix
with:
tools: loadtest
cache-id: test-loadtest
- name: Run loadtest
run: |
postgrest-loadtest-against ${{ steps.get-latest-tag.outputs.tag }}
postgrest-loadtest-report > loadtest/loadtest.md
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: loadtest.md
path: loadtest/loadtest.md
if-no-files-found: error
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps: steps:
- name: Download from Artifacts - name: Download from Artifacts
uses: dawidd6/action-download-artifact@v2 uses: dawidd6/action-download-artifact@v3
with: with:
workflow: ${{ github.event.workflow.name }} workflow: ${{ github.event.workflow.name }}
run_id: ${{github.event.workflow_run.id }} run_id: ${{github.event.workflow_run.id }}
+35 -20
View File
@@ -4,40 +4,40 @@ PostgREST ongoing development is only possible thanks to our Sponsors and Backer
## Sponsors ## Sponsors
<table> <table align="center">
<tbody> <tbody>
<tr> <tr>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://www.cybertec-postgresql.com/en/?utm_source=postgrest.org&utm_medium=referral&utm_campaign=postgrest" target="_blank"> <a href="https://www.cybertec-postgresql.com/en/?utm_source=postgrest.org&utm_medium=referral&utm_campaign=postgrest" target="_blank">
<img width="222px" src="static/cybertec-new.png"> <img width="296px" src="static/cybertec-new.png">
</a> </a>
</td> </td>
<td align="center" valign="middle">
<a href="https://www.2ndquadrant.com/en/?utm_campaign=External%20Websites&utm_source=PostgREST&utm_medium=Logo" target="_blank">
<img width="296px" src="static/2ndquadrant.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/retool.png">
</a>
</td>
</tr>
<tr></tr>
<tr>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank"> <a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/gnuhost.png"> <img width="296px" src="static/gnuhost.png">
</a> </a>
</td> </td>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://supabase.io?utm_source=postgrest%20backers&utm_medium=open%20source%20partner&utm_campaign=postgrest%20backers%20github&utm_term=homepage" target="_blank"> <a href="https://neon.tech/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/supabase.png"> <img width="296px" src="static/neon.jpg">
</a>
</td>
</tr>
<tr></tr>
<tr>
<td align="center" valign="middle">
<a href="https://code.build/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/code-build.png">
</a> </a>
</td> </td>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://oblivious.ai/?utm_source=sponsor&utm_campaign=postgrest" target="_blank"> <a href="https://supabase.io?utm_source=postgrest%20backers&utm_medium=open%20source%20partner&utm_campaign=postgrest%20backers%20github&utm_term=homepage" target="_blank">
<img width="296px" src="static/oblivious.jpg"> <img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/supabase.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://tembo.io/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/tembo.png">
</a> </a>
</td> </td>
</tr> </tr>
@@ -75,7 +75,22 @@ PostgREST ongoing development is only possible thanks to our Sponsors and Backer
<tr> <tr>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://www.timescale.com?utm_campaign=postgrest&utm_source=sponsor&utm_medium=referral&utm_content=github" target="_blank"> <a href="https://www.timescale.com?utm_campaign=postgrest&utm_source=sponsor&utm_medium=referral&utm_content=github" target="_blank">
<img width="222px" src="static/timescaledb.png"> <img width="222px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/timescaledb.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img max-width="222px" height="88" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/retool.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://www.2ndquadrant.com/en/?utm_campaign=External%20Websites&utm_source=PostgREST&utm_medium=Logo" target="_blank">
<img width="222px" src="static/2ndquadrant.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://oblivious.ai/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="222px" src="static/oblivious.jpg">
</a> </a>
</td> </td>
</tr> </tr>
+78
View File
@@ -5,6 +5,84 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased ## Unreleased
## [12.0.2] - 2023-12-20
### Fixed
- #3124, Fix table's media type handlers not working for all schemas - @steve-chavez
- #3126, Fix empty row on media type handler function - @steve-chavez
## [12.0.1] - 2023-12-12
### Fixed
- #3054, Fix not allowing special characters in JSON keys - @laurenceisla
- #2344, Replace JSON parser error with a clearer generic message - @develop7
- #3100, Add missing in-database configuration option for `jwt-cache-max-lifetime` - @laurenceisla
- #3089, The any media type handler now sets `Content-Type: application/octet-stream` by default instead of `Content-Type: application/json` - @steve-chavez
## [12.0.0] - 2023-12-01
### Added
- #1614, Add `db-pool-automatic-recovery` configuration to disable connection retrying - @taimoorzaeem
- #2492, Allow full response control when raising exceptions - @taimoorzaeem, @laurenceisla
- #2771, #2983, #3062, #3055 Add `Server-Timing` response header - @taimoorzaeem, @develop7, @laurenceisla
- #2698, Add config `jwt-cache-max-lifetime` and implement JWT caching - @taimoorzaeem
- #2943, Add `handling=strict/lenient` for Prefer header - @taimoorzaeem
- #2441, Add config `server-cors-allowed-origins` to specify CORS origins - @taimoorzaeem
- #2825, SQL handlers for custom media types - @steve-chavez
+ Solves #1548, #2699, #2763, #2170, #1462, #1102, #1374, #2901
- #2799, Add timezone in Prefer header - @taimoorzaeem
- #3001, Add `statement_timeout` set on functions - @taimoorzaeem
- #3045, Apply superuser settings on impersonated roles if they have PostgreSQL 15 `GRANT SET ON PARAMETER` privilege - @steve-chavez
- #915, Add support for aggregate functions - @timabdulla
+ The aggregate functions SUM(), MAX(), MIN(), AVG(), and COUNT() are now supported.
+ It's disabled by default, you can enable it with `db-aggregates-enabled`.
- #3057, Log all internal database errors to stderr - @laurenceisla
### Fixed
- #3015, Fix unnecessary count() on RPC returning single - @steve-chavez
- #1070, Fix HTTP status responses for upserts - @taimoorzaeem
+ `PUT` returns `201` instead of `200` when rows are inserted
+ `POST` with `Prefer: resolution=merge-duplicates` returns `200` instead of `201` when no rows are inserted
- #3019, Transaction-Scoped Settings are now shown clearly in the Postgres logs - @laurenceisla
+ Shows `set_config('pgrst.setting_name', $1)` instead of `setconfig($1, $2)`
+ Does not apply to role settings and `app.settings.*`
- #2420, Fix bogus message when listening on port 0 - @develop7
- #3067, Fix Acquision Timeout errors logging to stderr when `log-level=crit` - @laurenceisla
### Changed
- Removed [raw-media-types config](https://postgrest.org/en/v11.1/references/configuration.html#raw-media-types) - @steve-chavez
- Removed `application/octet-stream`, `text/plain`, `text/xml` [builtin support for scalar results](https://postgrest.org/en/v11.1/references/api/resource_representation.html#scalar-function-response-format) - @steve-chavez
- Removed default `application/openapi+json` media type for [db-root-spec](https://postgrest.org/en/v11.1/references/configuration.html#db-root-spec) - @steve-chavez
- Removed [db-use-legacy-gucs](https://postgrest.org/en/v11.2/references/configuration.html#db-use-legacy-gucs) - @laurenceisla
## [11.2.2] - 2023-10-25
### Fixed
- #2824, Fix regression by reverting fix that returned 206 when first position = length in a `Range` header - @laurenceisla, @strengthless
## [11.2.1] - 2023-10-03
### Fixed
- #2899, Fix `application/vnd.pgrst.array` not accepted as a valid mediatype - @taimoorzaeem
- #2524, Fix schema cache and configuration reloading with `NOTIFY` not working on Windows - @diogob, @laurenceisla
- #2915, Fix duplicate headers in response - @taimoorzaeem
- #2824, Fix range request with first position same as length return status 206 - @taimoorzaeem
- #2939, Fix wrong `Preference-Applied` with `Prefer: tx=commit` when transaction is rollbacked - @steve-chavez
- #2939, Fix `count=exact` not being included in `Preference-Applied` - @steve-chavez
- #2800, Fix not including to-one embed resources that had a `NULL` value in any of the selected fields when doing null filtering on them - @laurenceisla
- #2846, Fix error when requesting `Prefer: count=<type>` and doing null filtering on embedded resources - @laurenceisla
- #2959, Fix setting `default_transaction_isolation` unnecessarily - @steve-chavez
- #2929, Fix arrow filtering on RPC returning dynamic TABLE with composite type - @steve-chavez
- #2963, Fix RPCs not embedding correctly when using overloaded functions for computed relationships - @laurenceisla
- #2970, Fix regression that rejects URI connection strings with certain unescaped characters in the password - @laurenceisla, @steve-chavez
## [11.2.0] - 2023-08-10 ## [11.2.0] - 2023-08-10
### Added ### Added
+19 -19
View File
@@ -15,40 +15,40 @@ API than you are likely to write from scratch.
## Sponsors ## Sponsors
<table> <table align="center">
<tbody> <tbody>
<tr> <tr>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://www.cybertec-postgresql.com/en/?utm_source=postgrest.org&utm_medium=referral&utm_campaign=postgrest" target="_blank"> <a href="https://www.cybertec-postgresql.com/en/?utm_source=postgrest.org&utm_medium=referral&utm_campaign=postgrest" target="_blank">
<img width="222px" src="static/cybertec-new.png"> <img width="296px" src="static/cybertec-new.png">
</a> </a>
</td> </td>
<td align="center" valign="middle">
<a href="https://www.2ndquadrant.com/en/?utm_campaign=External%20Websites&utm_source=PostgREST&utm_medium=Logo" target="_blank">
<img width="296px" src="static/2ndquadrant.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/retool.png">
</a>
</td>
</tr>
<tr></tr>
<tr>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank"> <a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/gnuhost.png"> <img width="296px" src="static/gnuhost.png">
</a> </a>
</td> </td>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://supabase.io?utm_source=postgrest%20backers&utm_medium=open%20source%20partner&utm_campaign=postgrest%20backers%20github&utm_term=homepage" target="_blank"> <a href="https://neon.tech/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/supabase.png"> <img width="296px" src="static/neon.jpg">
</a>
</td>
</tr>
<tr></tr>
<tr>
<td align="center" valign="middle">
<a href="https://code.build/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/code-build.png">
</a> </a>
</td> </td>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://oblivious.ai/?utm_source=sponsor&utm_campaign=postgrest" target="_blank"> <a href="https://supabase.io?utm_source=postgrest%20backers&utm_medium=open%20source%20partner&utm_campaign=postgrest%20backers%20github&utm_term=homepage" target="_blank">
<img width="296px" src="static/oblivious.jpg"> <img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/supabase.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://tembo.io/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="static/tembo.png">
</a> </a>
</td> </td>
</tr> </tr>
+1
View File
@@ -0,0 +1 @@
index-state: hackage.haskell.org 2023-10-13T13:54:33Z
+13
View File
@@ -50,6 +50,19 @@ let
postgresqlVersions = postgresqlVersions =
[ [
{
name = "postgresql-16";
postgresql = pkgs.postgresql_16.withPackages (p: [
p.postgis
(p.pg_safeupdate.overrideAttrs (old: {
installPhase = ''
mkdir -p $out/bin
cp safeupdate.dylib safeupdate.so || true
install -D safeupdate.so -t $out/lib
'';
}))
]);
}
{ name = "postgresql-15"; postgresql = pkgs.postgresql_15.withPackages (p: [ p.postgis p.pg_safeupdate ]); } { name = "postgresql-15"; postgresql = pkgs.postgresql_15.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-14"; postgresql = pkgs.postgresql_14.withPackages (p: [ p.postgis p.pg_safeupdate ]); } { name = "postgresql-14"; postgresql = pkgs.postgresql_14.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-13"; postgresql = pkgs.postgresql_13.withPackages (p: [ p.postgis p.pg_safeupdate ]); } { name = "postgresql-13"; postgresql = pkgs.postgresql_13.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
+1 -22
View File
@@ -1,37 +1,16 @@
{-# LANGUAGE CPP #-}
module Main (main) where module Main (main) where
import System.IO (BufferMode (..), hSetBuffering) import System.IO (BufferMode (..), hSetBuffering)
import qualified PostgREST.App as App
import qualified PostgREST.CLI as CLI import qualified PostgREST.CLI as CLI
import Protolude import Protolude
#ifndef mingw32_HOST_OS
import qualified PostgREST.Unix as Unix
#endif
main :: IO () main :: IO ()
main = do main = do
setBuffering setBuffering
opts <- CLI.readCLIShowHelp opts <- CLI.readCLIShowHelp
CLI.main installSignalHandlers runAppInSocket opts CLI.main opts
installSignalHandlers :: App.SignalHandlerInstaller
#ifndef mingw32_HOST_OS
installSignalHandlers = Unix.installSignalHandlers
#else
installSignalHandlers _ = pass
#endif
runAppInSocket :: Maybe App.SocketRunner
#ifndef mingw32_HOST_OS
runAppInSocket = Just Unix.runAppWithSocket
#else
runAppInSocket = Nothing
#endif
setBuffering :: IO () setBuffering :: IO ()
setBuffering = do setBuffering = do
+34 -9
View File
@@ -22,6 +22,15 @@ build the `postgrestPackage` attribute from the Nix expression it finds in our
`default.nix` (see below for details). Nix will take care of getting the right `default.nix` (see below for details). Nix will take care of getting the right
GHC version and all the build dependencies. GHC version and all the build dependencies.
You can also build a statically linked binary with:
```bash
$ nix-build --attr postgrestStatic
$ ldd result/bin/postgrest
$ not a dynamic executable
```
## Binary cache ## Binary cache
We recommend that you use the PostgREST binary cache on We recommend that you use the PostgREST binary cache on
@@ -82,7 +91,7 @@ Some additional modules like `memory`, `docker` and `release`
have large dependencies that would need to be built before the shell becomes have large dependencies that would need to be built before the shell becomes
available, which could take an especially long time if the cachix binary cache available, which could take an especially long time if the cachix binary cache
is not used. You can activate those by passing a flag to `nix-shell` with is not used. You can activate those by passing a flag to `nix-shell` with
`nix-shell --arg <module> true`. This will make the respective utilites available: `nix-shell --arg <module> true`. This will make the respective utilities available:
```bash ```bash
$ nix-shell --arg memory true $ nix-shell --arg memory true
@@ -104,7 +113,7 @@ postgrest-test-memory
Note that `postgrest-test-memory` is now also available. Note that `postgrest-test-memory` is now also available.
To run one-off commands, you can also use `nix-shell --run <command>`, which To run one-off commands, you can also use `nix-shell --run <command>`, which
will lauch the Nix shell, run that one command and exit. Note that the tab will launch the Nix shell, run that one command and exit. Note that the tab
completion will not work with `nix-shell --run`, as Nix has yet to evaluate completion will not work with `nix-shell --run`, as Nix has yet to evaluate
our Nix expressions to see which utilities are available. our Nix expressions to see which utilities are available.
@@ -212,15 +221,14 @@ doctests for some of our modules are also available:
## Code coverage ## Code coverage
Code coverage is available under the `postgrest-coverage` command. This will produce a `./coverage` directory that can be visualized with a simple http server. Code coverage is available under the `postgrest-coverage` command. This will produce a `./coverage` directory that can be visualized on a browser.
```bash ```bash
# Will run all the tests and produce a coverage dir # Will run all the tests and produce a coverage dir
[nix-shell]$ postgrest-coverage [nix-shell]$ postgrest-coverage
...
# Visualize the output postgrest-coverage: To see the results, visit file://$(pwd)/coverage/check/hpc_index.html
[nix-shell]$ cd coverage
[nix-shell]$ python -mSimpleHTTPServer 8080
``` ```
## Linting and styling code ## Linting and styling code
@@ -238,11 +246,11 @@ $ nix-shell --run postgrest-style
``` ```
There is also `postgrest-style-check` that exits with a non-zero exit code if There is also `postgrest-style-check` that exits with a non-zero exit code if
the check resulted in any uncommited changes. It's mostly useful for CI. the check resulted in any uncommitted changes. It's mostly useful for CI.
## General development tools ## General development tools
Tools like `postgrest-build`, `postgrest-run` etc. are simple wrappers around Tools like `postgrest-build`, `postgrest-run`, `postgrest-repl` etc. are simple wrappers around
`cabal` and should do what you expect. `postgrest-check` runs most checks that will `cabal` and should do what you expect. `postgrest-check` runs most checks that will
also run in CI, with the exception of the IO and Memory checks that need to be run also run in CI, with the exception of the IO and Memory checks that need to be run
separately. separately.
@@ -256,6 +264,23 @@ run against the latest PostgreSQL version by default.
file is changed. For example, `postgrest-watch postgrest-with-all postgrest-test-spec` file is changed. For example, `postgrest-watch postgrest-with-all postgrest-test-spec`
will re-run the full spec test suite against all PostgreSQL versions on every change. will re-run the full spec test suite against all PostgreSQL versions on every change.
## REPL
You can use `postgrest-repl` to manually inspect the PostgREST modules.
```bash
$ postgrest-repl
ghci> import PostgREST.<tab>
PostgREST.Admin PostgREST.Config.Database PostgREST.Plan.MutatePlan PostgREST.Response.OpenAPI
PostgREST.ApiRequest PostgREST.Config.JSPath PostgREST.Plan.ReadPlan PostgREST.SchemaCache
...
ghci> import PostgREST.MediaType
ghci> decodeMediaType "application/json"
MTApplicationJSON
```
## Tour ## Tour
The following is not required for working on PostgREST with Nix, but it will The following is not required for working on PostgREST with Nix, but it will
@@ -284,7 +309,7 @@ version.
### `shell.nix` ### `shell.nix`
[`shell.nix`](../shell.nix) defines an environment in which PostgREST can be [`shell.nix`](../shell.nix) defines an environment in which PostgREST can be
built and developed. It extends the build enviroment from our `postgrest` built and developed. It extends the build environment from our `postgrest`
attribute with useful utilities that will be put on the PATH in `nix-shell`. attribute with useful utilities that will be put on the PATH in `nix-shell`.
### `nix/overlays` ### `nix/overlays`
+1 -1
View File
@@ -74,7 +74,7 @@ required to avoid build timeouts in CI.
You'll need to set the `CACHIX_SIGNING_KEY` before proceeding, e.g. by creating You'll need to set the `CACHIX_SIGNING_KEY` before proceeding, e.g. by creating
a file containing `export CACHIX_SIGNING_KEY=...` and sourcing that file, which a file containing `export CACHIX_SIGNING_KEY=...` and sourcing that file, which
avoids having the secret in you shell history. avoids having the secret in your shell history.
To push all new artifacts to Cachix, run: To push all new artifacts to Cachix, run:
+2 -2
View File
@@ -43,8 +43,8 @@ let
(prev.callHackageDirect (prev.callHackageDirect
{ {
pkg = "hasql-notifications"; pkg = "hasql-notifications";
ver = "0.2.0.5"; ver = "0.2.0.6";
sha256 = "sha256-KV36zs/RTgJh/oBBPZaNiQshusmakWnjYTcdoNa4JLA="; sha256 = "sha256-7PyFlB2B70njudOjaX6tk1m77ol9vnF5fI0LF86kVAI=";
} }
{ }); { });
+12 -12
View File
@@ -4,16 +4,16 @@ self: super:
{ {
## Example for including a postgresql version from a specific nixpks commit: ## Example for including a postgresql version from a specific nixpks commit:
## ##
# postgresql_14 = postgresql_16 =
# let let
# rev = "76b1e16c6659ccef7187ca69b287525fea133244"; rev = "5148520bfab61f99fd25fb9ff7bfbb50dad3c9db";
# tarballHash = "1vsahpcx80k2bgslspb0sa6j4bmhdx77sw6la455drqcrqhdqj6a"; tarballHash = "1dfjmz65h8z4lk845724vypzmf3dbgsdndjpj8ydlhx6c7rpcq3p";
#
# pinnedPkgs = pinnedPkgs =
# builtins.fetchTarball { builtins.fetchTarball {
# url = "https://github.com/nixos/nixpkgs/archive/${rev}.tar.gz"; url = "https://github.com/nixos/nixpkgs/archive/${rev}.tar.gz";
# sha256 = tarballHash; sha256 = tarballHash;
# }; };
# in in
# (import pinnedPkgs { }).pkgs.postgresql_14; (import pinnedPkgs { }).pkgs.postgresql_16;
} }
+13
View File
@@ -57,6 +57,18 @@ let
postgrest "''${_arg_leftovers[@]}" postgrest "''${_arg_leftovers[@]}"
''; '';
repl =
checkedShellScript
{
name = "postgrest-repl";
docs = "Interact with PostgREST modules using the cabal repl";
args = [ "ARG_LEFTOVERS([cabal v2-repl arguments])" ];
inRootDir = true;
withEnv = postgrest.env;
}
''
exec ${cabal-install}/bin/cabal v2-repl "''${_arg_leftovers[@]}"
'';
in in
buildToolbox buildToolbox
{ {
@@ -65,5 +77,6 @@ buildToolbox
build build
clean clean
run run
repl
]; ];
} }
+11 -8
View File
@@ -76,13 +76,12 @@ let
inherit name; inherit name;
docs = docs =
'' ''
Run the vegeta loadtest twice: Run the vegeta loadtest against every target branch and HEAD:
- once on the <target> branch - once on the every <target-#> branch
- once in the current worktree - once in the current worktree
''; '';
args = [ args = [
"ARG_POSITIONAL_SINGLE([target], [Commit-ish reference to compare with])" "ARG_POSITIONAL_INF([target], [Commit-ish reference to compare with], 1)"
"ARG_LEFTOVERS([additional vegeta arguments])"
]; ];
positionalCompletion = positionalCompletion =
'' ''
@@ -93,9 +92,11 @@ let
inRootDir = true; inRootDir = true;
} }
'' ''
for tgt in "''${_arg_target[@]}"; do
cat << EOF cat << EOF
Running loadtest on "$_arg_target"... Running loadtest on "$tgt"...
EOF EOF
@@ -104,21 +105,23 @@ let
# Save the results in the current working tree, too, # Save the results in the current working tree, too,
# otherwise they'd be lost in the temporary working tree # otherwise they'd be lost in the temporary working tree
# created by withTools.withGit. # created by withTools.withGit.
${withTools.withGit} "$_arg_target" ${loadtest} --output "$PWD/loadtest/$_arg_target.bin" --testdir "$PWD/test/load" "''${_arg_leftovers[@]}" ${withTools.withGit} "$tgt" ${loadtest} --output "$PWD/loadtest/$tgt.bin" --testdir "$PWD/test/load"
cat << EOF cat << EOF
Done running on "$_arg_target". Done running on "$tgt".
EOF EOF
done
cat << EOF cat << EOF
Running loadtest on HEAD... Running loadtest on HEAD...
EOF EOF
${loadtest} --output "$PWD/loadtest/head.bin" --testdir "$PWD/test/load" "''${_arg_leftovers[@]}" ${loadtest} --output "$PWD/loadtest/head.bin" --testdir "$PWD/test/load"
cat << EOF cat << EOF
+3 -3
View File
@@ -56,8 +56,8 @@ let
inRootDir = true; inRootDir = true;
} }
'' ''
trap "echo You need to be on the main branch to proceed. Exiting ..." ERR trap "echo You need to be on the main branch or a release branch to proceed. Exiting ..." ERR
[ "$(git rev-parse --abbrev-ref HEAD)" == "main" ] [[ "$(git rev-parse --abbrev-ref HEAD)" =~ ^main$|^rel- ]]
trap "" ERR trap "" ERR
trap "echo You have uncommitted changes in postgrest.cabal. Exiting ..." ERR trap "echo You have uncommitted changes in postgrest.cabal. Exiting ..." ERR
@@ -111,7 +111,7 @@ let
remote="$(git remote -v | grep PostgREST/postgrest | grep push | cut -f1)" remote="$(git remote -v | grep PostgREST/postgrest | grep push | cut -f1)"
trap "" ERR trap "" ERR
push="git push --atomic $remote main v$new_version" push="git push --atomic $remote $(git rev-parse --abbrev-ref HEAD) v$new_version"
echo "To push both the branch and the new tag, the following will be run:" echo "To push both the branch and the new tag, the following will be run:"
echo echo
@@ -12,30 +12,30 @@ write from scratch.
## Sponsors ## Sponsors
<table> <table align="center">
<tbody> <tbody>
<tr> <tr>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://www.cybertec-postgresql.com/en/?utm_source=postgrest.org&utm_medium=referral&utm_campaign=postgrest" target="_blank"> <a href="https://www.cybertec-postgresql.com/en/?utm_source=postgrest.org&utm_medium=referral&utm_campaign=postgrest" target="_blank">
<img width="222px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/cybertec-new.png"> <img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/cybertec-new.png">
</a> </a>
</td> </td>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://www.2ndquadrant.com/en/?utm_campaign=External%20Websites&utm_source=PostgREST&utm_medium=Logo" target="_blank"> <a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/2ndquadrant.png"> <img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/gnuhost.png">
</a> </a>
</td> </td>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank"> <a href="https://neon.tech/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/retool.png"> <img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/neon.jpg">
</a> </a>
</td> </td>
</tr> </tr>
<tr></tr> <tr></tr>
<tr> <tr>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank"> <a href="https://code.build/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/gnuhost.png"> <img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/code-build.png">
</a> </a>
</td> </td>
<td align="center" valign="middle"> <td align="center" valign="middle">
@@ -44,8 +44,8 @@ write from scratch.
</a> </a>
</td> </td>
<td align="center" valign="middle"> <td align="center" valign="middle">
<a href="https://oblivious.ai/?utm_source=sponsor&utm_campaign=postgrest" target="_blank"> <a href="https://tembo.io/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/oblivious.jpg"> <img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/tembo.png">
</a> </a>
</td> </td>
</tr> </tr>
@@ -58,13 +58,13 @@ To learn how to use this container, see the [PostgREST Docker
documentation](https://postgrest.org/en/stable/install.html#docker). documentation](https://postgrest.org/en/stable/install.html#docker).
You can configure the PostgREST image by setting You can configure the PostgREST image by setting
[enviroment variables](https://postgrest.org/en/stable/configuration.html). [environment variables](https://postgrest.org/en/stable/configuration.html).
# How this image is built # How this image is built
The image is built from scratch using The image is built from scratch using
[Nix](https://nixos.org/nixpkgs/manual/#sec-pkgs-dockerTools) instead of a [Nix](https://nixos.org/nixpkgs/manual/#sec-pkgs-dockerTools) instead of a
`Dockerfile`, which yields a higly secure and optimized image. This is also why `Dockerfile`, which yields a highly secure and optimized image. This is also why
no commands are listed in the image history. See the [PostgREST no commands are listed in the image history. See the [PostgREST
respository](https://github.com/PostgREST/postgrest/tree/main/nix/tools/docker) for respository](https://github.com/PostgREST/postgrest/tree/main/nix/tools/docker) for
details on the build process and how to inspect the image. details on the build process and how to inspect the image.
+2 -2
View File
@@ -164,7 +164,7 @@ let
${ghc}/bin/hpc markup --highlight-covered --destdir=coverage/overlay "$tmpdir"/overlay.tix || true ${ghc}/bin/hpc markup --highlight-covered --destdir=coverage/overlay "$tmpdir"/overlay.tix || true
${ghc}/bin/hpc markup --highlight-covered --destdir=coverage/check "$tmpdir"/check.tix || true ${ghc}/bin/hpc markup --highlight-covered --destdir=coverage/check "$tmpdir"/check.tix || true
echo "ERROR: Something is covered by both the tests and the overlay:" echo "ERROR: Something is covered by both the tests and the overlay:"
echo "file://$(pwd)/coverage/check/hpc_index.html" echo "postgrest-coverage: To see the results, visit file://$(pwd)/coverage/check/hpc_index.html"
exit 1 exit 1
else else
# copy the result .tix file to the coverage/ dir to make it available to postgrest-coverage-draft-overlay, too # copy the result .tix file to the coverage/ dir to make it available to postgrest-coverage-draft-overlay, too
@@ -174,7 +174,7 @@ let
# create html and stdout reports # create html and stdout reports
${ghc}/bin/hpc markup --destdir=coverage coverage/postgrest.tix ${ghc}/bin/hpc markup --destdir=coverage coverage/postgrest.tix
echo "file://$(pwd)/coverage/hpc_index.html" echo "postgrest-coverage: To see the results, visit file://$(pwd)/coverage/hpc_index.html"
${ghc}/bin/hpc report coverage/postgrest.tix "''${_arg_leftovers[@]}" ${ghc}/bin/hpc report coverage/postgrest.tix "''${_arg_leftovers[@]}"
fi fi
'' ''
+1 -1
View File
@@ -72,7 +72,7 @@ let
# We try to make the database cluster as independent as possible from the host # We try to make the database cluster as independent as possible from the host
# by specifying the timezone, locale and encoding. # by specifying the timezone, locale and encoding.
# initdb -U creates a superuser(man initdb) # initdb -U creates a superuser(man initdb)
PGTZ=UTC initdb --no-locale --encoding=UTF8 --nosync -U "${superuserRole}" --auth=trust \ TZ=$PGTZ initdb --no-locale --encoding=UTF8 --nosync -U "${superuserRole}" --auth=trust \
>> "$setuplog" >> "$setuplog"
log "Starting the database cluster..." log "Starting the database cluster..."
+17 -10
View File
@@ -1,5 +1,5 @@
name: postgrest name: postgrest
version: 11.2.0 version: 12.0.2
synopsis: REST API for any Postgres database synopsis: REST API for any Postgres database
description: Reads the schema of a PostgreSQL database and creates RESTful routes description: Reads the schema of a PostgreSQL database and creates RESTful routes
for tables, views, and functions, supporting all HTTP methods that security for tables, views, and functions, supporting all HTTP methods that security
@@ -64,6 +64,7 @@ library
PostgREST.Plan.ReadPlan PostgREST.Plan.ReadPlan
PostgREST.Plan.Types PostgREST.Plan.Types
PostgREST.RangeQuery PostgREST.RangeQuery
PostgREST.Unix
PostgREST.ApiRequest PostgREST.ApiRequest
PostgREST.ApiRequest.Preferences PostgREST.ApiRequest.Preferences
PostgREST.ApiRequest.QueryParams PostgREST.ApiRequest.QueryParams
@@ -71,6 +72,7 @@ library
PostgREST.Response PostgREST.Response
PostgREST.Response.OpenAPI PostgREST.Response.OpenAPI
PostgREST.Response.GucHeader PostgREST.Response.GucHeader
PostgREST.Response.Performance
PostgREST.Version PostgREST.Version
other-modules: Paths_postgrest other-modules: Paths_postgrest
build-depends: base >= 4.9 && < 4.17 build-depends: base >= 4.9 && < 4.17
@@ -80,19 +82,22 @@ library
, auto-update >= 0.1.4 && < 0.2 , auto-update >= 0.1.4 && < 0.2
, base64-bytestring >= 1 && < 1.3 , base64-bytestring >= 1 && < 1.3
, bytestring >= 0.10.8 && < 0.12 , bytestring >= 0.10.8 && < 0.12
, cache >= 0.1.3 && < 0.2.0
, case-insensitive >= 1.2 && < 1.3 , case-insensitive >= 1.2 && < 1.3
, cassava >= 0.4.5 && < 0.6 , cassava >= 0.4.5 && < 0.6
, clock >= 0.8.3 && < 0.9.0
, configurator-pg >= 0.2 && < 0.3 , configurator-pg >= 0.2 && < 0.3
, containers >= 0.5.7 && < 0.7 , containers >= 0.5.7 && < 0.7
, contravariant-extras >= 0.3.3 && < 0.4 , contravariant-extras >= 0.3.3 && < 0.4
, cookie >= 0.4.2 && < 0.5 , cookie >= 0.4.2 && < 0.5
, directory >= 1.2.6 && < 1.4
, either >= 4.4.1 && < 5.1 , either >= 4.4.1 && < 5.1
, extra >= 1.7.0 && < 2.0 , extra >= 1.7.0 && < 2.0
, fuzzyset >= 0.2.3 , fuzzyset >= 0.2.3
, gitrev >= 1.2 && < 1.4 , gitrev >= 1.2 && < 1.4
, hasql >= 1.6.1.1 && < 1.7 , hasql >= 1.6.1.1 && < 1.7
, hasql-dynamic-statements >= 0.3.1 && < 0.4 , hasql-dynamic-statements >= 0.3.1 && < 0.4
, hasql-notifications >= 0.1 && < 0.3 , hasql-notifications >= 0.2.0.6 && < 0.3
, hasql-pool >= 0.10 && < 0.11 , hasql-pool >= 0.10 && < 0.11
, hasql-transaction >= 1.0.1 && < 1.1 , hasql-transaction >= 1.0.1 && < 1.1
, heredoc >= 0.2 && < 0.3 , heredoc >= 0.2 && < 0.3
@@ -111,10 +116,13 @@ library
, regex-tdfa >= 1.2.2 && < 1.4 , regex-tdfa >= 1.2.2 && < 1.4
, retry >= 0.7.4 && < 0.10 , retry >= 0.7.4 && < 0.10
, scientific >= 0.3.4 && < 0.4 , scientific >= 0.3.4 && < 0.4
, streaming-commons >= 0.1.1 && < 0.3
, swagger2 >= 2.4 && < 2.9 , swagger2 >= 2.4 && < 2.9
, text >= 1.2.2 && < 1.3 , text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.12 , time >= 1.6 && < 1.12
, timeit >= 2.0 && < 2.1
, unordered-containers >= 0.2.8 && < 0.3 , unordered-containers >= 0.2.8 && < 0.3
, unix-compat >= 0.5.4 && < 0.6
, vault >= 0.3.1.5 && < 0.4 , vault >= 0.3.1.5 && < 0.4
, vector >= 0.11 && < 0.14 , vector >= 0.11 && < 0.14
, wai >= 3.2.1 && < 3.3 , wai >= 3.2.1 && < 3.3
@@ -144,9 +152,6 @@ library
if !os(windows) if !os(windows)
build-depends: build-depends:
unix unix
, directory >= 1.2.6 && < 1.4
exposed-modules:
PostgREST.Unix
executable postgrest executable postgrest
default-language: Haskell2010 default-language: Haskell2010
@@ -188,7 +193,6 @@ test-suite spec
Feature.ConcurrentSpec Feature.ConcurrentSpec
Feature.CorsSpec Feature.CorsSpec
Feature.ExtraSearchPathSpec Feature.ExtraSearchPathSpec
Feature.LegacyGucsSpec
Feature.NoSuperuserSpec Feature.NoSuperuserSpec
Feature.ObservabilitySpec Feature.ObservabilitySpec
Feature.OpenApi.DisabledOpenApiSpec Feature.OpenApi.DisabledOpenApiSpec
@@ -198,27 +202,30 @@ test-suite spec
Feature.OpenApi.RootSpec Feature.OpenApi.RootSpec
Feature.OpenApi.SecurityOpenApiSpec Feature.OpenApi.SecurityOpenApiSpec
Feature.OptionsSpec Feature.OptionsSpec
Feature.Query.AggregateFunctionsSpec
Feature.Query.AndOrParamsSpec Feature.Query.AndOrParamsSpec
Feature.Query.ComputedRelsSpec Feature.Query.ComputedRelsSpec
Feature.Query.CustomMediaSpec
Feature.Query.DeleteSpec Feature.Query.DeleteSpec
Feature.Query.EmbedDisambiguationSpec Feature.Query.EmbedDisambiguationSpec
Feature.Query.EmbedInnerJoinSpec Feature.Query.EmbedInnerJoinSpec
Feature.Query.PlanSpec Feature.Query.ErrorSpec
Feature.Query.HtmlRawOutputSpec
Feature.Query.InsertSpec Feature.Query.InsertSpec
Feature.Query.JsonOperatorSpec Feature.Query.JsonOperatorSpec
Feature.Query.MultipleSchemaSpec Feature.Query.MultipleSchemaSpec
Feature.Query.ErrorSpec Feature.Query.NullsStripSpec
Feature.Query.PgSafeUpdateSpec Feature.Query.PgSafeUpdateSpec
Feature.Query.PlanSpec
Feature.Query.PostGISSpec Feature.Query.PostGISSpec
Feature.Query.PreferencesSpec
Feature.Query.QueryLimitedSpec Feature.Query.QueryLimitedSpec
Feature.Query.QuerySpec Feature.Query.QuerySpec
Feature.Query.RangeSpec Feature.Query.RangeSpec
Feature.Query.RawOutputTypesSpec Feature.Query.RawOutputTypesSpec
Feature.Query.RelatedQueriesSpec Feature.Query.RelatedQueriesSpec
Feature.Query.RpcSpec Feature.Query.RpcSpec
Feature.Query.ServerTimingSpec
Feature.Query.SingularSpec Feature.Query.SingularSpec
Feature.Query.NullsStrip
Feature.Query.SpreadQueriesSpec Feature.Query.SpreadQueriesSpec
Feature.Query.UnicodeSpec Feature.Query.UnicodeSpec
Feature.Query.UpdateSpec Feature.Query.UpdateSpec
+17 -40
View File
@@ -1,11 +1,9 @@
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Admin module PostgREST.Admin
( runAdmin ( runAdmin
) where ) where
import qualified Data.Text as T
import qualified Hasql.Session as SQL import qualified Hasql.Session as SQL
import qualified Network.HTTP.Types.Status as HTTP import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.Wai as Wai import qualified Network.Wai as Wai
@@ -22,24 +20,25 @@ import PostgREST.Config (AppConfig (..))
import qualified PostgREST.AppState as AppState import qualified PostgREST.AppState as AppState
import Protolude import Protolude
import Protolude.Partial (fromJust)
runAdmin :: AppConfig -> AppState -> Warp.Settings -> IO () runAdmin :: AppConfig -> AppState -> Warp.Settings -> IO ()
runAdmin conf@AppConfig{configAdminServerPort} appState settings = runAdmin conf@AppConfig{configAdminServerPort} appState settings =
whenJust configAdminServerPort $ \adminPort -> do whenJust (AppState.getSocketAdmin appState) $ \adminSocket -> do
AppState.logWithZTime appState $ "Admin server listening on port " <> show adminPort AppState.logWithZTime appState $ "Admin server listening on port " <> show (fromIntegral (fromJust configAdminServerPort) :: Integer)
void . forkIO $ Warp.runSettings (settings & Warp.setPort adminPort) adminApp void . forkIO $ Warp.runSettingsSocket settings adminSocket adminApp
where where
adminApp = admin appState conf adminApp = admin appState conf
-- | PostgREST admin application -- | PostgREST admin application
admin :: AppState.AppState -> AppConfig -> Wai.Application admin :: AppState.AppState -> AppConfig -> Wai.Application
admin appState appConfig req respond = do admin appState appConfig req respond = do
isMainAppReachable <- any isRight <$> reachMainApp appConfig isMainAppReachable <- isRight <$> reachMainApp (AppState.getSocketREST appState)
isSchemaCacheLoaded <- isJust <$> AppState.getSchemaCache appState isSchemaCacheLoaded <- isJust <$> AppState.getSchemaCache appState
isConnectionUp <- isConnectionUp <-
if configDbChannelEnabled appConfig if configDbChannelEnabled appConfig
then AppState.getIsListenerOn appState then AppState.getIsListenerOn appState
else isRight <$> AppState.usePool appState (SQL.sql "SELECT 1") else isRight <$> AppState.usePool appState appConfig (SQL.sql "SELECT 1")
case Wai.pathInfo req of case Wai.pathInfo req of
["ready"] -> ["ready"] ->
@@ -51,37 +50,15 @@ admin appState appConfig req respond = do
-- Try to connect to the main app socket -- Try to connect to the main app socket
-- Note that it doesn't even send a valid HTTP request, we just want to check that the main app is accepting connections -- Note that it doesn't even send a valid HTTP request, we just want to check that the main app is accepting connections
-- The code for resolving the "*4", "!4", "*6", "!6", "*" special values is taken from reachMainApp :: Socket -> IO (Either IOException ())
-- https://hackage.haskell.org/package/streaming-commons-0.2.2.4/docs/src/Data.Streaming.Network.html#bindPortGenEx reachMainApp appSock = do
reachMainApp :: AppConfig -> IO [Either IOException ()] sockAddr <- getSocketName appSock
reachMainApp AppConfig{..} = sock <- socket (addrFamily sockAddr) Stream defaultProtocol
case configServerUnixSocket of try $ do
Just path -> do connect sock sockAddr
sock <- socket AF_UNIX Stream 0 withSocketsDo $ bracket (pure sock) close sendEmpty
(:[]) <$> try (do
connect sock $ SockAddrUnix path
withSocketsDo $ bracket (pure sock) close sendEmpty)
Nothing -> do
let
host | configServerHost `elem` ["*4", "!4", "*6", "!6", "*"] = Nothing
| otherwise = Just configServerHost
filterAddrs xs =
case configServerHost of
"*4" -> ipv4Addrs xs ++ ipv6Addrs xs
"!4" -> ipv4Addrs xs
"*6" -> ipv6Addrs xs ++ ipv4Addrs xs
"!6" -> ipv6Addrs xs
_ -> xs
ipv4Addrs = filter ((/=) AF_INET6 . addrFamily)
ipv6Addrs = filter ((==) AF_INET6 . addrFamily)
addrs <- getAddrInfo (Just $ defaultHints { addrSocketType = Stream }) (T.unpack <$> host) (Just . show $ configServerPort)
tryAddr `traverse` filterAddrs addrs
where where
sendEmpty sock = void $ send sock mempty sendEmpty sock = void $ send sock mempty
tryAddr :: AddrInfo -> IO (Either IOException ()) addrFamily (SockAddrInet _ _) = AF_INET
tryAddr addr = do addrFamily (SockAddrInet6 {}) = AF_INET6
sock <- socket (addrFamily addr) (addrSocketType addr) (addrProtocol addr) addrFamily (SockAddrUnix _) = AF_UNIX
try $ do
connect sock $ addrAddress addr
withSocketsDo $ bracket (pure sock) close sendEmpty
+12 -51
View File
@@ -26,7 +26,6 @@ import qualified Data.ByteString.Lazy as LBS
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
import qualified Data.Csv as CSV import qualified Data.Csv as CSV
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.List as L
import qualified Data.List.NonEmpty as NonEmptyList import qualified Data.List.NonEmpty as NonEmptyList
import qualified Data.Map.Strict as M import qualified Data.Map.Strict as M
import qualified Data.Set as S import qualified Data.Set as S
@@ -37,7 +36,7 @@ import Data.Either.Combinators (mapBoth)
import Control.Arrow ((***)) import Control.Arrow ((***))
import Data.Aeson.Types (emptyArray, emptyObject) import Data.Aeson.Types (emptyArray, emptyObject)
import Data.List (lookup, union) import Data.List (lookup)
import Data.Ranged.Ranges (emptyRange, rangeIntersection, import Data.Ranged.Ranges (emptyRange, rangeIntersection,
rangeIsEmpty) rangeIsEmpty)
import Network.HTTP.Types.Header (RequestHeaders, hCookie) import Network.HTTP.Types.Header (RequestHeaders, hCookie)
@@ -51,12 +50,12 @@ import PostgREST.ApiRequest.Types (ApiRequestError (..),
RangeError (..)) RangeError (..))
import PostgREST.Config (AppConfig (..), import PostgREST.Config (AppConfig (..),
OpenAPIMode (..)) OpenAPIMode (..))
import PostgREST.MediaType (MTPlanFormat (..), import PostgREST.MediaType (MediaType (..))
MediaType (..))
import PostgREST.RangeQuery (NonnegRange, allRange, import PostgREST.RangeQuery (NonnegRange, allRange,
convertToLimitZeroRange, convertToLimitZeroRange,
hasLimitZero, hasLimitZero,
rangeRequested) rangeRequested)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
Schema) Schema)
@@ -131,17 +130,16 @@ data ApiRequest = ApiRequest {
, iMethod :: ByteString -- ^ Raw request method , iMethod :: ByteString -- ^ Raw request method
, iSchema :: Schema -- ^ The request schema. Can vary depending on profile headers. , iSchema :: Schema -- ^ The request schema. Can vary depending on profile headers.
, iNegotiatedByProfile :: Bool -- ^ If schema was was chosen according to the profile spec https://www.w3.org/TR/dx-prof-conneg/ , iNegotiatedByProfile :: Bool -- ^ If schema was was chosen according to the profile spec https://www.w3.org/TR/dx-prof-conneg/
, iAcceptMediaType :: MediaType -- ^ The media type in the Accept header , iAcceptMediaType :: [MediaType] -- ^ The resolved media types in the Accept, considering quality(q) factors
, iContentMediaType :: MediaType -- ^ The media type in the Content-Type header , iContentMediaType :: MediaType -- ^ The media type in the Content-Type header
} }
-- | Examines HTTP request and translates it into user intent. -- | Examines HTTP request and translates it into user intent.
userApiRequest :: AppConfig -> Request -> RequestBody -> Either ApiRequestError ApiRequest userApiRequest :: AppConfig -> Request -> RequestBody -> SchemaCache -> Either ApiRequestError ApiRequest
userApiRequest conf req reqBody = do userApiRequest conf req reqBody sCache = do
pInfo@PathInfo{..} <- getPathInfo conf $ pathInfo req pInfo@PathInfo{..} <- getPathInfo conf $ pathInfo req
act <- getAction pInfo method act <- getAction pInfo method
qPrms <- first QueryParamError $ QueryParams.parse (pathIsProc && act `elem` [ActionInvoke InvGet, ActionInvoke InvHead]) $ rawQueryString req qPrms <- first QueryParamError $ QueryParams.parse (pathIsProc && act `elem` [ActionInvoke InvGet, ActionInvoke InvHead]) $ rawQueryString req
(acceptMediaType, contentMediaType) <- getMediaTypes conf hdrs act pInfo
(schema, negotiatedByProfile) <- getSchema conf hdrs method (schema, negotiatedByProfile) <- getSchema conf hdrs method
(topLevelRange, ranges) <- getRanges method qPrms hdrs (topLevelRange, ranges) <- getRanges method qPrms hdrs
(payload, columns) <- getPayload reqBody contentMediaType qPrms act pInfo (payload, columns) <- getPayload reqBody contentMediaType qPrms act pInfo
@@ -153,7 +151,7 @@ userApiRequest conf req reqBody = do
, iRange = ranges , iRange = ranges
, iTopLevelRange = topLevelRange , iTopLevelRange = topLevelRange
, iPayload = payload , iPayload = payload
, iPreferences = Preferences.fromHeaders hdrs , iPreferences = Preferences.fromHeaders (configDbTxAllowOverride conf) (dbTimezones sCache) hdrs
, iQueryParams = qPrms , iQueryParams = qPrms
, iColumns = columns , iColumns = columns
, iHeaders = iHdrs , iHeaders = iHdrs
@@ -162,7 +160,7 @@ userApiRequest conf req reqBody = do
, iMethod = method , iMethod = method
, iSchema = schema , iSchema = schema
, iNegotiatedByProfile = negotiatedByProfile , iNegotiatedByProfile = negotiatedByProfile
, iAcceptMediaType = acceptMediaType , iAcceptMediaType = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"
, iContentMediaType = contentMediaType , iContentMediaType = contentMediaType
} }
where where
@@ -171,6 +169,7 @@ userApiRequest conf req reqBody = do
lookupHeader = flip lookup hdrs lookupHeader = flip lookup hdrs
iHdrs = [ (CI.foldedCase k, v) | (k,v) <- hdrs, k /= hCookie] iHdrs = [ (CI.foldedCase k, v) | (k,v) <- hdrs, k /= hCookie]
iCkies = maybe [] parseCookies $ lookupHeader "Cookie" iCkies = maybe [] parseCookies $ lookupHeader "Cookie"
contentMediaType = maybe MTApplicationJSON MediaType.decodeMediaType $ lookupHeader "content-type"
getPathInfo :: AppConfig -> [Text] -> Either ApiRequestError PathInfo getPathInfo :: AppConfig -> [Text] -> Either ApiRequestError PathInfo
getPathInfo AppConfig{configOpenApiMode, configDbRootSpec} path = getPathInfo AppConfig{configOpenApiMode, configDbRootSpec} path =
@@ -204,15 +203,6 @@ getAction PathInfo{pathIsProc, pathIsDefSpec} method =
"OPTIONS" -> Right ActionInfo "OPTIONS" -> Right ActionInfo
_ -> Left $ UnsupportedMethod method _ -> Left $ UnsupportedMethod method
getMediaTypes :: AppConfig -> RequestHeaders -> Action -> PathInfo -> Either ApiRequestError (MediaType, MediaType)
getMediaTypes conf hdrs action path = do
acceptMediaType <- negotiateContent conf action path accepts
pure (acceptMediaType, contentMediaType)
where
accepts = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"
contentMediaType = maybe MTApplicationJSON MediaType.decodeMediaType $ lookupHeader "content-type"
lookupHeader = flip lookup hdrs
getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool) getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool)
getSchema AppConfig{configDbSchemas} hdrs method = do getSchema AppConfig{configDbSchemas} hdrs method = do
case profile of case profile of
@@ -269,7 +259,9 @@ getPayload reqBody contentMediaType QueryParams{qsColumns} action PathInfo{pathI
else note "All object keys must match" . payloadAttributes reqBody else note "All object keys must match" . payloadAttributes reqBody
=<< if LBS.null reqBody && pathIsProc =<< if LBS.null reqBody && pathIsProc
then Right emptyObject then Right emptyObject
else first BS.pack $ JSON.eitherDecode reqBody else first BS.pack $
-- Drop parsing error message in favor of generic one (https://github.com/PostgREST/postgrest/issues/2344)
maybe (Left "Empty or invalid json") Right $ JSON.decode reqBody
(MTTextCSV, _) -> do (MTTextCSV, _) -> do
json <- csvToJson <$> first BS.pack (CSV.decodeByName reqBody) json <- csvToJson <$> first BS.pack (CSV.decodeByName reqBody)
note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
@@ -346,34 +338,3 @@ payloadAttributes raw json =
_ -> Just emptyPJArray _ -> Just emptyPJArray
where where
emptyPJArray = ProcessedJSON (JSON.encode emptyArray) S.empty emptyPJArray = ProcessedJSON (JSON.encode emptyArray) S.empty
-- | Do content negotiation. i.e. choose a media type based on the intersection of accepted/produced media types.
negotiateContent :: AppConfig -> Action -> PathInfo -> [MediaType] -> Either ApiRequestError MediaType
negotiateContent conf action path accepts =
case firstAcceptedPick of
Just MTAny -> Right MTApplicationJSON -- by default(for */*) we respond with json
Just mt -> Right mt
Nothing -> Left . MediaTypeError $ map MediaType.toMime accepts
where
-- if there are multiple accepted media types, pick the first
firstAcceptedPick = listToMaybe $ L.intersect accepts $ producedMediaTypes conf action path
producedMediaTypes :: AppConfig -> Action -> PathInfo -> [MediaType]
producedMediaTypes conf action path =
case action of
ActionRead _ -> defaultMediaTypes ++ rawMediaTypes
ActionInvoke _ -> invokeMediaTypes
ActionInfo -> defaultMediaTypes
ActionMutate _ -> defaultMediaTypes
ActionInspect _ -> inspectMediaTypes
where
inspectMediaTypes = [MTOpenAPI, MTApplicationJSON, MTArrayJSONStrip, MTAny]
invokeMediaTypes =
defaultMediaTypes
++ rawMediaTypes
++ [MTOpenAPI | pathIsRootSpec path]
defaultMediaTypes =
[MTApplicationJSON, MTArrayJSONStrip, MTSingularJSON True, MTSingularJSON False, MTGeoJSON, MTTextCSV] ++
[MTPlan MTApplicationJSON PlanText mempty | configDbPlanEnabled conf] ++ [MTAny]
rawMediaTypes = configRawMediaTypes conf `union` [MTOctetStream, MTTextPlain, MTTextXML]
+86 -32
View File
@@ -6,25 +6,30 @@
-- --
-- [1] https://datatracker.ietf.org/doc/html/rfc7240 -- [1] https://datatracker.ietf.org/doc/html/rfc7240
-- --
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.ApiRequest.Preferences module PostgREST.ApiRequest.Preferences
( Preferences(..) ( Preferences(..)
, PreferCount(..) , PreferCount(..)
, PreferHandling(..)
, PreferMissing(..) , PreferMissing(..)
, PreferParameters(..) , PreferParameters(..)
, PreferRepresentation(..) , PreferRepresentation(..)
, PreferResolution(..) , PreferResolution(..)
, PreferTransaction(..) , PreferTransaction(..)
, PreferTimezone(..)
, fromHeaders , fromHeaders
, ToAppliedHeader(..)
, shouldCount , shouldCount
, prefAppliedHeader
) where ) where
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.Map as Map import qualified Data.Map as Map
import qualified Data.Set as S
import qualified Network.HTTP.Types.Header as HTTP import qualified Network.HTTP.Types.Header as HTTP
import Protolude import PostgREST.Config.Database (TimezoneNames)
import Protolude
-- $setup -- $setup
-- Setup for doctests -- Setup for doctests
@@ -35,6 +40,8 @@ import Protolude
-- >>> deriving instance Show PreferCount -- >>> deriving instance Show PreferCount
-- >>> deriving instance Show PreferTransaction -- >>> deriving instance Show PreferTransaction
-- >>> deriving instance Show PreferMissing -- >>> deriving instance Show PreferMissing
-- >>> deriving instance Show PreferHandling
-- >>> deriving instance Show PreferTimezone
-- >>> deriving instance Show Preferences -- >>> deriving instance Show Preferences
-- | Preferences recognized by the application. -- | Preferences recognized by the application.
@@ -46,14 +53,18 @@ data Preferences
, preferCount :: Maybe PreferCount , preferCount :: Maybe PreferCount
, preferTransaction :: Maybe PreferTransaction , preferTransaction :: Maybe PreferTransaction
, preferMissing :: Maybe PreferMissing , preferMissing :: Maybe PreferMissing
, preferHandling :: Maybe PreferHandling
, preferTimezone :: Maybe PreferTimezone
, invalidPrefs :: [ByteString]
} }
-- | -- |
-- Parse HTTP headers based on RFC7240[1] to identify preferences. -- Parse HTTP headers based on RFC7240[1] to identify preferences.
-- --
-- One header with comma-separated values can be used to set multiple preferences: -- >>> let sc = S.fromList ["America/Los_Angeles"]
-- --
-- >>> pPrint $ fromHeaders [("Prefer", "resolution=ignore-duplicates, count=exact")] -- One header with comma-separated values can be used to set multiple preferences:
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates, count=exact, timezone=America/Los_Angeles")]
-- Preferences -- Preferences
-- { preferResolution = Just IgnoreDuplicates -- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing -- , preferRepresentation = Nothing
@@ -61,11 +72,15 @@ data Preferences
-- , preferCount = Just ExactCount -- , preferCount = Just ExactCount
-- , preferTransaction = Nothing -- , preferTransaction = Nothing
-- , preferMissing = Nothing -- , preferMissing = Nothing
-- , preferHandling = Nothing
-- , preferTimezone = Just
-- ( PreferTimezone "America/Los_Angeles" )
-- , invalidPrefs = []
-- } -- }
-- --
-- Multiple headers can also be used: -- Multiple headers can also be used:
-- --
-- >>> pPrint $ fromHeaders [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null")] -- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid")]
-- Preferences -- Preferences
-- { preferResolution = Just IgnoreDuplicates -- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing -- , preferRepresentation = Nothing
@@ -73,31 +88,30 @@ data Preferences
-- , preferCount = Just ExactCount -- , preferCount = Just ExactCount
-- , preferTransaction = Nothing -- , preferTransaction = Nothing
-- , preferMissing = Just ApplyNulls -- , preferMissing = Just ApplyNulls
-- , preferHandling = Just Lenient
-- , preferTimezone = Nothing
-- , invalidPrefs = [ "invalid" ]
-- } -- }
-- --
-- If a preference is set more than once, only the first is used: -- If a preference is set more than once, only the first is used:
-- --
-- >>> preferTransaction $ fromHeaders [("Prefer", "tx=commit, tx=rollback")] -- >>> preferTransaction $ fromHeaders True sc [("Prefer", "tx=commit, tx=rollback")]
-- Just Commit -- Just Commit
-- --
-- This is also the case across multiple headers: -- This is also the case across multiple headers:
-- --
-- >>> :{ -- >>> :{
-- preferResolution . fromHeaders $ -- preferResolution . fromHeaders True sc $
-- [ ("Prefer", "resolution=ignore-duplicates") -- [ ("Prefer", "resolution=ignore-duplicates")
-- , ("Prefer", "resolution=merge-duplicates") -- , ("Prefer", "resolution=merge-duplicates")
-- ] -- ]
-- :} -- :}
-- Just IgnoreDuplicates -- Just IgnoreDuplicates
-- --
-- Preferences not recognized by the application are ignored:
--
-- >>> preferResolution $ fromHeaders [("Prefer", "resolution=foo")]
-- Nothing
-- --
-- Preferences can be separated by arbitrary amounts of space, lower-case header is also recognized: -- Preferences can be separated by arbitrary amounts of space, lower-case header is also recognized:
-- --
-- >>> pPrint $ fromHeaders [("prefer", "count=exact, tx=commit ,return=representation , missing=default")] -- >>> pPrint $ fromHeaders True sc [("prefer", "count=exact, tx=commit ,return=representation , missing=default, handling=strict, anything")]
-- Preferences -- Preferences
-- { preferResolution = Nothing -- { preferResolution = Nothing
-- , preferRepresentation = Just Full -- , preferRepresentation = Just Full
@@ -105,22 +119,43 @@ data Preferences
-- , preferCount = Just ExactCount -- , preferCount = Just ExactCount
-- , preferTransaction = Just Commit -- , preferTransaction = Just Commit
-- , preferMissing = Just ApplyDefaults -- , preferMissing = Just ApplyDefaults
-- , preferHandling = Just Strict
-- , preferTimezone = Nothing
-- , invalidPrefs = [ "anything" ]
-- } -- }
-- --
fromHeaders :: [HTTP.Header] -> Preferences fromHeaders :: Bool -> TimezoneNames -> [HTTP.Header] -> Preferences
fromHeaders headers = fromHeaders allowTxDbOverride acceptedTzNames headers =
Preferences Preferences
{ preferResolution = parsePrefs [MergeDuplicates, IgnoreDuplicates] { preferResolution = parsePrefs [MergeDuplicates, IgnoreDuplicates]
, preferRepresentation = parsePrefs [Full, None, HeadersOnly] , preferRepresentation = parsePrefs [Full, None, HeadersOnly]
, preferParameters = parsePrefs [SingleObject] , preferParameters = parsePrefs [SingleObject]
, preferCount = parsePrefs [ExactCount, PlannedCount, EstimatedCount] , preferCount = parsePrefs [ExactCount, PlannedCount, EstimatedCount]
, preferTransaction = parsePrefs [Commit, Rollback] , preferTransaction = if allowTxDbOverride then parsePrefs [Commit, Rollback] else Nothing
, preferMissing = parsePrefs [ApplyDefaults, ApplyNulls] , preferMissing = parsePrefs [ApplyDefaults, ApplyNulls]
, preferHandling = parsePrefs [Strict, Lenient]
, preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing
, invalidPrefs = filter checkPrefs prefs
} }
where where
mapToHeadVal :: ToHeaderValue a => [a] -> [ByteString]
mapToHeadVal = map toHeaderValue
acceptedPrefs = mapToHeadVal [MergeDuplicates, IgnoreDuplicates] ++
mapToHeadVal [Full, None, HeadersOnly] ++
mapToHeadVal [SingleObject] ++
mapToHeadVal [ExactCount, PlannedCount, EstimatedCount] ++
mapToHeadVal [Commit, Rollback] ++
mapToHeadVal [ApplyDefaults, ApplyNulls] ++
mapToHeadVal [Strict, Lenient]
prefHeaders = filter ((==) HTTP.hPrefer . fst) headers prefHeaders = filter ((==) HTTP.hPrefer . fst) headers
prefs = fmap BS.strip . concatMap (BS.split ',' . snd) $ prefHeaders prefs = fmap BS.strip . concatMap (BS.split ',' . snd) $ prefHeaders
timezonePref = listToMaybe $ mapMaybe (BS.stripPrefix "timezone=") prefs
isTimezonePrefAccepted = (S.member <$> timezonePref <*> pure acceptedTzNames) == Just True
checkPrefs p = p `notElem` acceptedPrefs && not isTimezonePrefAccepted
parsePrefs :: ToHeaderValue a => [a] -> Maybe a parsePrefs :: ToHeaderValue a => [a] -> Maybe a
parsePrefs vals = parsePrefs vals =
head $ mapMaybe (flip Map.lookup $ prefMap vals) prefs head $ mapMaybe (flip Map.lookup $ prefMap vals) prefs
@@ -128,6 +163,24 @@ fromHeaders headers =
prefMap :: ToHeaderValue a => [a] -> Map.Map ByteString a prefMap :: ToHeaderValue a => [a] -> Map.Map ByteString a
prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref)) prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref))
prefAppliedHeader :: Preferences -> Maybe HTTP.Header
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferParameters, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone } =
if null prefsVals
then Nothing
else Just (HTTP.hPreferenceApplied, combined)
where
combined = BS.intercalate ", " prefsVals
prefsVals = catMaybes [
toHeaderValue <$> preferResolution
, toHeaderValue <$> preferMissing
, toHeaderValue <$> preferRepresentation
, toHeaderValue <$> preferParameters
, toHeaderValue <$> preferCount
, toHeaderValue <$> preferTransaction
, toHeaderValue <$> preferHandling
, toHeaderValue <$> preferTimezone
]
-- | -- |
-- Convert a preference into the value that we look for in the 'Prefer' headers. -- Convert a preference into the value that we look for in the 'Prefer' headers.
-- --
@@ -137,27 +190,16 @@ fromHeaders headers =
class ToHeaderValue a where class ToHeaderValue a where
toHeaderValue :: a -> ByteString toHeaderValue :: a -> ByteString
-- |
-- Header to indicate that a preference has been applied.
--
-- >>> toAppliedHeader MergeDuplicates
-- ("Preference-Applied","resolution=merge-duplicates")
--
class ToHeaderValue a => ToAppliedHeader a where
toAppliedHeader :: a -> HTTP.Header
toAppliedHeader x = (HTTP.hPreferenceApplied, toHeaderValue x)
-- | How to handle duplicate values. -- | How to handle duplicate values.
data PreferResolution data PreferResolution
= MergeDuplicates = MergeDuplicates
| IgnoreDuplicates | IgnoreDuplicates
deriving Eq
instance ToHeaderValue PreferResolution where instance ToHeaderValue PreferResolution where
toHeaderValue MergeDuplicates = "resolution=merge-duplicates" toHeaderValue MergeDuplicates = "resolution=merge-duplicates"
toHeaderValue IgnoreDuplicates = "resolution=ignore-duplicates" toHeaderValue IgnoreDuplicates = "resolution=ignore-duplicates"
instance ToAppliedHeader PreferResolution
-- | -- |
-- How to return the mutated data. -- How to return the mutated data.
-- --
@@ -168,8 +210,6 @@ data PreferRepresentation
| None -- ^ Return nothing from the mutated data. | None -- ^ Return nothing from the mutated data.
deriving Eq deriving Eq
instance ToAppliedHeader PreferRepresentation
instance ToHeaderValue PreferRepresentation where instance ToHeaderValue PreferRepresentation where
toHeaderValue Full = "return=representation" toHeaderValue Full = "return=representation"
toHeaderValue None = "return=minimal" toHeaderValue None = "return=minimal"
@@ -209,8 +249,6 @@ instance ToHeaderValue PreferTransaction where
toHeaderValue Commit = "tx=commit" toHeaderValue Commit = "tx=commit"
toHeaderValue Rollback = "tx=rollback" toHeaderValue Rollback = "tx=rollback"
instance ToAppliedHeader PreferTransaction
-- | -- |
-- How to handle the insertion/update when the keys specified in ?columns are not present -- How to handle the insertion/update when the keys specified in ?columns are not present
-- in the json body. -- in the json body.
@@ -223,4 +261,20 @@ instance ToHeaderValue PreferMissing where
toHeaderValue ApplyDefaults = "missing=default" toHeaderValue ApplyDefaults = "missing=default"
toHeaderValue ApplyNulls = "missing=null" toHeaderValue ApplyNulls = "missing=null"
instance ToAppliedHeader PreferMissing -- |
-- Handling of unrecognised preferences
data PreferHandling
= Strict -- ^ Throw error on unrecognised preferences
| Lenient -- ^ Ignore unrecognised preferences
deriving Eq
instance ToHeaderValue PreferHandling where
toHeaderValue Strict = "handling=strict"
toHeaderValue Lenient = "handling=lenient"
-- |
-- Change timezone
newtype PreferTimezone = PreferTimezone ByteString
instance ToHeaderValue PreferTimezone where
toHeaderValue (PreferTimezone tz) = "timezone=" <> tz
+74 -25
View File
@@ -31,8 +31,8 @@ import Data.Tree (Tree (..))
import Text.Parsec.Error (errorMessages, import Text.Parsec.Error (errorMessages,
showErrorMessages) showErrorMessages)
import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser, import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser,
anyChar, between, char, digit, anyChar, between, char, choice,
eof, errorPos, letter, digit, eof, errorPos, letter,
lookAhead, many1, noneOf, lookAhead, many1, noneOf,
notFollowedBy, oneOf, notFollowedBy, oneOf,
optionMaybe, sepBy, sepBy1, optionMaybe, sepBy, sepBy1,
@@ -43,7 +43,8 @@ import PostgREST.RangeQuery (NonnegRange, allRange,
rangeOffset, restrictRange) rangeOffset, restrictRange)
import PostgREST.SchemaCache.Identifiers (FieldName) import PostgREST.SchemaCache.Identifiers (FieldName)
import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field, import PostgREST.ApiRequest.Types (AggregateFunction (..),
EmbedParam (..), EmbedPath, Field,
Filter (..), FtsOperator (..), Filter (..), FtsOperator (..),
Hint, JoinType (..), Hint, JoinType (..),
JsonOperand (..), JsonOperand (..),
@@ -58,7 +59,7 @@ import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field,
SimpleOperator (..), SingleVal, SimpleOperator (..), SingleVal,
TrileanVal (..)) TrileanVal (..))
import Protolude hiding (try) import Protolude hiding (Sum, try)
data QueryParams = data QueryParams =
QueryParams QueryParams
@@ -99,7 +100,7 @@ data QueryParams =
-- 'select' is a reserved parameter that selects the fields to be returned: -- 'select' is a reserved parameter that selects the fields to be returned:
-- --
-- >>> qsSelect <$> parse False "select=name,location" -- >>> qsSelect <$> parse False "select=name,location"
-- Right [Node {rootLabel = SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectField {selField = ("location",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}] -- Right [Node {rootLabel = SelectField {selField = ("name",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectField {selField = ("location",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]
-- --
-- Filters are parameters whose value contains an operator, separated by a '.' from its value: -- Filters are parameters whose value contains an operator, separated by a '.' from its value:
-- --
@@ -282,16 +283,16 @@ pTreePath = do
-- Parse select= into a Forest of SelectItems -- Parse select= into a Forest of SelectItems
-- --
-- >>> P.parse pFieldForest "" "id" -- >>> P.parse pFieldForest "" "id"
-- Right [Node {rootLabel = SelectField {selField = ("id",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}] -- Right [Node {rootLabel = SelectField {selField = ("id",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]
-- --
-- >>> P.parse pFieldForest "" "client(id)" -- >>> P.parse pFieldForest "" "client(id)"
-- Right [Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("id",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}] -- Right [Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("id",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]}]
-- --
-- >>> P.parse pFieldForest "" "*,client(*,nested(*))" -- >>> P.parse pFieldForest "" "*,client(*,nested(*))"
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "nested", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}]}] -- Right [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "nested", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]}]}]
-- --
-- >>> P.parse pFieldForest "" "*,...client(*),other(*)" -- >>> P.parse pFieldForest "" "*,...client(*),other(*)"
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SpreadRelation {selRelation = "client", selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]},Node {rootLabel = SelectRelation {selRelation = "other", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}] -- Right [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SpreadRelation {selRelation = "client", selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]},Node {rootLabel = SelectRelation {selRelation = "other", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]}]
-- --
-- >>> P.parse pFieldForest "" "" -- >>> P.parse pFieldForest "" ""
-- Right [] -- Right []
@@ -299,7 +300,7 @@ pTreePath = do
-- >>> P.parse pFieldForest "" "id,clients(name[])" -- >>> P.parse pFieldForest "" "id,clients(name[])"
-- Left (line 1, column 16): -- Left (line 1, column 16):
-- unexpected '[' -- unexpected '['
-- expecting letter, digit, "-", "->>", "->", "::", ")", "," or end of input -- expecting letter, digit, "-", "->>", "->", "::", ".", ")", "," or end of input
-- --
-- >>> P.parse pFieldForest "" "data->>-78xy" -- >>> P.parse pFieldForest "" "data->>-78xy"
-- Left (line 1, column 11): -- Left (line 1, column 11):
@@ -349,8 +350,12 @@ pFieldForest = pFieldTree `sepBy` lexeme (char ',')
pFieldName :: Parser Text pFieldName :: Parser Text
pFieldName = pFieldName =
pQuotedValue <|> pQuotedValue <|>
T.intercalate "-" . map toS <$> (pIdentifier `sepBy1` dash) <?> sepByDash pIdentifier <?>
"field name (* or [a..z0..9_$])" "field name (* or [a..z0..9_$])"
sepByDash :: Parser Text -> Parser Text
sepByDash fieldIdent =
T.intercalate "-" . map toS <$> (fieldIdent `sepBy1` dash)
where where
isDash :: GenParser Char st () isDash :: GenParser Char st ()
isDash = try ( char '-' >> notFollowedBy (char '>') ) isDash = try ( char '-' >> notFollowedBy (char '>') )
@@ -363,12 +368,18 @@ pFieldName =
-- >>> P.parse pJsonPath "" "->text" -- >>> P.parse pJsonPath "" "->text"
-- Right [JArrow {jOp = JKey {jVal = "text"}}] -- Right [JArrow {jOp = JKey {jVal = "text"}}]
-- --
-- >>> P.parse pJsonPath "" "->!@#$%^&*_a"
-- Right [JArrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]
--
-- >>> P.parse pJsonPath "" "->1" -- >>> P.parse pJsonPath "" "->1"
-- Right [JArrow {jOp = JIdx {jVal = "+1"}}] -- Right [JArrow {jOp = JIdx {jVal = "+1"}}]
-- --
-- >>> P.parse pJsonPath "" "->>text" -- >>> P.parse pJsonPath "" "->>text"
-- Right [J2Arrow {jOp = JKey {jVal = "text"}}] -- Right [J2Arrow {jOp = JKey {jVal = "text"}}]
-- --
-- >>> P.parse pJsonPath "" "->>!@#$%^&*_a"
-- Right [J2Arrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]
--
-- >>> P.parse pJsonPath "" "->>1" -- >>> P.parse pJsonPath "" "->>1"
-- Right [J2Arrow {jOp = JIdx {jVal = "+1"}}] -- Right [J2Arrow {jOp = JIdx {jVal = "+1"}}]
-- --
@@ -405,7 +416,7 @@ pJsonPath = many pJsonOperation
try (string "->" $> JArrow) try (string "->" $> JArrow)
pJsonOperand = pJsonOperand =
let pJKey = JKey . toS <$> pFieldName let pJKey = JKey . toS <$> pJsonKeyName
pJIdx = JIdx . toS <$> ((:) <$> P.option '+' (char '-') <*> many1 digit) <* pEnd pJIdx = JIdx . toS <$> ((:) <$> P.option '+' (char '-') <*> many1 digit) <* pEnd
pEnd = try (void $ lookAhead (string "->")) <|> pEnd = try (void $ lookAhead (string "->")) <|>
try (void $ lookAhead (string "::")) <|> try (void $ lookAhead (string "::")) <|>
@@ -414,6 +425,15 @@ pJsonPath = many pJsonOperation
try eof in try eof in
try pJIdx <|> try pJKey try pJIdx <|> try pJKey
pJsonKeyName :: Parser Text
pJsonKeyName =
pQuotedValue <|>
sepByDash pJsonKeyIdentifier <?>
"any non reserved character different from: .,>()"
pJsonKeyIdentifier :: Parser Text
pJsonKeyIdentifier = T.strip . toS <$> many1 (noneOf "(-:.,>)")
pField :: Parser Field pField :: Parser Field
pField = lexeme $ (,) <$> pFieldName <*> P.option [] pJsonPath pField = lexeme $ (,) <$> pFieldName <*> P.option [] pJsonPath
@@ -452,35 +472,40 @@ pRelationSelect :: Parser SelectItem
pRelationSelect = lexeme $ do pRelationSelect = lexeme $ do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
name <- pFieldName name <- pFieldName
guard (name /= "count")
(hint, jType) <- pEmbedParams (hint, jType) <- pEmbedParams
try (void $ lookAhead (string "(")) try (void $ lookAhead (string "("))
return $ SelectRelation name alias hint jType return $ SelectRelation name alias hint jType
-- | -- |
-- Parse regular fields in select -- Parse regular fields in select
-- --
-- >>> P.parse pFieldSelect "" "name" -- >>> P.parse pFieldSelect "" "name"
-- Right (SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Nothing}) -- Right (SelectField {selField = ("name",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing})
-- --
-- >>> P.parse pFieldSelect "" "name->jsonpath" -- >>> P.parse pFieldSelect "" "name->jsonpath"
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selCast = Nothing, selAlias = Nothing}) -- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing})
-- --
-- >>> P.parse pFieldSelect "" "name::cast" -- >>> P.parse pFieldSelect "" "name::cast"
-- Right (SelectField {selField = ("name",[]), selCast = Just "cast", selAlias = Nothing}) -- Right (SelectField {selField = ("name",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Just "cast", selAlias = Nothing})
-- --
-- >>> P.parse pFieldSelect "" "alias:name" -- >>> P.parse pFieldSelect "" "alias:name"
-- Right (SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Just "alias"}) -- Right (SelectField {selField = ("name",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Just "alias"})
-- --
-- >>> P.parse pFieldSelect "" "alias:name->jsonpath::cast" -- >>> P.parse pFieldSelect "" "alias:name->jsonpath::cast"
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selCast = Just "cast", selAlias = Just "alias"}) -- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Just "cast", selAlias = Just "alias"})
--
-- >>> P.parse pFieldSelect "" "alias:name->!@#$%^&*_a::cast"
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Just "cast", selAlias = Just "alias"})
-- --
-- >>> P.parse pFieldSelect "" "*" -- >>> P.parse pFieldSelect "" "*"
-- Right (SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}) -- Right (SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing})
-- --
-- >>> P.parse pFieldSelect "" "name!hint" -- >>> P.parse pFieldSelect "" "name!hint"
-- Left (line 1, column 5): -- Left (line 1, column 5):
-- unexpected '!' -- unexpected '!'
-- expecting letter, digit, "-", "->>", "->", "::", ")", "," or end of input -- expecting letter, digit, "-", "->>", "->", "::", ".", ")", "," or end of input
-- --
-- >>> P.parse pFieldSelect "" "*!hint" -- >>> P.parse pFieldSelect "" "*!hint"
-- Left (line 1, column 2): -- Left (line 1, column 2):
@@ -495,18 +520,36 @@ pFieldSelect :: Parser SelectItem
pFieldSelect = lexeme $ try (do pFieldSelect = lexeme $ try (do
s <- pStar s <- pStar
pEnd pEnd
return $ SelectField (s, []) Nothing Nothing) return $ SelectField (s, []) Nothing Nothing Nothing Nothing)
<|> do <|> try (do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField _ <- string "count()"
cast' <- optionMaybe (string "::" *> pIdentifier) aggCast' <- optionMaybe (string "::" *> pIdentifier)
pEnd pEnd
return $ SelectField fld (toS <$> cast') alias return $ SelectField ("*", []) (Just Count) (toS <$> aggCast') Nothing alias)
<|> do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField
cast' <- optionMaybe (string "::" *> pIdentifier)
agg <- optionMaybe (try (char '.' *> pAggregation <* string "()"))
aggCast' <- optionMaybe (string "::" *> pIdentifier)
pEnd
return $ SelectField fld agg (toS <$> aggCast') (toS <$> cast') alias
where where
pEnd = try (void $ lookAhead (string ")")) <|> pEnd = try (void $ lookAhead (string ")")) <|>
try (void $ lookAhead (string ",")) <|> try (void $ lookAhead (string ",")) <|>
try eof try eof
pStar = string "*" $> "*" pStar = string "*" $> "*"
pAggregation = choice
[ string "sum" $> Sum
, string "avg" $> Avg
, string "count" $> Count
-- Using 'try' for "min" and "max" to allow backtracking.
-- This is necessary because both start with the same character 'm',
-- and without 'try', a partial match on "max" would prevent "min" from being tried.
, try (string "max") $> Max
, try (string "min") $> Min
]
-- | -- |
@@ -657,9 +700,15 @@ pDelimiter = char '.' <?> "delimiter (.)"
-- >>> P.parse pOrder "" "json_col->key.asc.nullslast" -- >>> P.parse pOrder "" "json_col->key.asc.nullslast"
-- Right [OrderTerm {otTerm = ("json_col",[JArrow {jOp = JKey {jVal = "key"}}]), otDirection = Just OrderAsc, otNullOrder = Just OrderNullsLast}] -- Right [OrderTerm {otTerm = ("json_col",[JArrow {jOp = JKey {jVal = "key"}}]), otDirection = Just OrderAsc, otNullOrder = Just OrderNullsLast}]
-- --
-- >>> P.parse pOrder "" "json_col->!@#$%^&*_a.asc.nullslast"
-- Right [OrderTerm {otTerm = ("json_col",[JArrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]), otDirection = Just OrderAsc, otNullOrder = Just OrderNullsLast}]
--
-- >>> P.parse pOrder "" "clients(json_col->key).desc.nullsfirst" -- >>> P.parse pOrder "" "clients(json_col->key).desc.nullsfirst"
-- Right [OrderRelationTerm {otRelation = "clients", otRelTerm = ("json_col",[JArrow {jOp = JKey {jVal = "key"}}]), otDirection = Just OrderDesc, otNullOrder = Just OrderNullsFirst}] -- Right [OrderRelationTerm {otRelation = "clients", otRelTerm = ("json_col",[JArrow {jOp = JKey {jVal = "key"}}]), otDirection = Just OrderDesc, otNullOrder = Just OrderNullsFirst}]
-- --
-- >>> P.parse pOrder "" "clients(json_col->!@#$%^&*_a).desc.nullsfirst"
-- Right [OrderRelationTerm {otRelation = "clients", otRelTerm = ("json_col",[JArrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]), otDirection = Just OrderDesc, otNullOrder = Just OrderNullsFirst}]
--
-- >>> P.parse pOrder "" "clients(name,id)" -- >>> P.parse pOrder "" "clients(name,id)"
-- Left (line 1, column 8): -- Left (line 1, column 8):
-- unexpected '(' -- unexpected '('
+20 -7
View File
@@ -1,6 +1,7 @@
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.ApiRequest.Types module PostgREST.ApiRequest.Types
( Alias ( AggregateFunction(..)
, Alias
, Cast , Cast
, Depth , Depth
, EmbedParam(..) , EmbedParam(..)
@@ -42,12 +43,14 @@ import PostgREST.SchemaCache.Routine (Routine (..))
import Protolude import Protolude
-- | The value in `/tbl?select=alias:field::cast` -- | The value in `/tbl?select=alias:field.aggregateFunction()::cast`
data SelectItem data SelectItem
= SelectField = SelectField
{ selField :: Field { selField :: Field
, selCast :: Maybe Cast , selAggregateFunction :: Maybe AggregateFunction
, selAlias :: Maybe Alias , selAggregateCast :: Maybe Cast
, selCast :: Maybe Cast
, selAlias :: Maybe Alias
} }
-- | The value in `/tbl?select=alias:another_tbl(*)` -- | The value in `/tbl?select=alias:another_tbl(*)`
| SelectRelation | SelectRelation
@@ -65,12 +68,13 @@ data SelectItem
deriving (Eq, Show) deriving (Eq, Show)
data ApiRequestError data ApiRequestError
= AmbiguousRelBetween Text Text [Relationship] = AggregatesNotAllowed
| AmbiguousRelBetween Text Text [Relationship]
| AmbiguousRpc [Routine] | AmbiguousRpc [Routine]
| BinaryFieldError MediaType
| MediaTypeError [ByteString] | MediaTypeError [ByteString]
| InvalidBody ByteString | InvalidBody ByteString
| InvalidFilters | InvalidFilters
| InvalidPreferences [ByteString]
| InvalidRange RangeError | InvalidRange RangeError
| InvalidRpcMethod ByteString | InvalidRpcMethod ByteString
| LimitNoOrderError | LimitNoOrderError
@@ -86,6 +90,12 @@ data ApiRequestError
| UnacceptableSchema [Text] | UnacceptableSchema [Text]
| UnsupportedMethod ByteString | UnsupportedMethod ByteString
| ColumnNotFound Text Text | ColumnNotFound Text Text
| GucHeadersError
| GucStatusError
| OffLimitsChangesError Int64 Integer
| PutMatchingPkError
| SingularityError Integer
| PGRSTParseError
deriving Show deriving Show
data QPError = QPError Text Text data QPError = QPError Text Text
@@ -128,6 +138,9 @@ type Cast = Text
type Alias = Text type Alias = Text
type Hint = Text type Hint = Text
data AggregateFunction = Sum | Avg | Max | Min | Count
deriving (Show, Eq)
data EmbedParam data EmbedParam
-- | Disambiguates an embedding operation when there's multiple relationships -- | Disambiguates an embedding operation when there's multiple relationships
-- between two tables. Can be the name of a foreign key constraint, column -- between two tables. Can be the name of a foreign key constraint, column
+118 -79
View File
@@ -9,11 +9,10 @@ Some of its functionality includes:
- Producing HTTP Headers according to RFCs. - Producing HTTP Headers according to RFCs.
- Content Negotiation - Content Negotiation
-} -}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.App module PostgREST.App
( SignalHandlerInstaller ( postgrest
, SocketRunner
, postgrest
, run , run
) where ) where
@@ -24,7 +23,6 @@ import Data.Maybe (fromJust)
import Data.String (IsString (..)) import Data.String (IsString (..))
import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort, import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort,
setServerName) setServerName)
import System.Posix.Types (FileMode)
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.Text.Encoding as T import qualified Data.Text.Encoding as T
@@ -43,32 +41,36 @@ import qualified PostgREST.Logger as Logger
import qualified PostgREST.Plan as Plan import qualified PostgREST.Plan as Plan
import qualified PostgREST.Query as Query import qualified PostgREST.Query as Query
import qualified PostgREST.Response as Response import qualified PostgREST.Response as Response
import qualified PostgREST.Unix as Unix (installSignalHandlers)
import PostgREST.ApiRequest (Action (..), ApiRequest (..), import PostgREST.ApiRequest (Action (..), ApiRequest (..),
Mutation (..), Target (..)) Mutation (..), Target (..))
import PostgREST.AppState (AppState) import PostgREST.AppState (AppState)
import PostgREST.Auth (AuthResult (..)) import PostgREST.Auth (AuthResult (..))
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.Config.PgVersion (PgVersion (..)) import PostgREST.Config.PgVersion (PgVersion (..))
import PostgREST.Error (Error) import PostgREST.Error (Error)
import PostgREST.Query (DbHandler) import PostgREST.Query (DbHandler)
import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.Response.Performance (ServerTiming (..),
import PostgREST.SchemaCache.Routine (Routine (..)) serverTimingHeader)
import PostgREST.Version (docsVersion, prettyVersion) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Routine (Routine (..))
import PostgREST.Version (docsVersion, prettyVersion)
import Protolude hiding (Handler) import qualified Data.ByteString.Char8 as BS
import qualified Data.List as L
import qualified Network.HTTP.Types as HTTP
import qualified Network.Socket as NS
import Protolude hiding (Handler)
import System.TimeIt (timeItT)
type Handler = ExceptT Error type Handler = ExceptT Error
type SignalHandlerInstaller = AppState -> IO() run :: AppState -> IO ()
run appState = do
type SocketRunner = Warp.Settings -> Wai.Application -> FileMode -> FilePath -> IO()
run :: SignalHandlerInstaller -> Maybe SocketRunner -> AppState -> IO ()
run installHandlers maybeRunWithSocket appState = do
conf@AppConfig{..} <- AppState.getConfig appState conf@AppConfig{..} <- AppState.getConfig appState
AppState.connectionWorker appState -- Loads the initial SchemaCache AppState.connectionWorker appState -- Loads the initial SchemaCache
installHandlers appState Unix.installSignalHandlers (AppState.getMainThreadId appState) (AppState.connectionWorker appState) (AppState.reReadConfig False appState)
-- reload schema cache + config on NOTIFY -- reload schema cache + config on NOTIFY
AppState.runListener conf appState AppState.runListener conf appState
@@ -76,19 +78,14 @@ run installHandlers maybeRunWithSocket appState = do
let app = postgrest conf appState (AppState.connectionWorker appState) let app = postgrest conf appState (AppState.connectionWorker appState)
case configServerUnixSocket of what <- case configServerUnixSocket of
Just socket -> Just path -> pure $ "unix socket " <> show path
-- run the postgrest application with user defined socket. Only for UNIX systems Nothing -> do
case maybeRunWithSocket of port <- NS.socketPort $ AppState.getSocketREST appState
Just runWithSocket -> do pure $ "port " <> show port
AppState.logWithZTime appState $ "Listening on unix socket " <> show socket AppState.logWithZTime appState $ "Listening on " <> what
runWithSocket (serverSettings conf) app configServerUnixSocketMode socket
Nothing -> Warp.runSettingsSocket (serverSettings conf) (AppState.getSocketREST appState) app
panic "Cannot run with unix socket on non-unix platforms."
Nothing ->
do
AppState.logWithZTime appState $ "Listening on port " <> show configServerPort
Warp.runSettings (serverSettings conf) app
serverSettings :: AppConfig -> Warp.Settings serverSettings :: AppConfig -> Warp.Settings
serverSettings AppConfig{..} = serverSettings AppConfig{..} =
@@ -100,8 +97,8 @@ serverSettings AppConfig{..} =
-- | PostgREST application -- | PostgREST application
postgrest :: AppConfig -> AppState.AppState -> IO () -> Wai.Application postgrest :: AppConfig -> AppState.AppState -> IO () -> Wai.Application
postgrest conf appState connWorker = postgrest conf appState connWorker =
Response.traceHeaderMiddleware conf . traceHeaderMiddleware conf .
Cors.middleware . Cors.middleware (configServerCorsAllowedOrigins conf) .
Auth.middleware appState . Auth.middleware appState .
Logger.middleware (configLogLevel conf) $ Logger.middleware (configLogLevel conf) $
-- fromJust can be used, because the auth middleware will **always** add -- fromJust can be used, because the auth middleware will **always** add
@@ -122,10 +119,10 @@ postgrest conf appState connWorker =
-- Launch the connWorker when the connection is down. The postgrest -- Launch the connWorker when the connection is down. The postgrest
-- function can respond successfully (with a stale schema cache) before -- function can respond successfully (with a stale schema cache) before
-- the connWorker is done. -- the connWorker is done.
when (Response.isServiceUnavailable response) connWorker when (isServiceUnavailable response) connWorker
resp <- do resp <- do
delay <- AppState.getRetryNextIn appState delay <- AppState.getRetryNextIn appState
return $ Response.addRetryHint delay response return $ addRetryHint delay response
respond resp respond resp
postgrestResponse postgrestResponse
@@ -146,18 +143,19 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache pgVer authResult@
body <- lift $ Wai.strictRequestBody req body <- lift $ Wai.strictRequestBody req
apiRequest <- (parseTime, apiRequest) <-
liftEither . mapLeft Error.ApiRequestError $ calcTiming configServerTimingEnabled $
ApiRequest.userApiRequest conf req body liftEither . mapLeft Error.ApiRequestError $
ApiRequest.userApiRequest conf req body sCache
Response.optionalRollback conf apiRequest $ let jwtTime = if configServerTimingEnabled then Auth.getJwtDur req else Nothing
handleRequest authResult conf appState (Just authRole /= configDbAnonRole) configDbPreparedStatements pgVer apiRequest sCache handleRequest authResult conf appState (Just authRole /= configDbAnonRole) configDbPreparedStatements pgVer apiRequest sCache jwtTime parseTime
runDbHandler :: AppState.AppState -> SQL.IsolationLevel -> SQL.Mode -> Bool -> Bool -> DbHandler b -> Handler IO b runDbHandler :: AppState.AppState -> AppConfig -> SQL.IsolationLevel -> SQL.Mode -> Bool -> Bool -> DbHandler b -> Handler IO b
runDbHandler appState isoLvl mode authenticated prepared handler = do runDbHandler appState config isoLvl mode authenticated prepared handler = do
dbResp <- lift $ do dbResp <- lift $ do
let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction
AppState.usePool appState . transaction isoLvl mode $ runExceptT handler AppState.usePool appState config . transaction isoLvl mode $ runExceptT handler
resp <- resp <-
liftEither . mapLeft Error.PgErr $ liftEither . mapLeft Error.PgErr $
@@ -165,52 +163,63 @@ runDbHandler appState isoLvl mode authenticated prepared handler = do
liftEither resp liftEither resp
handleRequest :: AuthResult -> AppConfig -> AppState.AppState -> Bool -> Bool -> PgVersion -> ApiRequest -> SchemaCache -> Handler IO Wai.Response handleRequest :: AuthResult -> AppConfig -> AppState.AppState -> Bool -> Bool -> PgVersion -> ApiRequest -> SchemaCache -> Maybe Double -> Maybe Double -> Handler IO Wai.Response
handleRequest AuthResult{..} conf appState authenticated prepared pgVer apiReq@ApiRequest{..} sCache = handleRequest AuthResult{..} conf appState authenticated prepared pgVer apiReq@ApiRequest{..} sCache jwtTime parseTime =
case (iAction, iTarget) of case (iAction, iTarget) of
(ActionRead headersOnly, TargetIdent identifier) -> do (ActionRead headersOnly, TargetIdent identifier) -> do
wrPlan <- liftEither $ Plan.wrappedReadPlan identifier conf sCache apiReq (planTime', wrPlan) <- withTiming $ liftEither $ Plan.wrappedReadPlan identifier conf sCache apiReq
resultSet <- runQuery roleIsoLvl (Plan.wrTxMode wrPlan) $ Query.readQuery wrPlan conf apiReq (txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.wrTxMode wrPlan) $ Query.readQuery wrPlan conf apiReq
return $ Response.readResponse headersOnly identifier apiReq resultSet (respTime', pgrst) <- withTiming $ liftEither $ Response.readResponse wrPlan headersOnly identifier apiReq resultSet
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
(ActionMutate MutationCreate, TargetIdent identifier) -> do (ActionMutate MutationCreate, TargetIdent identifier) -> do
mrPlan <- liftEither $ Plan.mutateReadPlan MutationCreate apiReq identifier conf sCache (planTime', mrPlan) <- withTiming $ liftEither $ Plan.mutateReadPlan MutationCreate apiReq identifier conf sCache
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.createQuery mrPlan apiReq conf (txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.mrTxMode mrPlan) $ Query.createQuery mrPlan apiReq conf
return $ Response.createResponse identifier mrPlan apiReq resultSet (respTime', pgrst) <- withTiming $ liftEither $ Response.createResponse identifier mrPlan apiReq resultSet
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
(ActionMutate MutationUpdate, TargetIdent identifier) -> do (ActionMutate MutationUpdate, TargetIdent identifier) -> do
mrPlan <- liftEither $ Plan.mutateReadPlan MutationUpdate apiReq identifier conf sCache (planTime', mrPlan) <- withTiming $ liftEither $ Plan.mutateReadPlan MutationUpdate apiReq identifier conf sCache
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.updateQuery mrPlan apiReq conf (txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.mrTxMode mrPlan) $ Query.updateQuery mrPlan apiReq conf
return $ Response.updateResponse apiReq resultSet (respTime', pgrst) <- withTiming $ liftEither $ Response.updateResponse mrPlan apiReq resultSet
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
(ActionMutate MutationSingleUpsert, TargetIdent identifier) -> do (ActionMutate MutationSingleUpsert, TargetIdent identifier) -> do
mrPlan <- liftEither $ Plan.mutateReadPlan MutationSingleUpsert apiReq identifier conf sCache (planTime', mrPlan) <- withTiming $ liftEither $ Plan.mutateReadPlan MutationSingleUpsert apiReq identifier conf sCache
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.singleUpsertQuery mrPlan apiReq conf (txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.mrTxMode mrPlan) $ Query.singleUpsertQuery mrPlan apiReq conf
return $ Response.singleUpsertResponse apiReq resultSet (respTime', pgrst) <- withTiming $ liftEither $ Response.singleUpsertResponse mrPlan apiReq resultSet
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
(ActionMutate MutationDelete, TargetIdent identifier) -> do (ActionMutate MutationDelete, TargetIdent identifier) -> do
mrPlan <- liftEither $ Plan.mutateReadPlan MutationDelete apiReq identifier conf sCache (planTime', mrPlan) <- withTiming $ liftEither $ Plan.mutateReadPlan MutationDelete apiReq identifier conf sCache
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.deleteQuery mrPlan apiReq conf (txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.mrTxMode mrPlan) $ Query.deleteQuery mrPlan apiReq conf
return $ Response.deleteResponse apiReq resultSet (respTime', pgrst) <- withTiming $ liftEither $ Response.deleteResponse mrPlan apiReq resultSet
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
(ActionInvoke invMethod, TargetProc identifier _) -> do (ActionInvoke invMethod, TargetProc identifier _) -> do
cPlan <- liftEither $ Plan.callReadPlan identifier conf sCache apiReq invMethod (planTime', cPlan) <- withTiming $ liftEither $ Plan.callReadPlan identifier conf sCache apiReq invMethod
resultSet <- runQuery (fromMaybe roleIsoLvl $ pdIsoLvl (Plan.crProc cPlan))(Plan.crTxMode cPlan) $ Query.invokeQuery (Plan.crProc cPlan) cPlan apiReq conf pgVer (txTime', resultSet) <- withTiming $ runQuery (fromMaybe roleIsoLvl $ pdIsoLvl (Plan.crProc cPlan)) (pdTimeout $ Plan.crProc cPlan) (Plan.crTxMode cPlan) $ Query.invokeQuery (Plan.crProc cPlan) cPlan apiReq conf pgVer
return $ Response.invokeResponse invMethod (Plan.crProc cPlan) apiReq resultSet (respTime', pgrst) <- withTiming $ liftEither $ Response.invokeResponse cPlan invMethod (Plan.crProc cPlan) apiReq resultSet
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do (ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do
oaiResult <- runQuery roleIsoLvl Plan.inspectPlanTxMode $ Query.openApiQuery sCache pgVer conf tSchema (planTime', iPlan) <- withTiming $ liftEither $ Plan.inspectPlan apiReq
return $ Response.openApiResponse (T.decodeUtf8 prettyVersion, docsVersion) headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile (txTime', oaiResult) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.ipTxmode iPlan) $ Query.openApiQuery sCache pgVer conf tSchema
(respTime', pgrst) <- withTiming $ liftEither $ Response.openApiResponse (T.decodeUtf8 prettyVersion, docsVersion) headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
(ActionInfo, TargetIdent identifier) -> (ActionInfo, TargetIdent identifier) -> do
return $ Response.infoIdentResponse identifier sCache (respTime', pgrst) <- withTiming $ liftEither $ Response.infoIdentResponse identifier sCache
return $ pgrstResponse (ServerTiming jwtTime parseTime Nothing Nothing respTime') pgrst
(ActionInfo, TargetProc identifier _) -> do (ActionInfo, TargetProc identifier _) -> do
cPlan <- liftEither $ Plan.callReadPlan identifier conf sCache apiReq ApiRequest.InvHead (planTime', cPlan) <- withTiming $ liftEither $ Plan.callReadPlan identifier conf sCache apiReq ApiRequest.InvHead
return $ Response.infoProcResponse (Plan.crProc cPlan) (respTime', pgrst) <- withTiming $ liftEither $ Response.infoProcResponse (Plan.crProc cPlan)
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' Nothing respTime') pgrst
(ActionInfo, TargetDefaultSpec _) -> (ActionInfo, TargetDefaultSpec _) -> do
return Response.infoRootResponse (respTime', pgrst) <- withTiming $ liftEither Response.infoRootResponse
return $ pgrstResponse (ServerTiming jwtTime parseTime Nothing Nothing respTime') pgrst
_ -> _ ->
-- This is unreachable as the ApiRequest.hs rejects it before -- This is unreachable as the ApiRequest.hs rejects it before
@@ -219,8 +228,38 @@ handleRequest AuthResult{..} conf appState authenticated prepared pgVer apiReq@A
where where
roleSettings = fromMaybe mempty (HM.lookup authRole $ configRoleSettings conf) roleSettings = fromMaybe mempty (HM.lookup authRole $ configRoleSettings conf)
roleIsoLvl = HM.findWithDefault SQL.ReadCommitted authRole $ configRoleIsoLvl conf roleIsoLvl = HM.findWithDefault SQL.ReadCommitted authRole $ configRoleIsoLvl conf
runQuery isoLvl mode query = runQuery isoLvl timeout mode query =
runDbHandler appState isoLvl mode authenticated prepared $ do runDbHandler appState conf isoLvl mode authenticated prepared $ do
Query.setPgLocals conf authClaims authRole (HM.toList roleSettings) apiReq pgVer Query.setPgLocals conf authClaims authRole (HM.toList roleSettings) apiReq timeout
Query.runPreReq conf Query.runPreReq conf
query query
pgrstResponse :: ServerTiming -> Response.PgrstResponse -> Wai.Response
pgrstResponse timing (Response.PgrstResponse st hdrs bod) = Wai.responseLBS st (hdrs ++ ([serverTimingHeader timing | configServerTimingEnabled conf])) bod
withTiming = calcTiming $ configServerTimingEnabled conf
calcTiming :: Bool -> Handler IO a -> Handler IO (Maybe Double, a)
calcTiming timingEnabled f = if timingEnabled
then do
(t, r) <- timeItT f
pure (Just t, r)
else do
r <- f
pure (Nothing, r)
traceHeaderMiddleware :: AppConfig -> Wai.Middleware
traceHeaderMiddleware AppConfig{configServerTraceHeader} app req respond =
case configServerTraceHeader of
Nothing -> app req respond
Just hdr ->
let hdrVal = L.lookup hdr $ Wai.requestHeaders req in
app req (respond . Wai.mapResponseHeaders ([(hdr, fromMaybe mempty hdrVal)] ++))
addRetryHint :: Int -> Wai.Response -> Wai.Response
addRetryHint delay response = do
let h = ("Retry-After", BS.pack $ show delay)
Wai.mapResponseHeaders (\hs -> if isServiceUnavailable response then h:hs else hs) response
isServiceUnavailable :: Wai.Response -> Bool
isServiceUnavailable response = Wai.responseStatus response == HTTP.status503
+107 -22
View File
@@ -4,6 +4,7 @@
module PostgREST.AppState module PostgREST.AppState
( AppState ( AppState
, AuthResult(..)
, destroy , destroy
, getConfig , getConfig
, getSchemaCache , getSchemaCache
@@ -12,7 +13,11 @@ module PostgREST.AppState
, getPgVersion , getPgVersion
, getRetryNextIn , getRetryNextIn
, getTime , getTime
, getJwtCache
, getSocketREST
, getSocketAdmin
, init , init
, initSockets
, initWithPool , initWithPool
, logWithZTime , logWithZTime
, putSchemaCache , putSchemaCache
@@ -24,15 +29,21 @@ module PostgREST.AppState
, runListener , runListener
) where ) where
import qualified Data.Aeson as JSON
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import qualified Data.Cache as C
import Data.Either.Combinators (whenLeft) import Data.Either.Combinators (whenLeft)
import qualified Data.Text as T (unpack)
import qualified Data.Text.Encoding as T import qualified Data.Text.Encoding as T
import Hasql.Connection (acquire) import Hasql.Connection (acquire)
import qualified Hasql.Notifications as SQL import qualified Hasql.Notifications as SQL
import qualified Hasql.Pool as SQL import qualified Hasql.Pool as SQL
import qualified Hasql.Session as SQL import qualified Hasql.Session as SQL
import qualified Hasql.Transaction.Sessions as SQL import qualified Hasql.Transaction.Sessions as SQL
import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.Socket as NS
import qualified PostgREST.Error as Error import qualified PostgREST.Error as Error
import PostgREST.Version (prettyVersion) import PostgREST.Version (prettyVersion)
@@ -48,6 +59,7 @@ import Data.Time (ZonedTime, defaultTimeLocale, formatTime,
import Data.Time.Clock (UTCTime, getCurrentTime) import Data.Time.Clock (UTCTime, getCurrentTime)
import PostgREST.Config (AppConfig (..), import PostgREST.Config (AppConfig (..),
LogLevel (..),
addFallbackAppName, addFallbackAppName,
readAppConfig) readAppConfig)
import PostgREST.Config.Database (queryDbSettings, import PostgREST.Config.Database (queryDbSettings,
@@ -58,9 +70,16 @@ import PostgREST.Config.PgVersion (PgVersion (..),
import PostgREST.SchemaCache (SchemaCache, import PostgREST.SchemaCache (SchemaCache,
querySchemaCache) querySchemaCache)
import PostgREST.SchemaCache.Identifiers (dumpQi) import PostgREST.SchemaCache.Identifiers (dumpQi)
import PostgREST.Unix (createAndBindDomainSocket)
import Data.Streaming.Network (bindPortTCP, bindRandomPortTCP)
import Data.String (IsString (..))
import Protolude import Protolude
data AuthResult = AuthResult
{ authClaims :: KM.KeyMap JSON.Value
, authRole :: BS.ByteString
}
data AppState = AppState data AppState = AppState
-- | Database connection pool -- | Database connection pool
@@ -87,15 +106,25 @@ data AppState = AppState
, stateRetryNextIn :: IORef Int , stateRetryNextIn :: IORef Int
-- | Logs a pool error with a debounce -- | Logs a pool error with a debounce
, debounceLogAcquisitionTimeout :: IO () , debounceLogAcquisitionTimeout :: IO ()
-- | JWT Cache
, jwtCache :: C.Cache ByteString AuthResult
-- | Network socket for REST API
, stateSocketREST :: NS.Socket
-- | Network socket for the admin UI
, stateSocketAdmin :: Maybe NS.Socket
} }
type AppSockets = (NS.Socket, Maybe NS.Socket)
init :: AppConfig -> IO AppState init :: AppConfig -> IO AppState
init conf = do init conf = do
pool <- initPool conf pool <- initPool conf
initWithPool pool conf (sock, adminSock) <- initSockets conf
state' <- initWithPool (sock, adminSock) pool conf
pure state' { stateSocketREST = sock, stateSocketAdmin = adminSock }
initWithPool :: SQL.Pool -> AppConfig -> IO AppState initWithPool :: AppSockets -> SQL.Pool -> AppConfig -> IO AppState
initWithPool pool conf = do initWithPool (sock, adminSock) pool conf = do
appState <- AppState pool appState <- AppState pool
<$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step <$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step
<*> newIORef Nothing <*> newIORef Nothing
@@ -108,6 +137,9 @@ initWithPool pool conf = do
<*> myThreadId <*> myThreadId
<*> newIORef 0 <*> newIORef 0
<*> pure (pure ()) <*> pure (pure ())
<*> C.newCache Nothing
<*> pure sock
<*> pure adminSock
debLogTimeout <- debLogTimeout <-
@@ -131,6 +163,39 @@ initWithPool pool conf = do
destroy :: AppState -> IO () destroy :: AppState -> IO ()
destroy = destroyPool destroy = destroyPool
initSockets :: AppConfig -> IO AppSockets
initSockets AppConfig{..} = do
let
cfg'usp = configServerUnixSocket
cfg'uspm = configServerUnixSocketMode
cfg'host = configServerHost
cfg'port = configServerPort
cfg'adminport = configAdminServerPort
sock <- case cfg'usp of
-- I'm not using `streaming-commons`' bindPath function here because it's not defined for Windows,
-- but we need to have runtime error if we try to use it in Windows, not compile time error
Just path -> createAndBindDomainSocket path cfg'uspm
Nothing -> do
(_, sock) <-
if cfg'port /= 0
then do
sock <- bindPortTCP cfg'port (fromString $ T.unpack cfg'host)
pure (cfg'port, sock)
else do
-- explicitly bind to a random port, returning bound port number
(num, sock) <- bindRandomPortTCP (fromString $ T.unpack cfg'host)
pure (num, sock)
pure sock
adminSock <- case cfg'adminport of
Just adminPort -> do
adminSock <- bindPortTCP adminPort (fromString $ T.unpack cfg'host)
pure $ Just adminSock
Nothing -> pure Nothing
pure (sock, adminSock)
initPool :: AppConfig -> IO SQL.Pool initPool :: AppConfig -> IO SQL.Pool
initPool AppConfig{..} = initPool AppConfig{..} =
SQL.acquire SQL.acquire
@@ -141,12 +206,18 @@ initPool AppConfig{..} =
(toUtf8 $ addFallbackAppName prettyVersion configDbUri) (toUtf8 $ addFallbackAppName prettyVersion configDbUri)
-- | Run an action with a database connection. -- | Run an action with a database connection.
usePool :: AppState -> SQL.Session a -> IO (Either SQL.UsageError a) usePool :: AppState -> AppConfig -> SQL.Session a -> IO (Either SQL.UsageError a)
usePool AppState{..} x = do usePool appState@AppState{..} AppConfig{configLogLevel} x = do
res <- SQL.use statePool x res <- SQL.use statePool x
whenLeft res (\case
SQL.AcquisitionTimeoutUsageError -> debounceLogAcquisitionTimeout -- this can happen rapidly for many requests, so we debounce when (configLogLevel > LogCrit) $ do
_ -> pure ()) whenLeft res (\case
SQL.AcquisitionTimeoutUsageError -> debounceLogAcquisitionTimeout -- this can happen rapidly for many requests, so we debounce
error
-- TODO We're using the 500 HTTP status for getting all internal db errors but there's no response here. We need a new intermediate type to not rely on the HTTP status.
| Error.status (Error.PgError False error) >= HTTP.status500 -> logPgrstError appState error
| otherwise -> pure ())
return res return res
-- | Flush the connection pool so that any future use of the pool will -- | Flush the connection pool so that any future use of the pool will
@@ -188,6 +259,15 @@ putConfig = atomicWriteIORef . stateConf
getTime :: AppState -> IO UTCTime getTime :: AppState -> IO UTCTime
getTime = stateGetTime getTime = stateGetTime
getJwtCache :: AppState -> C.Cache ByteString AuthResult
getJwtCache = jwtCache
getSocketREST :: AppState -> NS.Socket
getSocketREST = stateSocketREST
getSocketAdmin :: AppState -> Maybe NS.Socket
getSocketAdmin = stateSocketAdmin
-- | Log to stderr with local time -- | Log to stderr with local time
logWithZTime :: AppState -> Text -> IO () logWithZTime :: AppState -> Text -> IO ()
logWithZTime appState txt = do logWithZTime appState txt = do
@@ -229,7 +309,7 @@ loadSchemaCache appState = do
conf@AppConfig{..} <- getConfig appState conf@AppConfig{..} <- getConfig appState
result <- result <-
let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
usePool appState . transaction SQL.ReadCommitted SQL.Read $ usePool appState conf . transaction SQL.ReadCommitted SQL.Read $
querySchemaCache conf querySchemaCache conf
case result of case result of
Left e -> do Left e -> do
@@ -272,17 +352,18 @@ internalConnectionWorker :: AppState -> IO ()
internalConnectionWorker appState = work internalConnectionWorker appState = work
where where
work = do work = do
AppConfig{..} <- getConfig appState config@AppConfig{..} <- getConfig appState
logWithZTime appState $ "Starting PostgREST " <> T.decodeUtf8 prettyVersion <> "..." logWithZTime appState $ "Starting PostgREST " <> T.decodeUtf8 prettyVersion <> "..."
logWithZTime appState "Attempting to connect to the database..." logWithZTime appState "Attempting to connect to the database..."
connected <- establishConnection appState connected <- establishConnection appState config
case connected of case connected of
FatalConnectionError reason -> FatalConnectionError reason ->
-- Fatal error when connecting -- Fatal error when connecting
logWithZTime appState reason >> killThread (getMainThreadId appState) logWithZTime appState reason >> killThread (getMainThreadId appState)
NotConnected -> NotConnected ->
-- Unreachable because establishConnection will keep trying to connect -- Unreachable because establishConnection will keep trying to connect, unless disable-recovery is turned on
return () unless configDbPoolAutomaticRecovery
$ logWithZTime appState "Automatic recovery disabled, exiting." >> killThread (getMainThreadId appState)
Connected actualPgVersion -> do Connected actualPgVersion -> do
-- Procede with initialization -- Procede with initialization
putPgVersion appState actualPgVersion putPgVersion appState actualPgVersion
@@ -314,8 +395,8 @@ internalConnectionWorker appState = work
-- --
-- The connection tries are capped, but if the connection times out no error is -- The connection tries are capped, but if the connection times out no error is
-- thrown, just 'False' is returned. -- thrown, just 'False' is returned.
establishConnection :: AppState -> IO ConnectionStatus establishConnection :: AppState -> AppConfig -> IO ConnectionStatus
establishConnection appState = establishConnection appState config =
retrying retrySettings shouldRetry $ retrying retrySettings shouldRetry $
const $ flushPool appState >> getConnectionStatus const $ flushPool appState >> getConnectionStatus
where where
@@ -325,7 +406,7 @@ establishConnection appState =
getConnectionStatus :: IO ConnectionStatus getConnectionStatus :: IO ConnectionStatus
getConnectionStatus = do getConnectionStatus = do
pgVersion <- usePool appState $ queryPgVersion False -- No need to prepare the query here, as the connection might not be established pgVersion <- usePool appState config $ queryPgVersion False -- No need to prepare the query here, as the connection might not be established
case pgVersion of case pgVersion of
Left e -> do Left e -> do
logPgrstError appState e logPgrstError appState e
@@ -344,9 +425,10 @@ establishConnection appState =
shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool
shouldRetry rs isConnSucc = do shouldRetry rs isConnSucc = do
AppConfig{..} <- getConfig appState
let let
delay = fromMaybe 0 (rsPreviousDelay rs) `div` backoffMicroseconds delay = fromMaybe 0 (rsPreviousDelay rs) `div` backoffMicroseconds
itShould = NotConnected == isConnSucc itShould = NotConnected == isConnSucc && configDbPoolAutomaticRecovery
when itShould . logWithZTime appState $ when itShould . logWithZTime appState $
"Attempting to reconnect to the database in " "Attempting to reconnect to the database in "
<> (show delay::Text) <> (show delay::Text)
@@ -357,10 +439,11 @@ establishConnection appState =
-- | Re-reads the config plus config options from the db -- | Re-reads the config plus config options from the db
reReadConfig :: Bool -> AppState -> IO () reReadConfig :: Bool -> AppState -> IO ()
reReadConfig startingUp appState = do reReadConfig startingUp appState = do
AppConfig{..} <- getConfig appState config@AppConfig{..} <- getConfig appState
pgVer <- getPgVersion appState
dbSettings <- dbSettings <-
if configDbConfig then do if configDbConfig then do
qDbSettings <- usePool appState $ queryDbSettings (dumpQi <$> configDbPreConfig) configDbPreparedStatements qDbSettings <- usePool appState config $ queryDbSettings (dumpQi <$> configDbPreConfig) configDbPreparedStatements
case qDbSettings of case qDbSettings of
Left e -> do Left e -> do
logWithZTime appState logWithZTime appState
@@ -378,7 +461,7 @@ reReadConfig startingUp appState = do
pure mempty pure mempty
(roleSettings, roleIsolationLvl) <- (roleSettings, roleIsolationLvl) <-
if configDbConfig then do if configDbConfig then do
rSettings <- usePool appState $ queryRoleSettings configDbPreparedStatements rSettings <- usePool appState config $ queryRoleSettings pgVer configDbPreparedStatements
case rSettings of case rSettings of
Left e -> do Left e -> do
logWithZTime appState "An error ocurred when trying to query the role settings" logWithZTime appState "An error ocurred when trying to query the role settings"
@@ -420,7 +503,7 @@ listener appState = do
waitListener appState waitListener appState
-- forkFinally allows to detect if the thread dies -- forkFinally allows to detect if the thread dies
void . flip forkFinally (handleFinally dbChannel) $ do void . flip forkFinally (handleFinally dbChannel configDbPoolAutomaticRecovery) $ do
dbOrError <- acquire $ toUtf8 (addFallbackAppName prettyVersion configDbUri) dbOrError <- acquire $ toUtf8 (addFallbackAppName prettyVersion configDbUri)
case dbOrError of case dbOrError of
Right db -> do Right db -> do
@@ -431,7 +514,9 @@ listener appState = do
_ -> _ ->
die $ "Could not listen for notifications on the " <> dbChannel <> " channel" die $ "Could not listen for notifications on the " <> dbChannel <> " channel"
where where
handleFinally dbChannel _ = do handleFinally _ False _ =
logWithZTime appState "Automatic recovery disabled, exiting." >> killThread (getMainThreadId appState)
handleFinally dbChannel True _ = do
-- if the thread dies, we try to recover -- if the thread dies, we try to recover
logWithZTime appState $ "Retrying listening for notifications on the " <> dbChannel <> " channel.." logWithZTime appState $ "Retrying listening for notifications on the " <> dbChannel <> " channel.."
putIsListenerOn appState False putIsListenerOn appState False
+59 -12
View File
@@ -14,6 +14,7 @@ very simple authentication system inside the PostgreSQL database.
module PostgREST.Auth module PostgREST.Auth
( AuthResult (..) ( AuthResult (..)
, getResult , getResult
, getJwtDur
, getRole , getRole
, middleware , middleware
) where ) where
@@ -25,6 +26,8 @@ import qualified Data.Aeson.KeyMap as KM
import qualified Data.Aeson.Types as JSON import qualified Data.Aeson.Types as JSON
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as LBS import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.Cache as C
import qualified Data.Scientific as Sci
import qualified Data.Vault.Lazy as Vault import qualified Data.Vault.Lazy as Vault
import qualified Data.Vector as V import qualified Data.Vector as V
import qualified Network.HTTP.Types.Header as HTTP import qualified Network.HTTP.Types.Header as HTTP
@@ -35,21 +38,20 @@ import Control.Lens (set)
import Control.Monad.Except (liftEither) import Control.Monad.Except (liftEither)
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft)
import Data.List (lookup) import Data.List (lookup)
import Data.Time.Clock (UTCTime) import Data.Time.Clock (UTCTime, nominalDiffTimeToSeconds)
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
import System.Clock (TimeSpec (..))
import System.IO.Unsafe (unsafePerformIO) import System.IO.Unsafe (unsafePerformIO)
import System.TimeIt (timeItT)
import PostgREST.AppState (AppState, getConfig, getTime) import PostgREST.AppState (AppState, AuthResult (..), getConfig,
getJwtCache, getTime)
import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..)) import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..))
import PostgREST.Error (Error (..)) import PostgREST.Error (Error (..))
import Protolude import Protolude
data AuthResult = AuthResult
{ authClaims :: KM.KeyMap JSON.Value
, authRole :: BS.ByteString
}
-- | Receives the JWT secret and audience (from config) and a JWT and returns a -- | Receives the JWT secret and audience (from config) and a JWT and returns a
-- JSON object of JWT claims. -- JSON object of JWT claims.
parseToken :: Monad m => parseToken :: Monad m =>
@@ -102,14 +104,52 @@ middleware appState app req respond = do
conf <- getConfig appState conf <- getConfig appState
time <- getTime appState time <- getTime appState
let token = fromMaybe "" $ Wai.extractBearerAuth =<< lookup HTTP.hAuthorization (Wai.requestHeaders req) let token = fromMaybe "" $ Wai.extractBearerAuth =<< lookup HTTP.hAuthorization (Wai.requestHeaders req)
authResult <- runExceptT $ parseJwt = runExceptT $ parseToken conf (LBS.fromStrict token) time >>= parseClaims conf
parseToken conf (LBS.fromStrict token) time >>=
parseClaims conf -- If DbPlanEnabled -> calculate JWT validation time
-- If JwtCacheMaxLifetime -> cache JWT validation result
req' <- case (configServerTimingEnabled conf, configJwtCacheMaxLifetime conf) of
(True, 0) -> do
(dur, authResult) <- timeItT parseJwt
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
(True, maxLifetime) -> do
(dur, authResult) <- timeItT $ getJWTFromCache appState token maxLifetime parseJwt time
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
(False, 0) -> do
authResult <- parseJwt
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
(False, maxLifetime) -> do
authResult <- getJWTFromCache appState token maxLifetime parseJwt time
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
let req' = req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
app req' respond app req' respond
-- | Used to retrieve and insert JWT to JWT Cache
getJWTFromCache :: AppState -> ByteString -> Int -> IO (Either Error AuthResult) -> UTCTime -> IO (Either Error AuthResult)
getJWTFromCache appState token maxLifetime parseJwt utc = do
checkCache <- C.lookup (getJwtCache appState) token
authResult <- maybe parseJwt (pure . Right) checkCache
case (authResult,checkCache) of
(Right res, Nothing) -> C.insert' (getJwtCache appState) (getTimeSpec res maxLifetime utc) token res
_ -> pure ()
return authResult
-- Used to extract JWT exp claim and add to JWT Cache
getTimeSpec :: AuthResult -> Int -> UTCTime -> Maybe TimeSpec
getTimeSpec res maxLifetime utc = do
let expireJSON = KM.lookup "exp" (authClaims res)
utcToSecs = floor . nominalDiffTimeToSeconds . utcTimeToPOSIXSeconds
sciToInt = fromMaybe 0 . Sci.toBoundedInteger
case expireJSON of
Just (JSON.Number seconds) -> Just $ TimeSpec (sciToInt seconds - utcToSecs utc) 0
_ -> Just $ TimeSpec (fromIntegral maxLifetime :: Int64) 0
authResultKey :: Vault.Key (Either Error AuthResult) authResultKey :: Vault.Key (Either Error AuthResult)
authResultKey = unsafePerformIO Vault.newKey authResultKey = unsafePerformIO Vault.newKey
{-# NOINLINE authResultKey #-} {-# NOINLINE authResultKey #-}
@@ -117,5 +157,12 @@ authResultKey = unsafePerformIO Vault.newKey
getResult :: Wai.Request -> Maybe (Either Error AuthResult) getResult :: Wai.Request -> Maybe (Either Error AuthResult)
getResult = Vault.lookup authResultKey . Wai.vault getResult = Vault.lookup authResultKey . Wai.vault
jwtDurKey :: Vault.Key Double
jwtDurKey = unsafePerformIO Vault.newKey
{-# NOINLINE jwtDurKey #-}
getJwtDur :: Wai.Request -> Maybe Double
getJwtDur = Vault.lookup jwtDurKey . Wai.vault
getRole :: Wai.Request -> Maybe BS.ByteString getRole :: Wai.Request -> Maybe BS.ByteString
getRole req = authRole <$> (rightToMaybe =<< getResult req) getRole req = authRole <$> (rightToMaybe =<< getResult req)
+15 -10
View File
@@ -29,8 +29,8 @@ import qualified PostgREST.Config as Config
import Protolude hiding (hPutStrLn) import Protolude hiding (hPutStrLn)
main :: App.SignalHandlerInstaller -> Maybe App.SocketRunner -> CLI -> IO () main :: CLI -> IO ()
main installSignalHandlers runAppWithSocket CLI{cliCommand, cliPath} = do main CLI{cliCommand, cliPath} = do
conf@AppConfig{..} <- conf@AppConfig{..} <-
either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty
@@ -45,7 +45,7 @@ main installSignalHandlers runAppWithSocket CLI{cliCommand, cliPath} = do
when configDbConfig $ AppState.reReadConfig True appState when configDbConfig $ AppState.reReadConfig True appState
putStr . Config.toText =<< AppState.getConfig appState putStr . Config.toText =<< AppState.getConfig appState
CmdDumpSchema -> putStrLn =<< dumpSchema appState CmdDumpSchema -> putStrLn =<< dumpSchema appState
CmdRun -> App.run installSignalHandlers runAppWithSocket appState) CmdRun -> App.run appState)
-- | Dump SchemaCache schema to JSON -- | Dump SchemaCache schema to JSON
dumpSchema :: AppState -> IO LBS.ByteString dumpSchema :: AppState -> IO LBS.ByteString
@@ -53,7 +53,7 @@ dumpSchema appState = do
conf@AppConfig{..} <- AppState.getConfig appState conf@AppConfig{..} <- AppState.getConfig appState
result <- result <-
let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
AppState.usePool appState $ AppState.usePool appState conf $
transaction SQL.ReadCommitted SQL.Read $ transaction SQL.ReadCommitted SQL.Read $
querySchemaCache conf querySchemaCache conf
case result of case result of
@@ -162,6 +162,9 @@ exampleConfigFile =
|## Time in seconds after which to recycle unused pool connections |## Time in seconds after which to recycle unused pool connections
|# db-pool-max-idletime = 30 |# db-pool-max-idletime = 30
| |
|## Allow automatic database connection retrying
|# db-pool-automatic-recovery = true
|
|## Stored proc to exec immediately after auth |## Stored proc to exec immediately after auth
|# db-pre-request = "stored_proc_name" |# db-pre-request = "stored_proc_name"
| |
@@ -188,10 +191,6 @@ exampleConfigFile =
|## https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING |## https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
|db-uri = "postgresql://" |db-uri = "postgresql://"
| |
|## Determine if GUC request settings for headers, cookies and jwt claims use the legacy names (string with dashes, invalid starting from PostgreSQL v14) with text values instead of the new names (string without dashes, valid on all PostgreSQL versions) with json values.
|## For PostgreSQL v14 and up, this setting will be ignored.
|db-use-legacy-gucs = true
|
|# jwt-aud = "your_audience_claim" |# jwt-aud = "your_audience_claim"
| |
|## Jspath to the role claim key |## Jspath to the role claim key
@@ -202,6 +201,9 @@ exampleConfigFile =
|# jwt-secret = "secret_with_at_least_32_characters" |# jwt-secret = "secret_with_at_least_32_characters"
|jwt-secret-is-base64 = false |jwt-secret-is-base64 = false
| |
|## Enables and set JWT Cache max lifetime, disables caching with 0
|# jwt-cache-max-lifetime = 0
|
|## Logging level, the admitted values are: crit, error, warn and info. |## Logging level, the admitted values are: crit, error, warn and info.
|log-level = "error" |log-level = "error"
| |
@@ -212,12 +214,15 @@ exampleConfigFile =
|## Base url for the OpenAPI output |## Base url for the OpenAPI output
|openapi-server-proxy-uri = "" |openapi-server-proxy-uri = ""
| |
|## Content types to produce raw output |## Configurable CORS origins
|# raw-media-types="image/png, image/jpg" |# server-cors-allowed-origins = ""
| |
|server-host = "!4" |server-host = "!4"
|server-port = 3000 |server-port = 3000
| |
|## Allow getting the request-response timing information through the `Server-Timing` header
|server-timing-enabled = false
|
|## Unix socket location |## Unix socket location
|## if specified it takes precedence over server-port |## if specified it takes precedence over server-port
|# server-unix-socket = "/tmp/pgrst.sock" |# server-unix-socket = "/tmp/pgrst.sock"
+30 -16
View File
@@ -61,7 +61,6 @@ import PostgREST.Config.JSPath (JSPath, JSPathExp (..),
dumpJSPath, pRoleClaimKey) dumpJSPath, pRoleClaimKey)
import PostgREST.Config.Proxy (Proxy (..), import PostgREST.Config.Proxy (Proxy (..),
isMalformedProxyUri, toURI) isMalformedProxyUri, toURI)
import PostgREST.MediaType (MediaType (..), toMime)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi, import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi,
toQi) toQi)
@@ -70,6 +69,7 @@ import Protolude hiding (Proxy, toList)
data AppConfig = AppConfig data AppConfig = AppConfig
{ configAppSettings :: [(Text, Text)] { configAppSettings :: [(Text, Text)]
, configDbAggregates :: Bool
, configDbAnonRole :: Maybe BS.ByteString , configDbAnonRole :: Maybe BS.ByteString
, configDbChannel :: Text , configDbChannel :: Text
, configDbChannelEnabled :: Bool , configDbChannelEnabled :: Bool
@@ -80,6 +80,7 @@ data AppConfig = AppConfig
, configDbPoolAcquisitionTimeout :: Int , configDbPoolAcquisitionTimeout :: Int
, configDbPoolMaxLifetime :: Int , configDbPoolMaxLifetime :: Int
, configDbPoolMaxIdletime :: Int , configDbPoolMaxIdletime :: Int
, configDbPoolAutomaticRecovery :: Bool
, configDbPreRequest :: Maybe QualifiedIdentifier , configDbPreRequest :: Maybe QualifiedIdentifier
, configDbPreparedStatements :: Bool , configDbPreparedStatements :: Bool
, configDbRootSpec :: Maybe QualifiedIdentifier , configDbRootSpec :: Maybe QualifiedIdentifier
@@ -89,21 +90,22 @@ data AppConfig = AppConfig
, configDbTxAllowOverride :: Bool , configDbTxAllowOverride :: Bool
, configDbTxRollbackAll :: Bool , configDbTxRollbackAll :: Bool
, configDbUri :: Text , configDbUri :: Text
, configDbUseLegacyGucs :: Bool
, configFilePath :: Maybe FilePath , configFilePath :: Maybe FilePath
, configJWKS :: Maybe JWKSet , configJWKS :: Maybe JWKSet
, configJwtAudience :: Maybe StringOrURI , configJwtAudience :: Maybe StringOrURI
, configJwtRoleClaimKey :: JSPath , configJwtRoleClaimKey :: JSPath
, configJwtSecret :: Maybe BS.ByteString , configJwtSecret :: Maybe BS.ByteString
, configJwtSecretIsBase64 :: Bool , configJwtSecretIsBase64 :: Bool
, configJwtCacheMaxLifetime :: Int
, configLogLevel :: LogLevel , configLogLevel :: LogLevel
, configOpenApiMode :: OpenAPIMode , configOpenApiMode :: OpenAPIMode
, configOpenApiSecurityActive :: Bool , configOpenApiSecurityActive :: Bool
, configOpenApiServerProxyUri :: Maybe Text , configOpenApiServerProxyUri :: Maybe Text
, configRawMediaTypes :: [MediaType] , configServerCorsAllowedOrigins :: Maybe [Text]
, configServerHost :: Text , configServerHost :: Text
, configServerPort :: Int , configServerPort :: Int
, configServerTraceHeader :: Maybe (CI.CI BS.ByteString) , configServerTraceHeader :: Maybe (CI.CI BS.ByteString)
, configServerTimingEnabled :: Bool
, configServerUnixSocket :: Maybe FilePath , configServerUnixSocket :: Maybe FilePath
, configServerUnixSocketMode :: FileMode , configServerUnixSocketMode :: FileMode
, configAdminServerPort :: Maybe Int , configAdminServerPort :: Maybe Int
@@ -113,6 +115,7 @@ data AppConfig = AppConfig
} }
data LogLevel = LogCrit | LogError | LogWarn | LogInfo data LogLevel = LogCrit | LogError | LogWarn | LogInfo
deriving (Eq, Ord)
dumpLogLevel :: LogLevel -> Text dumpLogLevel :: LogLevel -> Text
dumpLogLevel = \case dumpLogLevel = \case
@@ -137,7 +140,8 @@ toText conf =
where where
-- apply conf to all pgrst settings -- apply conf to all pgrst settings
pgrstSettings = (\(k, v) -> (k, v conf)) <$> pgrstSettings = (\(k, v) -> (k, v conf)) <$>
[("db-anon-role", q . T.decodeUtf8 . fromMaybe "" . configDbAnonRole) [("db-aggregates-enabled", T.toLower . show . configDbAggregates)
,("db-anon-role", q . T.decodeUtf8 . fromMaybe "" . configDbAnonRole)
,("db-channel", q . configDbChannel) ,("db-channel", q . configDbChannel)
,("db-channel-enabled", T.toLower . show . configDbChannelEnabled) ,("db-channel-enabled", T.toLower . show . configDbChannelEnabled)
,("db-extra-search-path", q . T.intercalate "," . configDbExtraSearchPath) ,("db-extra-search-path", q . T.intercalate "," . configDbExtraSearchPath)
@@ -147,6 +151,7 @@ toText conf =
,("db-pool-acquisition-timeout", show . configDbPoolAcquisitionTimeout) ,("db-pool-acquisition-timeout", show . configDbPoolAcquisitionTimeout)
,("db-pool-max-lifetime", show . configDbPoolMaxLifetime) ,("db-pool-max-lifetime", show . configDbPoolMaxLifetime)
,("db-pool-max-idletime", show . configDbPoolMaxIdletime) ,("db-pool-max-idletime", show . configDbPoolMaxIdletime)
,("db-pool-automatic-recovery", T.toLower . show . configDbPoolAutomaticRecovery)
,("db-pre-request", q . maybe mempty dumpQi . configDbPreRequest) ,("db-pre-request", q . maybe mempty dumpQi . configDbPreRequest)
,("db-prepared-statements", T.toLower . show . configDbPreparedStatements) ,("db-prepared-statements", T.toLower . show . configDbPreparedStatements)
,("db-root-spec", q . maybe mempty dumpQi . configDbRootSpec) ,("db-root-spec", q . maybe mempty dumpQi . configDbRootSpec)
@@ -155,19 +160,20 @@ toText conf =
,("db-pre-config", q . maybe mempty dumpQi . configDbPreConfig) ,("db-pre-config", q . maybe mempty dumpQi . configDbPreConfig)
,("db-tx-end", q . showTxEnd) ,("db-tx-end", q . showTxEnd)
,("db-uri", q . configDbUri) ,("db-uri", q . configDbUri)
,("db-use-legacy-gucs", T.toLower . show . configDbUseLegacyGucs)
,("jwt-aud", T.decodeUtf8 . LBS.toStrict . JSON.encode . maybe "" toJSON . configJwtAudience) ,("jwt-aud", T.decodeUtf8 . LBS.toStrict . JSON.encode . maybe "" toJSON . configJwtAudience)
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey) ,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret) ,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64) ,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
,("jwt-cache-max-lifetime", show . configJwtCacheMaxLifetime)
,("log-level", q . dumpLogLevel . configLogLevel) ,("log-level", q . dumpLogLevel . configLogLevel)
,("openapi-mode", q . dumpOpenApiMode . configOpenApiMode) ,("openapi-mode", q . dumpOpenApiMode . configOpenApiMode)
,("openapi-security-active", T.toLower . show . configOpenApiSecurityActive) ,("openapi-security-active", T.toLower . show . configOpenApiSecurityActive)
,("openapi-server-proxy-uri", q . fromMaybe mempty . configOpenApiServerProxyUri) ,("openapi-server-proxy-uri", q . fromMaybe mempty . configOpenApiServerProxyUri)
,("raw-media-types", q . T.decodeUtf8 . BS.intercalate "," . fmap toMime . configRawMediaTypes) ,("server-cors-allowed-origins", q . maybe "" (T.intercalate ",") . configServerCorsAllowedOrigins)
,("server-host", q . configServerHost) ,("server-host", q . configServerHost)
,("server-port", show . configServerPort) ,("server-port", show . configServerPort)
,("server-trace-header", q . T.decodeUtf8 . maybe mempty CI.original . configServerTraceHeader) ,("server-trace-header", q . T.decodeUtf8 . maybe mempty CI.original . configServerTraceHeader)
,("server-timing-enabled", T.toLower . show . configServerTimingEnabled)
,("server-unix-socket", q . maybe mempty T.pack . configServerUnixSocket) ,("server-unix-socket", q . maybe mempty T.pack . configServerUnixSocket)
,("server-unix-socket-mode", q . T.pack . showSocketMode) ,("server-unix-socket-mode", q . T.pack . showSocketMode)
,("admin-server-port", maybe "\"\"" show . configAdminServerPort) ,("admin-server-port", maybe "\"\"" show . configAdminServerPort)
@@ -229,6 +235,7 @@ parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> Rol
parser optPath env dbSettings roleSettings roleIsolationLvl = parser optPath env dbSettings roleSettings roleIsolationLvl =
AppConfig AppConfig
<$> parseAppSettings "app.settings" <$> parseAppSettings "app.settings"
<*> (fromMaybe False <$> optBool "db-aggregates-enabled")
<*> (fmap encodeUtf8 <$> optString "db-anon-role") <*> (fmap encodeUtf8 <$> optString "db-anon-role")
<*> (fromMaybe "pgrst" <$> optString "db-channel") <*> (fromMaybe "pgrst" <$> optString "db-channel")
<*> (fromMaybe True <$> optBool "db-channel-enabled") <*> (fromMaybe True <$> optBool "db-channel-enabled")
@@ -241,6 +248,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
<*> (fromMaybe 1800 <$> optInt "db-pool-max-lifetime") <*> (fromMaybe 1800 <$> optInt "db-pool-max-lifetime")
<*> (fromMaybe 30 <$> optWithAlias (optInt "db-pool-timeout") <*> (fromMaybe 30 <$> optWithAlias (optInt "db-pool-timeout")
(optInt "db-pool-max-idletime")) (optInt "db-pool-max-idletime"))
<*> (fromMaybe True <$> optBool "db-pool-automatic-recovery")
<*> (fmap toQi <$> optWithAlias (optString "db-pre-request") <*> (fmap toQi <$> optWithAlias (optString "db-pre-request")
(optString "pre-request")) (optString "pre-request"))
<*> (fromMaybe True <$> optBool "db-prepared-statements") <*> (fromMaybe True <$> optBool "db-prepared-statements")
@@ -253,7 +261,6 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
<*> parseTxEnd "db-tx-end" snd <*> parseTxEnd "db-tx-end" snd
<*> parseTxEnd "db-tx-end" fst <*> parseTxEnd "db-tx-end" fst
<*> (fromMaybe "postgresql://" <$> optString "db-uri") <*> (fromMaybe "postgresql://" <$> optString "db-uri")
<*> (fromMaybe True <$> optBool "db-use-legacy-gucs")
<*> pure optPath <*> pure optPath
<*> pure Nothing <*> pure Nothing
<*> parseJwtAudience "jwt-aud" <*> parseJwtAudience "jwt-aud"
@@ -262,14 +269,16 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
<*> (fromMaybe False <$> optWithAlias <*> (fromMaybe False <$> optWithAlias
(optBool "jwt-secret-is-base64") (optBool "jwt-secret-is-base64")
(optBool "secret-is-base64")) (optBool "secret-is-base64"))
<*> (fromMaybe 0 <$> optInt "jwt-cache-max-lifetime")
<*> parseLogLevel "log-level" <*> parseLogLevel "log-level"
<*> parseOpenAPIMode "openapi-mode" <*> parseOpenAPIMode "openapi-mode"
<*> (fromMaybe False <$> optBool "openapi-security-active") <*> (fromMaybe False <$> optBool "openapi-security-active")
<*> parseOpenAPIServerProxyURI "openapi-server-proxy-uri" <*> parseOpenAPIServerProxyURI "openapi-server-proxy-uri"
<*> (maybe [] (fmap (MTOther . encodeUtf8) . splitOnCommas) <$> optValue "raw-media-types") <*> parseCORSAllowedOrigins "server-cors-allowed-origins"
<*> (fromMaybe "!4" <$> optString "server-host") <*> (fromMaybe "!4" <$> optString "server-host")
<*> (fromMaybe 3000 <$> optInt "server-port") <*> (fromMaybe 3000 <$> optInt "server-port")
<*> (fmap (CI.mk . encodeUtf8) <$> optString "server-trace-header") <*> (fmap (CI.mk . encodeUtf8) <$> optString "server-trace-header")
<*> (fromMaybe False <$> optBool "server-timing-enabled")
<*> (fmap T.unpack <$> optString "server-unix-socket") <*> (fmap T.unpack <$> optString "server-unix-socket")
<*> parseSocketFileMode "server-unix-socket-mode" <*> parseSocketFileMode "server-unix-socket-mode"
<*> optInt "admin-server-port" <*> optInt "admin-server-port"
@@ -348,6 +357,11 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
Nothing -> pure [JSPKey "role"] Nothing -> pure [JSPKey "role"]
Just rck -> either (fail . show) pure $ pRoleClaimKey rck Just rck -> either (fail . show) pure $ pRoleClaimKey rck
parseCORSAllowedOrigins k =
optString k >>= \case
Nothing -> pure Nothing
Just orig -> pure $ Just (T.strip <$> T.splitOn "," orig)
optWithAlias :: C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) optWithAlias :: C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a)
optWithAlias orig alias = optWithAlias orig alias =
orig >>= \case orig >>= \case
@@ -479,26 +493,26 @@ readPGRSTEnvironment =
-- >>> addFallbackAppName ver "postgres:///postgres?host=server&port=5432" -- >>> addFallbackAppName ver "postgres:///postgres?host=server&port=5432"
-- "postgres:///postgres?host=server&port=5432&fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29" -- "postgres:///postgres?host=server&port=5432&fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29"
-- --
-- >>> addFallbackAppName ver "host=localhost port=5432 dbname=postgres"
-- "host=localhost port=5432 dbname=postgres fallback_application_name='PostgREST 11.1.0 (5a04ec7)'"
--
-- >>> addFallbackAppName ver "postgresql://" -- >>> addFallbackAppName ver "postgresql://"
-- "postgresql://?fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29" -- "postgresql://?fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29"
-- --
-- >>> addFallbackAppName strangeVer "host=localhost port=5432 dbname=postgres"
-- "host=localhost port=5432 dbname=postgres fallback_application_name='PostgREST 11\\'1&0@#$%,.:\"[]{}?+^()=asdfqwer'"
--
-- >>> addFallbackAppName strangeVer "postgres:///postgres?host=server&port=5432" -- >>> addFallbackAppName strangeVer "postgres:///postgres?host=server&port=5432"
-- "postgres:///postgres?host=server&port=5432&fallback_application_name=PostgREST%2011%271%260%40%23%24%25%2C.%3A%22%5B%5D%7B%7D%3F%2B%5E%28%29%3Dasdfqwer" -- "postgres:///postgres?host=server&port=5432&fallback_application_name=PostgREST%2011%271%260%40%23%24%25%2C.%3A%22%5B%5D%7B%7D%3F%2B%5E%28%29%3Dasdfqwer"
--
-- >>> addFallbackAppName ver "postgres://user:invalid_chars[]#@host:5432/postgres"
-- "postgres://user:invalid_chars[]#@host:5432/postgres"
--
-- >>> addFallbackAppName ver "invalid_uri1=val1 invalid_uri2=val2"
-- "invalid_uri1=val1 invalid_uri2=val2"
addFallbackAppName :: ByteString -> Text -> Text addFallbackAppName :: ByteString -> Text -> Text
addFallbackAppName version dbUri = dbUri <> addFallbackAppName version dbUri = dbUri <>
case uriQuery <$> parseURI (toS dbUri) of case uriQuery <$> parseURI (toS dbUri) of
Nothing -> " " <> keyValFmt -- Assume key/value connection string if the uri is not valid -- Does not add the application name to key=val connection strings or invalid URIs
Nothing -> mempty
Just "" -> "?" <> uriFmt Just "" -> "?" <> uriFmt
Just "?" -> uriFmt Just "?" -> uriFmt
_ -> "&" <> uriFmt _ -> "&" <> uriFmt
where where
uriFmt = pKeyWord <> toS (escapeURIString isUnescapedInURIComponent $ toS pgrstVer) uriFmt = pKeyWord <> toS (escapeURIString isUnescapedInURIComponent $ toS pgrstVer)
keyValFmt = pKeyWord <> "'" <> T.replace "'" "\\'" pgrstVer <> "'"
pKeyWord = "fallback_application_name=" pKeyWord = "fallback_application_name="
pgrstVer = "PostgREST " <> T.decodeUtf8 version pgrstVer = "PostgREST " <> T.decodeUtf8 version
+15 -8
View File
@@ -3,16 +3,17 @@
module PostgREST.Config.Database module PostgREST.Config.Database
( pgVersionStatement ( pgVersionStatement
, queryDbSettings , queryDbSettings
, queryRoleSettings
, queryPgVersion , queryPgVersion
, queryRoleSettings
, RoleSettings , RoleSettings
, RoleIsolationLvl , RoleIsolationLvl
, TimezoneNames
, toIsolationLevel , toIsolationLevel
) where ) where
import Control.Arrow ((***)) import Control.Arrow ((***))
import PostgREST.Config.PgVersion (PgVersion (..)) import PostgREST.Config.PgVersion (PgVersion (..), pgVersion150)
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
@@ -29,6 +30,7 @@ import Protolude
type RoleSettings = (HM.HashMap ByteString (HM.HashMap ByteString ByteString)) type RoleSettings = (HM.HashMap ByteString (HM.HashMap ByteString ByteString))
type RoleIsolationLvl = HM.HashMap ByteString SQL.IsolationLevel type RoleIsolationLvl = HM.HashMap ByteString SQL.IsolationLevel
type TimezoneNames = Set ByteString -- cache timezone names for prefer timezone=
toIsolationLevel :: (Eq a, IsString a) => a -> SQL.IsolationLevel toIsolationLevel :: (Eq a, IsString a) => a -> SQL.IsolationLevel
toIsolationLevel a = case a of toIsolationLevel a = case a of
@@ -43,7 +45,8 @@ prefix = "pgrst."
dbSettingsNames :: [Text] dbSettingsNames :: [Text]
dbSettingsNames = dbSettingsNames =
(prefix <>) <$> (prefix <>) <$>
["db_anon_role" ["db_aggregates_enabled"
,"db_anon_role"
,"db_pre_config" ,"db_pre_config"
,"db_extra_search_path" ,"db_extra_search_path"
,"db_max_rows" ,"db_max_rows"
@@ -53,16 +56,17 @@ dbSettingsNames =
,"db_root_spec" ,"db_root_spec"
,"db_schemas" ,"db_schemas"
,"db_tx_end" ,"db_tx_end"
,"db_use_legacy_gucs"
,"jwt_aud" ,"jwt_aud"
,"jwt_role_claim_key" ,"jwt_role_claim_key"
,"jwt_secret" ,"jwt_secret"
,"jwt_secret_is_base64" ,"jwt_secret_is_base64"
,"jwt_cache_max_lifetime"
,"openapi_mode" ,"openapi_mode"
,"openapi_security_active" ,"openapi_security_active"
,"openapi_server_proxy_uri" ,"openapi_server_proxy_uri"
,"raw_media_types" ,"raw_media_types"
,"server_trace_header" ,"server_trace_header"
,"server_timing_enabled"
] ]
queryPgVersion :: Bool -> Session PgVersion queryPgVersion :: Bool -> Session PgVersion
@@ -126,8 +130,8 @@ queryDbSettings preConfFunc prepared =
|]::Text |]::Text
decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text
queryRoleSettings :: Bool -> Session (RoleSettings, RoleIsolationLvl) queryRoleSettings :: PgVersion -> Bool -> Session (RoleSettings, RoleIsolationLvl)
queryRoleSettings prepared = queryRoleSettings pgVer prepared =
let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in
transaction SQL.ReadCommitted SQL.Read $ SQL.statement mempty $ SQL.Statement sql HE.noParams (processRows <$> rows) prepared transaction SQL.ReadCommitted SQL.Read $ SQL.statement mempty $ SQL.Statement sql HE.noParams (processRows <$> rows) prepared
where where
@@ -154,9 +158,12 @@ queryRoleSettings prepared =
select select
kv.rolname, kv.rolname,
i.value as iso_lvl, i.value as iso_lvl,
array_agg(row(kv.key, kv.value)) filter (where key <> 'default_transation_isolation') as role_settings coalesce(array_agg(row(kv.key, kv.value)) filter (where key <> 'default_transaction_isolation'), '{}') as role_settings
from kv_settings kv from kv_settings kv
join pg_settings ps on ps.name = kv.key and ps.context = 'user' join pg_settings ps on ps.name = kv.key |] <>
(if pgVer >= pgVersion150
then "and (ps.context = 'user' or has_parameter_privilege(current_user::regrole::oid, ps.name, 'set')) "
else "and ps.context = 'user' ") <> [q|
left join iso_setting i on i.rolname = kv.rolname left join iso_setting i on i.rolname = kv.rolname
group by kv.rolname, i.value; group by kv.rolname, i.value;
|] |]
+4
View File
@@ -13,6 +13,7 @@ module PostgREST.Config.PgVersion
, pgVersion121 , pgVersion121
, pgVersion130 , pgVersion130
, pgVersion140 , pgVersion140
, pgVersion150
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
@@ -62,3 +63,6 @@ pgVersion130 = PgVersion 130000 "13.0"
pgVersion140 :: PgVersion pgVersion140 :: PgVersion
pgVersion140 = PgVersion 140000 "14.0" pgVersion140 = PgVersion 140000 "14.0"
pgVersion150 :: PgVersion
pgVersion150 = PgVersion 150000 "15.0"
+10 -6
View File
@@ -2,10 +2,14 @@
Module : PostgREST.Cors Module : PostgREST.Cors
Description : Wai Middleware to set cors policy. Description : Wai Middleware to set cors policy.
-} -}
{-# LANGUAGE TupleSections #-}
module PostgREST.Cors (middleware) where module PostgREST.Cors (middleware) where
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
import qualified Data.Text.Encoding as T
import qualified Network.Wai as Wai import qualified Network.Wai as Wai
import qualified Network.Wai.Middleware.Cors as Wai import qualified Network.Wai.Middleware.Cors as Wai
@@ -13,15 +17,15 @@ import Data.List (lookup)
import Protolude import Protolude
middleware :: Wai.Middleware middleware :: Maybe [Text] -> Wai.Middleware
middleware = Wai.cors corsPolicy middleware corsAllowedOrigins = Wai.cors $ corsPolicy corsAllowedOrigins
-- | CORS policy to be used in by Wai Cors middleware -- | CORS policy to be used in by Wai Cors middleware
corsPolicy :: Wai.Request -> Maybe Wai.CorsResourcePolicy corsPolicy :: Maybe [Text] -> Wai.Request -> Maybe Wai.CorsResourcePolicy
corsPolicy req = case lookup "origin" headers of corsPolicy corsAllowedOrigins req = case lookup "origin" headers of
Just origin -> Just _ ->
Just Wai.CorsResourcePolicy Just Wai.CorsResourcePolicy
{ Wai.corsOrigins = Just ([origin], True) { Wai.corsOrigins = (, True) . map T.encodeUtf8 <$> corsAllowedOrigins
, Wai.corsMethods = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] , Wai.corsMethods = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
, Wai.corsRequestHeaders = "Authorization" : accHeaders , Wai.corsRequestHeaders = "Authorization" : accHeaders
, Wai.corsExposedHeaders = Just , Wai.corsExposedHeaders = Just
+243 -211
View File
@@ -11,13 +11,15 @@ module PostgREST.Error
, PgError(..) , PgError(..)
, Error(..) , Error(..)
, errorPayload , errorPayload
, singularityError , status
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.CaseInsensitive as CI
import qualified Data.FuzzySet as Fuzzy import qualified Data.FuzzySet as Fuzzy
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.Map.Internal as M
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as T import qualified Data.Text.Encoding as T
import qualified Data.Text.Encoding.Error as T import qualified Data.Text.Encoding.Error as T
@@ -25,7 +27,7 @@ import qualified Hasql.Pool as SQL
import qualified Hasql.Session as SQL import qualified Hasql.Session as SQL
import qualified Network.HTTP.Types.Status as HTTP import qualified Network.HTTP.Types.Status as HTTP
import Data.Aeson ((.=)) import Data.Aeson ((.:), (.:?), (.=))
import Network.Wai (Response, responseLBS) import Network.Wai (Response, responseLBS)
import Network.HTTP.Types.Header (Header) import Network.HTTP.Types.Header (Header)
@@ -55,15 +57,18 @@ class (JSON.ToJSON a) => PgrstError a where
errorPayload = JSON.encode errorPayload = JSON.encode
errorResponseFor :: a -> Response errorResponseFor :: a -> Response
errorResponseFor err = responseLBS (status err) (headers err) $ errorPayload err errorResponseFor err =
let baseHeader = MediaType.toContentType MTApplicationJSON in
responseLBS (status err) (baseHeader : headers err) $ errorPayload err
instance PgrstError ApiRequestError where instance PgrstError ApiRequestError where
status AggregatesNotAllowed{} = HTTP.status400
status AmbiguousRelBetween{} = HTTP.status300 status AmbiguousRelBetween{} = HTTP.status300
status AmbiguousRpc{} = HTTP.status300 status AmbiguousRpc{} = HTTP.status300
status BinaryFieldError{} = HTTP.status406
status MediaTypeError{} = HTTP.status415 status MediaTypeError{} = HTTP.status415
status InvalidBody{} = HTTP.status400 status InvalidBody{} = HTTP.status400
status InvalidFilters = HTTP.status405 status InvalidFilters = HTTP.status405
status InvalidPreferences{} = HTTP.status400
status InvalidRpcMethod{} = HTTP.status405 status InvalidRpcMethod{} = HTTP.status405
status InvalidRange{} = HTTP.status416 status InvalidRange{} = HTTP.status416
status NotFound = HTTP.status404 status NotFound = HTTP.status404
@@ -80,108 +85,132 @@ instance PgrstError ApiRequestError where
status UnsupportedMethod{} = HTTP.status405 status UnsupportedMethod{} = HTTP.status405
status LimitNoOrderError = HTTP.status400 status LimitNoOrderError = HTTP.status400
status ColumnNotFound{} = HTTP.status400 status ColumnNotFound{} = HTTP.status400
status GucHeadersError = HTTP.status500
status GucStatusError = HTTP.status500
status OffLimitsChangesError{} = HTTP.status400
status PutMatchingPkError = HTTP.status400
status SingularityError{} = HTTP.status406
status PGRSTParseError = HTTP.status500
headers _ = [MediaType.toContentType MTApplicationJSON] headers SingularityError{} = [MediaType.toContentType $ MTVndSingularJSON False]
headers _ = mempty
toJsonPgrstError :: ErrorCode -> Text -> Maybe JSON.Value -> Maybe JSON.Value -> JSON.Value
toJsonPgrstError code msg details hint = JSON.object [
"code" .= code
, "message" .= msg
, "details" .= details
, "hint" .= hint
]
instance JSON.ToJSON ApiRequestError where instance JSON.ToJSON ApiRequestError where
toJSON (QueryParamError (QPError message details)) = JSON.object [ toJSON (QueryParamError (QPError message details)) = toJsonPgrstError
"code" .= ApiRequestErrorCode00, ApiRequestErrorCode00 message (Just (JSON.String details)) Nothing
"message" .= message,
"details" .= details, toJSON (InvalidRpcMethod method) = toJsonPgrstError
"hint" .= JSON.Null] ApiRequestErrorCode01 ("Cannot use the " <> T.decodeUtf8 method <> " method on RPC") Nothing Nothing
toJSON (InvalidRpcMethod method) = JSON.object [
"code" .= ApiRequestErrorCode01, toJSON (InvalidBody errorMessage) = toJsonPgrstError
"message" .= ("Cannot use the " <> T.decodeUtf8 method <> " method on RPC"), ApiRequestErrorCode02 (T.decodeUtf8 errorMessage) Nothing Nothing
"details" .= JSON.Null,
"hint" .= JSON.Null] toJSON (InvalidRange rangeError) = toJsonPgrstError
toJSON (InvalidBody errorMessage) = JSON.object [ ApiRequestErrorCode03
"code" .= ApiRequestErrorCode02, "Requested range not satisfiable"
"message" .= T.decodeUtf8 errorMessage, (Just $ case rangeError of
"details" .= JSON.Null, NegativeLimit -> "Limit should be greater than or equal to zero."
"hint" .= JSON.Null] LowerGTUpper -> "The lower boundary must be lower than or equal to the upper boundary in the Range header."
toJSON (InvalidRange rangeError) = JSON.object [ OutOfBounds lower total -> JSON.String $ "An offset of " <> lower <> " was requested, but there are only " <> total <> " rows.")
"code" .= ApiRequestErrorCode03, Nothing
"message" .= ("Requested range not satisfiable" :: Text),
"details" .= (case rangeError of toJSON InvalidFilters = toJsonPgrstError
NegativeLimit -> "Limit should be greater than or equal to zero." ApiRequestErrorCode05 "Filters must include all and only primary key columns with 'eq' operators" Nothing Nothing
LowerGTUpper -> "The lower boundary must be lower than or equal to the upper boundary in the Range header."
OutOfBounds lower total -> "An offset of " <> lower <> " was requested, but there are only " <> total <> " rows."), toJSON (UnacceptableSchema schemas) = toJsonPgrstError
"hint" .= JSON.Null] ApiRequestErrorCode06 ("The schema must be one of the following: " <> T.intercalate ", " schemas) Nothing Nothing
toJSON InvalidFilters = JSON.object [
"code" .= ApiRequestErrorCode05, toJSON (MediaTypeError cts) = toJsonPgrstError
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text), ApiRequestErrorCode07 ("None of these media types are available: " <> T.intercalate ", " (map T.decodeUtf8 cts)) Nothing Nothing
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (UnacceptableSchema schemas) = JSON.object [
"code" .= ApiRequestErrorCode06,
"message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (MediaTypeError cts) = JSON.object [
"code" .= ApiRequestErrorCode07,
"message" .= ("None of these media types are available: " <> T.intercalate ", " (map T.decodeUtf8 cts)),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON NotFound = JSON.object [] toJSON NotFound = JSON.object []
toJSON (NotEmbedded resource) = JSON.object [
"code" .= ApiRequestErrorCode08,
"message" .= ("'" <> resource <> "' is not an embedded resource in this request" :: Text),
"details" .= JSON.Null,
"hint" .= ("Verify that '" <> resource <> "' is included in the 'select' query parameter." :: Text)]
toJSON LimitNoOrderError = JSON.object [ toJSON (NotEmbedded resource) = toJsonPgrstError
"code" .= ApiRequestErrorCode09, ApiRequestErrorCode08
"message" .= ("A 'limit' was applied without an explicit 'order'":: Text), ("'" <> resource <> "' is not an embedded resource in this request")
"details" .= JSON.Null, Nothing
"hint" .= ("Apply an 'order' using unique column(s)" :: Text)] (Just $ JSON.String $ "Verify that '" <> resource <> "' is included in the 'select' query parameter.")
toJSON (BinaryFieldError ct) = JSON.object [ toJSON LimitNoOrderError = toJsonPgrstError
"code" .= ApiRequestErrorCode13, ApiRequestErrorCode09 "A 'limit' was applied without an explicit 'order'" Nothing (Just "Apply an 'order' using unique column(s)")
"message" .= ((T.decodeUtf8 (MediaType.toMime ct) <> " requested but more than one column was selected") :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON PutLimitNotAllowedError = JSON.object [ toJSON (OffLimitsChangesError n maxs) = toJsonPgrstError
"code" .= ApiRequestErrorCode14, ApiRequestErrorCode10
"message" .= ("limit/offset querystring parameters are not allowed for PUT" :: Text), "The maximum number of rows allowed to change was surpassed"
"details" .= JSON.Null, (Just $ JSON.String $ T.unwords ["Results contain", show n, "rows changed but the maximum number allowed is", show maxs])
"hint" .= JSON.Null] Nothing
toJSON (UnsupportedMethod method) = JSON.object [ toJSON GucHeadersError = toJsonPgrstError
"code" .= ApiRequestErrorCode17, ApiRequestErrorCode11 "response.headers guc must be a JSON array composed of objects with a single key and a string value" Nothing Nothing
"message" .= ("Unsupported HTTP method: " <> T.decodeUtf8 method),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (RelatedOrderNotToOne origin target) = JSON.object [ toJSON GucStatusError = toJsonPgrstError
"code" .= ApiRequestErrorCode18, ApiRequestErrorCode12 "response.status guc must be a valid status code" Nothing Nothing
"message" .= ("A related order on '" <> target <> "' is not possible" :: Text),
"details" .= ("'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship" :: Text),
"hint" .= JSON.Null]
toJSON (SpreadNotToOne origin target) = JSON.object [ toJSON PutLimitNotAllowedError = toJsonPgrstError
"code" .= ApiRequestErrorCode19, ApiRequestErrorCode14 "limit/offset querystring parameters are not allowed for PUT" Nothing Nothing
"message" .= ("A spread operation on '" <> target <> "' is not possible" :: Text),
"details" .= ("'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship" :: Text),
"hint" .= JSON.Null]
toJSON (UnacceptableFilter target) = JSON.object [ toJSON PutMatchingPkError = toJsonPgrstError
"code" .= ApiRequestErrorCode20, ApiRequestErrorCode15 "Payload values do not match URL in primary key column(s)" Nothing Nothing
"message" .= ("Bad operator on the '" <> target <> "' embedded resource":: Text),
"details" .= ("Only is null or not is null filters are allowed on embedded resources":: Text),
"hint" .= JSON.Null]
toJSON (NoRelBetween parent child embedHint schema allRels) = JSON.object [ toJSON (SingularityError n) = toJsonPgrstError
"code" .= SchemaCacheErrorCode00, ApiRequestErrorCode16
"message" .= ("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache" :: Text), "JSON object requested, multiple (or no) rows returned"
"details" .= ("Searched for a foreign key relationship between '" <> parent <> "' and '" <> child <> maybe mempty ("' using the hint '" <>) embedHint <> "' in the schema '" <> schema <> "', but no matches were found."), (Just $ JSON.String $ T.unwords ["The result contains", show n, "rows"])
"hint" .= noRelBetweenHint parent child schema allRels] Nothing
toJSON (UnsupportedMethod method) = toJsonPgrstError
ApiRequestErrorCode17 ("Unsupported HTTP method: " <> T.decodeUtf8 method) Nothing Nothing
toJSON (RelatedOrderNotToOne origin target) = toJsonPgrstError
ApiRequestErrorCode18
("A related order on '" <> target <> "' is not possible")
(Just $ JSON.String $ "'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship")
Nothing
toJSON (SpreadNotToOne origin target) = toJsonPgrstError
ApiRequestErrorCode19
("A spread operation on '" <> target <> "' is not possible")
(Just $ JSON.String $ "'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship")
Nothing
toJSON (UnacceptableFilter target) = toJsonPgrstError
ApiRequestErrorCode20
("Bad operator on the '" <> target <> "' embedded resource")
(Just "Only is null or not is null filters are allowed on embedded resources")
Nothing
toJSON PGRSTParseError = toJsonPgrstError
ApiRequestErrorCode21 "The message and detail field of RAISE 'PGRST' error expects JSON" Nothing Nothing
toJSON (InvalidPreferences prefs) = toJsonPgrstError
ApiRequestErrorCode22
"Invalid preferences given with handling=strict"
(Just $ JSON.String $ T.decodeUtf8 ("Invalid preferences: " <> BS.intercalate ", " prefs))
Nothing
toJSON AggregatesNotAllowed = toJsonPgrstError
ApiRequestErrorCode23 "Use of aggregate functions is not allowed" Nothing Nothing
toJSON (NoRelBetween parent child embedHint schema allRels) = toJsonPgrstError
SchemaCacheErrorCode00
("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache")
(Just $ JSON.String $ "Searched for a foreign key relationship between '" <> parent <> "' and '" <> child <> maybe mempty ("' using the hint '" <>) embedHint <> "' in the schema '" <> schema <> "', but no matches were found.")
(JSON.String <$> noRelBetweenHint parent child schema allRels)
toJSON (AmbiguousRelBetween parent child rels) = toJsonPgrstError
SchemaCacheErrorCode01
("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'")
(Just $ JSON.toJSONList (compressedRel <$> rels))
(Just $ JSON.String $ "Try changing '" <> child <> "' to one of the following: " <> relHint rels <> ". Find the desired relationship in the 'details' key.")
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [
"code" .= SchemaCacheErrorCode01,
"message" .= ("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'" :: Text),
"details" .= (compressedRel <$> rels),
"hint" .= ("Try changing '" <> child <> "' to one of the following: " <> relHint rels <> ". Find the desired relationship in the 'details' key." :: Text)]
toJSON (NoRpc schema procName argumentKeys hasPreferSingleObject contentType isInvPost allProcs overloadedProcs) = toJSON (NoRpc schema procName argumentKeys hasPreferSingleObject contentType isInvPost allProcs overloadedProcs) =
let func = schema <> "." <> procName let func = schema <> "." <> procName
prms = T.intercalate ", " argumentKeys prms = T.intercalate ", " argumentKeys
@@ -189,10 +218,10 @@ instance JSON.ToJSON ApiRequestError where
prmsDet = " with parameter" <> (if length argumentKeys > 1 then "s " else " ") <> prms prmsDet = " with parameter" <> (if length argumentKeys > 1 then "s " else " ") <> prms
fmtPrms p = if null argumentKeys then " without parameters" else p fmtPrms p = if null argumentKeys then " without parameters" else p
onlySingleParams = hasPreferSingleObject || (isInvPost && contentType `elem` [MTTextPlain, MTTextXML, MTOctetStream]) onlySingleParams = hasPreferSingleObject || (isInvPost && contentType `elem` [MTTextPlain, MTTextXML, MTOctetStream])
in JSON.object [ in toJsonPgrstError
"code" .= SchemaCacheErrorCode02, SchemaCacheErrorCode02
"message" .= ("Could not find the function " <> func <> (if onlySingleParams then "" else fmtPrms prmsMsg) <> " in the schema cache"), ("Could not find the function " <> func <> (if onlySingleParams then "" else fmtPrms prmsMsg) <> " in the schema cache")
"details" .= ("Searched for the function " <> func <> (Just $ JSON.String $ "Searched for the function " <> func <>
(case (hasPreferSingleObject, isInvPost, contentType) of (case (hasPreferSingleObject, isInvPost, contentType) of
(True, _, _) -> " with a single json/jsonb parameter" (True, _, _) -> " with a single json/jsonb parameter"
(_, True, MTTextPlain) -> " with a single unnamed text parameter" (_, True, MTTextPlain) -> " with a single unnamed text parameter"
@@ -200,21 +229,20 @@ instance JSON.ToJSON ApiRequestError where
(_, True, MTOctetStream) -> " with a single unnamed bytea parameter" (_, True, MTOctetStream) -> " with a single unnamed bytea parameter"
(_, True, MTApplicationJSON) -> fmtPrms prmsDet <> " or with a single unnamed json/jsonb parameter" (_, True, MTApplicationJSON) -> fmtPrms prmsDet <> " or with a single unnamed json/jsonb parameter"
_ -> fmtPrms prmsDet) <> _ -> fmtPrms prmsDet) <>
", but no matches were found in the schema cache."), ", but no matches were found in the schema cache.")
-- The hint will be null in the case of single unnamed parameter functions -- The hint will be null in the case of single unnamed parameter functions
"hint" .= if onlySingleParams (if onlySingleParams
then Nothing then Nothing
else noRpcHint schema procName argumentKeys allProcs overloadedProcs ] else JSON.String <$> noRpcHint schema procName argumentKeys allProcs overloadedProcs)
toJSON (AmbiguousRpc procs) = JSON.object [
"code" .= SchemaCacheErrorCode03, toJSON (AmbiguousRpc procs) = toJsonPgrstError
"message" .= ("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs]), SchemaCacheErrorCode03
"details" .= JSON.Null, ("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs])
"hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)] Nothing
toJSON (ColumnNotFound relName colName) = JSON.object [ (Just "Try renaming the parameters or the function itself in the database so function overloading can be resolved")
"code" .= SchemaCacheErrorCode04,
"message" .= ("Column '" <> colName <> "' of relation '" <> relName <> "' does not exist" :: Text), toJSON (ColumnNotFound relName colName) = toJsonPgrstError
"details" .= JSON.Null, SchemaCacheErrorCode04 ("Column '" <> colName <> "' of relation '" <> relName <> "' does not exist") Nothing Nothing
"hint" .= JSON.Null]
-- | -- |
-- If no relationship is found then: -- If no relationship is found then:
@@ -359,49 +387,60 @@ type Authenticated = Bool
instance PgrstError PgError where instance PgrstError PgError where
status (PgError authed usageError) = pgErrorStatus authed usageError status (PgError authed usageError) = pgErrorStatus authed usageError
headers (PgError _ (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError (SQL.ServerError "PGRST" m d _ _p))))) =
case (parseMessage m, parseDetails d) of
(Just _, Just r) -> headers PGRSTParseError ++ map intoHeader (M.toList $ getHeaders r)
_ -> headers PGRSTParseError
where
intoHeader (k,v) = (CI.mk $ T.encodeUtf8 k, T.encodeUtf8 v)
headers err = headers err =
if status err == HTTP.status401 if status err == HTTP.status401
then [MediaType.toContentType MTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header] then [("WWW-Authenticate", "Bearer") :: Header]
else [MediaType.toContentType MTApplicationJSON] else mempty
instance JSON.ToJSON PgError where instance JSON.ToJSON PgError where
toJSON (PgError _ usageError) = JSON.toJSON usageError toJSON (PgError _ usageError) = JSON.toJSON usageError
instance JSON.ToJSON SQL.UsageError where instance JSON.ToJSON SQL.UsageError where
toJSON (SQL.ConnectionUsageError e) = JSON.object [ toJSON (SQL.ConnectionUsageError e) = toJsonPgrstError
"code" .= ConnectionErrorCode00, ConnectionErrorCode00
"message" .= ("Database connection error. Retrying the connection." :: Text), "Database connection error. Retrying the connection."
"details" .= (T.decodeUtf8With T.lenientDecode $ fromMaybe "" e :: Text), (Just $ JSON.String $ T.decodeUtf8With T.lenientDecode $ fromMaybe "" e)
"hint" .= JSON.Null] Nothing
toJSON (SQL.SessionUsageError e) = JSON.toJSON e -- SQL.Error toJSON (SQL.SessionUsageError e) = JSON.toJSON e -- SQL.Error
toJSON SQL.AcquisitionTimeoutUsageError = JSON.object [
"code" .= ConnectionErrorCode03, toJSON SQL.AcquisitionTimeoutUsageError = toJsonPgrstError
"message" .= ("Timed out acquiring connection from connection pool." :: Text), ConnectionErrorCode03 "Timed out acquiring connection from connection pool." Nothing Nothing
"details" .= JSON.Null,
"hint" .= JSON.Null]
instance JSON.ToJSON SQL.QueryError where instance JSON.ToJSON SQL.QueryError where
toJSON (SQL.QueryError _ _ e) = JSON.toJSON e toJSON (SQL.QueryError _ _ e) = JSON.toJSON e
instance JSON.ToJSON SQL.CommandError where instance JSON.ToJSON SQL.CommandError where
-- Special error raised with code PGRST, to allow full response control
toJSON (SQL.ResultError (SQL.ServerError "PGRST" m d _ _p)) =
case (parseMessage m, parseDetails d) of
(Just r, Just _) -> JSON.object [
"code" .= getCode r,
"message" .= getMessage r,
"details" .= checkMaybe (getDetails r),
"hint" .= checkMaybe (getHint r)]
_ -> JSON.toJSON PGRSTParseError
where
checkMaybe = maybe JSON.Null JSON.String
toJSON (SQL.ResultError (SQL.ServerError c m d h _p)) = JSON.object [ toJSON (SQL.ResultError (SQL.ServerError c m d h _p)) = JSON.object [
"code" .= (T.decodeUtf8 c :: Text), "code" .= (T.decodeUtf8 c :: Text),
"message" .= (T.decodeUtf8 m :: Text), "message" .= (T.decodeUtf8 m :: Text),
"details" .= (fmap T.decodeUtf8 d :: Maybe Text), "details" .= (fmap T.decodeUtf8 d :: Maybe Text),
"hint" .= (fmap T.decodeUtf8 h :: Maybe Text)] "hint" .= (fmap T.decodeUtf8 h :: Maybe Text)]
toJSON (SQL.ResultError resultError) = JSON.object [ toJSON (SQL.ResultError resultError) = toJsonPgrstError
"code" .= InternalErrorCode00, InternalErrorCode00 (show resultError) Nothing Nothing
"message" .= (show resultError :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (SQL.ClientError d) = JSON.object [
"code" .= ConnectionErrorCode01,
"message" .= ("Database client error. Retrying the connection." :: Text),
"details" .= (fmap T.decodeUtf8 d :: Maybe Text),
"hint" .= JSON.Null]
toJSON (SQL.ClientError d) = toJsonPgrstError
ConnectionErrorCode01 "Database client error. Retrying the connection." (JSON.String <$> fmap T.decodeUtf8 d) Nothing
pgErrorStatus :: Bool -> SQL.UsageError -> HTTP.Status pgErrorStatus :: Bool -> SQL.UsageError -> HTTP.Status
pgErrorStatus _ (SQL.ConnectionUsageError _) = HTTP.status503 pgErrorStatus _ (SQL.ConnectionUsageError _) = HTTP.status503
@@ -409,7 +448,7 @@ pgErrorStatus _ SQL.AcquisitionTimeoutUsageError = HTTP.status504
pgErrorStatus _ (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ClientError _))) = HTTP.status503 pgErrorStatus _ (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ClientError _))) = HTTP.status503
pgErrorStatus authed (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError rError))) = pgErrorStatus authed (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError rError))) =
case rError of case rError of
(SQL.ServerError c m _ _ _) -> (SQL.ServerError c m d _ _) ->
case BS.unpack c of case BS.unpack c of
'0':'8':_ -> HTTP.status503 -- pg connection err '0':'8':_ -> HTTP.status503 -- pg connection err
'0':'9':_ -> HTTP.status500 -- triggered action exception '0':'9':_ -> HTTP.status500 -- triggered action exception
@@ -442,96 +481,49 @@ pgErrorStatus authed (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError
"42P01" -> HTTP.status404 -- undefined table "42P01" -> HTTP.status404 -- undefined table
"42501" -> if authed then HTTP.status403 else HTTP.status401 -- insufficient privilege "42501" -> if authed then HTTP.status403 else HTTP.status401 -- insufficient privilege
'P':'T':n -> fromMaybe HTTP.status500 (HTTP.mkStatus <$> readMaybe n <*> pure m) 'P':'T':n -> fromMaybe HTTP.status500 (HTTP.mkStatus <$> readMaybe n <*> pure m)
"PGRST" ->
case (parseMessage m, parseDetails d) of
(Just _, Just r) -> maybe (toEnum $ getStatus r) (HTTP.mkStatus (getStatus r) . T.encodeUtf8) (getStatusText r)
_ -> status PGRSTParseError
_ -> HTTP.status400 _ -> HTTP.status400
_ -> HTTP.status500 _ -> HTTP.status500
data Error data Error
= ApiRequestError ApiRequestError = ApiRequestError ApiRequestError
| GucHeadersError
| GucStatusError
| JwtTokenInvalid Text | JwtTokenInvalid Text
| JwtTokenMissing | JwtTokenMissing
| JwtTokenRequired | JwtTokenRequired
| NoSchemaCacheError | NoSchemaCacheError
| OffLimitsChangesError Int64 Integer
| PgErr PgError | PgErr PgError
| PutMatchingPkError
| SingularityError Integer
instance PgrstError Error where instance PgrstError Error where
status (ApiRequestError err) = status err status (ApiRequestError err) = status err
status GucHeadersError = HTTP.status500 status JwtTokenInvalid{} = HTTP.unauthorized401
status GucStatusError = HTTP.status500 status JwtTokenMissing = HTTP.status500
status JwtTokenInvalid{} = HTTP.unauthorized401 status JwtTokenRequired = HTTP.unauthorized401
status JwtTokenMissing = HTTP.status500 status NoSchemaCacheError = HTTP.status503
status JwtTokenRequired = HTTP.unauthorized401 status (PgErr err) = status err
status NoSchemaCacheError = HTTP.status503
status OffLimitsChangesError{} = HTTP.status400
status (PgErr err) = status err
status PutMatchingPkError = HTTP.status400
status SingularityError{} = HTTP.status406
headers (ApiRequestError err) = headers err headers (ApiRequestError err) = headers err
headers (JwtTokenInvalid m) = [MediaType.toContentType MTApplicationJSON, invalidTokenHeader m] headers (JwtTokenInvalid m) = [invalidTokenHeader m]
headers JwtTokenRequired = [MediaType.toContentType MTApplicationJSON, requiredTokenHeader] headers JwtTokenRequired = [requiredTokenHeader]
headers (PgErr err) = headers err headers (PgErr err) = headers err
headers SingularityError{} = [MediaType.toContentType (MTSingularJSON False)] headers _ = mempty
headers _ = [MediaType.toContentType MTApplicationJSON]
instance JSON.ToJSON Error where instance JSON.ToJSON Error where
toJSON NoSchemaCacheError = JSON.object [ toJSON NoSchemaCacheError = toJsonPgrstError
"code" .= ConnectionErrorCode02, ConnectionErrorCode02 "Could not query the database for the schema cache. Retrying." Nothing Nothing
"message" .= ("Could not query the database for the schema cache. Retrying." :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON JwtTokenMissing = JSON.object [ toJSON JwtTokenMissing = toJsonPgrstError
"code" .= JWTErrorCode00, JWTErrorCode00 "Server lacks JWT secret" Nothing Nothing
"message" .= ("Server lacks JWT secret" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (JwtTokenInvalid message) = JSON.object [
"code" .= JWTErrorCode01,
"message" .= (message :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON JwtTokenRequired = JSON.object [
"code" .= JWTErrorCode02,
"message" .= ("Anonymous access is disabled" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (OffLimitsChangesError n maxs) = JSON.object [ toJSON (JwtTokenInvalid message) = toJsonPgrstError
"code" .= ApiRequestErrorCode10, JWTErrorCode01 message Nothing Nothing
"message" .= ("The maximum number of rows allowed to change was surpassed" :: Text),
"details" .= T.unwords ["Results contain", show n, "rows changed but the maximum number allowed is", show maxs],
"hint" .= JSON.Null]
toJSON GucHeadersError = JSON.object [ toJSON JwtTokenRequired = toJsonPgrstError
"code" .= ApiRequestErrorCode11, JWTErrorCode02 "Anonymous access is disabled" Nothing Nothing
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON GucStatusError = JSON.object [
"code" .= ApiRequestErrorCode12,
"message" .= ("response.status guc must be a valid status code" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON PutMatchingPkError = JSON.object [
"code" .= ApiRequestErrorCode15,
"message" .= ("Payload values do not match URL in primary key column(s)" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (SingularityError n) = JSON.object [
"code" .= ApiRequestErrorCode16,
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),
"details" .= T.unwords ["The result contains", show n, "rows"],
"hint" .= JSON.Null]
toJSON (PgErr err) = JSON.toJSON err toJSON (PgErr err) = JSON.toJSON err
toJSON (ApiRequestError err) = JSON.toJSON err toJSON (ApiRequestError err) = JSON.toJSON err
@@ -543,8 +535,44 @@ invalidTokenHeader m =
requiredTokenHeader :: Header requiredTokenHeader :: Header
requiredTokenHeader = ("WWW-Authenticate", "Bearer") requiredTokenHeader = ("WWW-Authenticate", "Bearer")
singularityError :: (Integral a) => a -> Error -- For parsing byteString to JSON Object, used for allowing full response control
singularityError = SingularityError . toInteger data PgRaiseErrMessage = PgRaiseErrMessage {
getCode :: Text,
getMessage :: Text,
getDetails :: Maybe Text,
getHint :: Maybe Text
}
data PgRaiseErrDetails = PgRaiseErrDetails {
getStatus :: Int,
getStatusText :: Maybe Text,
getHeaders :: Map Text Text
}
instance JSON.FromJSON PgRaiseErrMessage where
parseJSON (JSON.Object m) =
PgRaiseErrMessage
<$> m .: "code"
<*> m .: "message"
<*> m .:? "details"
<*> m .:? "hint"
parseJSON _ = mzero
instance JSON.FromJSON PgRaiseErrDetails where
parseJSON (JSON.Object d) =
PgRaiseErrDetails
<$> d .: "status"
<*> d .:? "status_text"
<*> d .: "headers"
parseJSON _ = mzero
parseMessage :: ByteString -> Maybe PgRaiseErrMessage
parseMessage = JSON.decodeStrict
parseDetails :: Maybe ByteString -> Maybe PgRaiseErrDetails
parseDetails d = JSON.decodeStrict =<< d
-- Error codes are grouped by common modules or characteristics -- Error codes are grouped by common modules or characteristics
data ErrorCode data ErrorCode
@@ -558,7 +586,7 @@ data ErrorCode
| ApiRequestErrorCode01 | ApiRequestErrorCode01
| ApiRequestErrorCode02 | ApiRequestErrorCode02
| ApiRequestErrorCode03 | ApiRequestErrorCode03
| ApiRequestErrorCode04 -- no longer used (used to be mapped to ParseRequestError) -- | ApiRequestErrorCode04 -- no longer used (used to be mapped to ParseRequestError)
| ApiRequestErrorCode05 | ApiRequestErrorCode05
| ApiRequestErrorCode06 | ApiRequestErrorCode06
| ApiRequestErrorCode07 | ApiRequestErrorCode07
@@ -566,8 +594,8 @@ data ErrorCode
| ApiRequestErrorCode09 | ApiRequestErrorCode09
| ApiRequestErrorCode10 | ApiRequestErrorCode10
| ApiRequestErrorCode11 | ApiRequestErrorCode11
-- | ApiRequestErrorCode13 -- no longer used (used to be mapped to BinaryFieldError)
| ApiRequestErrorCode12 | ApiRequestErrorCode12
| ApiRequestErrorCode13
| ApiRequestErrorCode14 | ApiRequestErrorCode14
| ApiRequestErrorCode15 | ApiRequestErrorCode15
| ApiRequestErrorCode16 | ApiRequestErrorCode16
@@ -575,6 +603,9 @@ data ErrorCode
| ApiRequestErrorCode18 | ApiRequestErrorCode18
| ApiRequestErrorCode19 | ApiRequestErrorCode19
| ApiRequestErrorCode20 | ApiRequestErrorCode20
| ApiRequestErrorCode21
| ApiRequestErrorCode22
| ApiRequestErrorCode23
-- Schema Cache errors -- Schema Cache errors
| SchemaCacheErrorCode00 | SchemaCacheErrorCode00
| SchemaCacheErrorCode01 | SchemaCacheErrorCode01
@@ -604,7 +635,6 @@ buildErrorCode code = "PGRST" <> case code of
ApiRequestErrorCode01 -> "101" ApiRequestErrorCode01 -> "101"
ApiRequestErrorCode02 -> "102" ApiRequestErrorCode02 -> "102"
ApiRequestErrorCode03 -> "103" ApiRequestErrorCode03 -> "103"
ApiRequestErrorCode04 -> "104"
ApiRequestErrorCode05 -> "105" ApiRequestErrorCode05 -> "105"
ApiRequestErrorCode06 -> "106" ApiRequestErrorCode06 -> "106"
ApiRequestErrorCode07 -> "107" ApiRequestErrorCode07 -> "107"
@@ -613,7 +643,6 @@ buildErrorCode code = "PGRST" <> case code of
ApiRequestErrorCode10 -> "110" ApiRequestErrorCode10 -> "110"
ApiRequestErrorCode11 -> "111" ApiRequestErrorCode11 -> "111"
ApiRequestErrorCode12 -> "112" ApiRequestErrorCode12 -> "112"
ApiRequestErrorCode13 -> "113"
ApiRequestErrorCode14 -> "114" ApiRequestErrorCode14 -> "114"
ApiRequestErrorCode15 -> "115" ApiRequestErrorCode15 -> "115"
ApiRequestErrorCode16 -> "116" ApiRequestErrorCode16 -> "116"
@@ -621,6 +650,9 @@ buildErrorCode code = "PGRST" <> case code of
ApiRequestErrorCode18 -> "118" ApiRequestErrorCode18 -> "118"
ApiRequestErrorCode19 -> "119" ApiRequestErrorCode19 -> "119"
ApiRequestErrorCode20 -> "120" ApiRequestErrorCode20 -> "120"
ApiRequestErrorCode21 -> "121"
ApiRequestErrorCode22 -> "122"
ApiRequestErrorCode23 -> "123"
SchemaCacheErrorCode00 -> "200" SchemaCacheErrorCode00 -> "200"
SchemaCacheErrorCode01 -> "201" SchemaCacheErrorCode01 -> "201"
+33 -42
View File
@@ -1,9 +1,10 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.MediaType module PostgREST.MediaType
( MediaType(..) ( MediaType(..)
, MTPlanOption (..) , MTVndPlanOption (..)
, MTPlanFormat (..) , MTVndPlanFormat (..)
, toContentType , toContentType
, toMime , toMime
, decodeMediaType , decodeMediaType
@@ -19,8 +20,6 @@ import Protolude
-- | Enumeration of currently supported media types -- | Enumeration of currently supported media types
data MediaType data MediaType
= MTApplicationJSON = MTApplicationJSON
| MTArrayJSONStrip
| MTSingularJSON Bool
| MTGeoJSON | MTGeoJSON
| MTTextCSV | MTTextCSV
| MTTextPlain | MTTextPlain
@@ -30,32 +29,23 @@ data MediaType
| MTOctetStream | MTOctetStream
| MTAny | MTAny
| MTOther ByteString | MTOther ByteString
-- TODO MTPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes. -- vendored media types
| MTPlan MediaType MTPlanFormat [MTPlanOption] | MTVndArrayJSONStrip
deriving Show | MTVndSingularJSON Bool
instance Eq MediaType where -- TODO MTVndPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes.
MTApplicationJSON == MTApplicationJSON = True | MTVndPlan MediaType MTVndPlanFormat [MTVndPlanOption]
MTArrayJSONStrip == MTArrayJSONStrip = True deriving (Eq, Show, Generic)
MTSingularJSON x == MTSingularJSON y = x == y instance Hashable MediaType
MTGeoJSON == MTGeoJSON = True
MTTextCSV == MTTextCSV = True
MTTextPlain == MTTextPlain = True
MTTextXML == MTTextXML = True
MTOpenAPI == MTOpenAPI = True
MTUrlEncoded == MTUrlEncoded = True
MTOctetStream == MTOctetStream = True
MTAny == MTAny = True
MTOther x == MTOther y = x == y
MTPlan{} == MTPlan{} = True
_ == _ = False
data MTPlanOption data MTVndPlanOption
= PlanAnalyze | PlanVerbose | PlanSettings | PlanBuffers | PlanWAL = PlanAnalyze | PlanVerbose | PlanSettings | PlanBuffers | PlanWAL
deriving (Eq, Show) deriving (Eq, Show, Generic)
instance Hashable MTVndPlanOption
data MTPlanFormat data MTVndPlanFormat
= PlanJSON | PlanText = PlanJSON | PlanText
deriving (Eq, Show) deriving (Eq, Show, Generic)
instance Hashable MTVndPlanFormat
-- | Convert MediaType to a Content-Type HTTP Header -- | Convert MediaType to a Content-Type HTTP Header
toContentType :: MediaType -> Header toContentType :: MediaType -> Header
@@ -69,31 +59,31 @@ toContentType ct = (hContentType, toMime ct <> charset)
-- | Convert from MediaType to a ByteString representing the mime type -- | Convert from MediaType to a ByteString representing the mime type
toMime :: MediaType -> ByteString toMime :: MediaType -> ByteString
toMime MTApplicationJSON = "application/json" toMime MTApplicationJSON = "application/json"
toMime MTArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped" toMime MTVndArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped"
toMime MTGeoJSON = "application/geo+json" toMime MTGeoJSON = "application/geo+json"
toMime MTTextCSV = "text/csv" toMime MTTextCSV = "text/csv"
toMime MTTextPlain = "text/plain" toMime MTTextPlain = "text/plain"
toMime MTTextXML = "text/xml" toMime MTTextXML = "text/xml"
toMime MTOpenAPI = "application/openapi+json" toMime MTOpenAPI = "application/openapi+json"
toMime (MTSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped" toMime (MTVndSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped"
toMime (MTSingularJSON False) = "application/vnd.pgrst.object+json" toMime (MTVndSingularJSON False) = "application/vnd.pgrst.object+json"
toMime MTUrlEncoded = "application/x-www-form-urlencoded" toMime MTUrlEncoded = "application/x-www-form-urlencoded"
toMime MTOctetStream = "application/octet-stream" toMime MTOctetStream = "application/octet-stream"
toMime MTAny = "*/*" toMime MTAny = "*/*"
toMime (MTOther ct) = ct toMime (MTOther ct) = ct
toMime (MTPlan mt fmt opts) = toMime (MTVndPlan mt fmt opts) =
"application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <> "application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <>
("; for=\"" <> toMime mt <> "\"") <> ("; for=\"" <> toMime mt <> "\"") <>
(if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts)) (if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts))
toMimePlanOption :: MTPlanOption -> ByteString toMimePlanOption :: MTVndPlanOption -> ByteString
toMimePlanOption PlanAnalyze = "analyze" toMimePlanOption PlanAnalyze = "analyze"
toMimePlanOption PlanVerbose = "verbose" toMimePlanOption PlanVerbose = "verbose"
toMimePlanOption PlanSettings = "settings" toMimePlanOption PlanSettings = "settings"
toMimePlanOption PlanBuffers = "buffers" toMimePlanOption PlanBuffers = "buffers"
toMimePlanOption PlanWAL = "wal" toMimePlanOption PlanWAL = "wal"
toMimePlanFormat :: MTPlanFormat -> ByteString toMimePlanFormat :: MTVndPlanFormat -> ByteString
toMimePlanFormat PlanJSON = "json" toMimePlanFormat PlanJSON = "json"
toMimePlanFormat PlanText = "text" toMimePlanFormat PlanText = "text"
@@ -103,25 +93,25 @@ toMimePlanFormat PlanText = "text"
-- MTApplicationJSON -- MTApplicationJSON
-- --
-- >>> decodeMediaType "application/vnd.pgrst.plan;" -- >>> decodeMediaType "application/vnd.pgrst.plan;"
-- MTPlan MTApplicationJSON PlanText [] -- MTVndPlan MTApplicationJSON PlanText []
-- --
-- >>> decodeMediaType "application/vnd.pgrst.plan;for=\"application/json\"" -- >>> decodeMediaType "application/vnd.pgrst.plan;for=\"application/json\""
-- MTPlan MTApplicationJSON PlanText [] -- MTVndPlan MTApplicationJSON PlanText []
-- --
-- >>> decodeMediaType "application/vnd.pgrst.plan+json;for=\"text/csv\"" -- >>> decodeMediaType "application/vnd.pgrst.plan+json;for=\"text/csv\""
-- MTPlan MTTextCSV PlanJSON [] -- MTVndPlan MTTextCSV PlanJSON []
-- --
-- >>> decodeMediaType "application/vnd.pgrst.array+json;nulls=stripped" -- >>> decodeMediaType "application/vnd.pgrst.array+json;nulls=stripped"
-- MTArrayJSONStrip -- MTVndArrayJSONStrip
-- --
-- >>> decodeMediaType "application/vnd.pgrst.array+json" -- >>> decodeMediaType "application/vnd.pgrst.array+json"
-- MTApplicationJSON -- MTApplicationJSON
-- --
-- >>> decodeMediaType "application/vnd.pgrst.object+json;nulls=stripped" -- >>> decodeMediaType "application/vnd.pgrst.object+json;nulls=stripped"
-- MTSingularJSON True -- MTVndSingularJSON True
-- --
-- >>> decodeMediaType "application/vnd.pgrst.object+json" -- >>> decodeMediaType "application/vnd.pgrst.object+json"
-- MTSingularJSON False -- MTVndSingularJSON False
decodeMediaType :: BS.ByteString -> MediaType decodeMediaType :: BS.ByteString -> MediaType
decodeMediaType mt = decodeMediaType mt =
@@ -140,15 +130,16 @@ decodeMediaType mt =
"application/vnd.pgrst.object+json":rest -> checkSingularNullStrip rest "application/vnd.pgrst.object+json":rest -> checkSingularNullStrip rest
"application/vnd.pgrst.object":rest -> checkSingularNullStrip rest "application/vnd.pgrst.object":rest -> checkSingularNullStrip rest
"application/vnd.pgrst.array+json":rest -> checkArrayNullStrip rest "application/vnd.pgrst.array+json":rest -> checkArrayNullStrip rest
"application/vnd.pgrst.array":rest -> checkArrayNullStrip rest
"*/*":_ -> MTAny "*/*":_ -> MTAny
other:_ -> MTOther other other:_ -> MTOther other
_ -> MTAny _ -> MTAny
where where
checkArrayNullStrip ["nulls=stripped"] = MTArrayJSONStrip checkArrayNullStrip ["nulls=stripped"] = MTVndArrayJSONStrip
checkArrayNullStrip _ = MTApplicationJSON checkArrayNullStrip _ = MTApplicationJSON
checkSingularNullStrip ["nulls=stripped"] = MTSingularJSON True checkSingularNullStrip ["nulls=stripped"] = MTVndSingularJSON True
checkSingularNullStrip _ = MTSingularJSON False checkSingularNullStrip _ = MTVndSingularJSON False
getPlan fmt rest = getPlan fmt rest =
let let
@@ -160,7 +151,7 @@ decodeMediaType mt =
strippedFor <- BS.stripPrefix "for=" foundFor strippedFor <- BS.stripPrefix "for=" foundFor
pure . decodeMediaType $ dropAround (== BS.c2w '"') strippedFor pure . decodeMediaType $ dropAround (== BS.c2w '"') strippedFor
in in
MTPlan mtFor fmt $ MTVndPlan mtFor fmt $
[PlanAnalyze | inOpts "analyze" ] ++ [PlanAnalyze | inOpts "analyze" ] ++
[PlanVerbose | inOpts "verbose" ] ++ [PlanVerbose | inOpts "verbose" ] ++
[PlanSettings | inOpts "settings"] ++ [PlanSettings | inOpts "settings"] ++
+279 -132
View File
@@ -19,10 +19,11 @@ module PostgREST.Plan
( wrappedReadPlan ( wrappedReadPlan
, mutateReadPlan , mutateReadPlan
, callReadPlan , callReadPlan
, inspectPlan
, WrappedReadPlan(..) , WrappedReadPlan(..)
, MutateReadPlan(..) , MutateReadPlan(..)
, CallReadPlan(..) , CallReadPlan(..)
, inspectPlanTxMode , InspectPlan(..)
) where ) where
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
@@ -33,7 +34,7 @@ import qualified Data.Set as S
import qualified PostgREST.SchemaCache.Routine as Routine import qualified PostgREST.SchemaCache.Routine as Routine
import Data.Either.Combinators (mapLeft, mapRight) import Data.Either.Combinators (mapLeft, mapRight)
import Data.List (delete) import Data.List (delete, lookup)
import Data.Tree (Tree (..)) import Data.Tree (Tree (..))
import PostgREST.ApiRequest (Action (..), import PostgREST.ApiRequest (Action (..),
@@ -51,6 +52,7 @@ import PostgREST.RangeQuery (NonnegRange, allRange,
import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
RelIdentifier (..),
Schema) Schema)
import PostgREST.SchemaCache.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
@@ -59,7 +61,9 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
relIsToOne) relIsToOne)
import PostgREST.SchemaCache.Representations (DataRepresentation (..), import PostgREST.SchemaCache.Representations (DataRepresentation (..),
RepresentationsMap) RepresentationsMap)
import PostgREST.SchemaCache.Routine (ResultAggregate (..), import PostgREST.SchemaCache.Routine (MediaHandler (..),
MediaHandlerMap,
ResolvedHandler,
Routine (..), Routine (..),
RoutineMap, RoutineMap,
RoutineParam (..), RoutineParam (..),
@@ -80,6 +84,7 @@ import PostgREST.Plan.Types
import qualified Hasql.Transaction.Sessions as SQL import qualified Hasql.Transaction.Sessions as SQL
import qualified PostgREST.ApiRequest.QueryParams as QueryParams import qualified PostgREST.ApiRequest.QueryParams as QueryParams
import qualified PostgREST.MediaType as MediaType
import Protolude hiding (from) import Protolude hiding (from)
@@ -90,14 +95,18 @@ import Protolude hiding (from)
data WrappedReadPlan = WrappedReadPlan { data WrappedReadPlan = WrappedReadPlan {
wrReadPlan :: ReadPlanTree wrReadPlan :: ReadPlanTree
, wrTxMode :: SQL.Mode , wrTxMode :: SQL.Mode
, wrResAgg :: ResultAggregate , wrHandler :: MediaHandler
, wrMedia :: MediaType
, wrIdent :: QualifiedIdentifier
} }
data MutateReadPlan = MutateReadPlan { data MutateReadPlan = MutateReadPlan {
mrReadPlan :: ReadPlanTree mrReadPlan :: ReadPlanTree
, mrMutatePlan :: MutatePlan , mrMutatePlan :: MutatePlan
, mrTxMode :: SQL.Mode , mrTxMode :: SQL.Mode
, mrResAgg :: ResultAggregate , mrHandler :: MediaHandler
, mrMedia :: MediaType
, mrIdent :: QualifiedIdentifier
} }
data CallReadPlan = CallReadPlan { data CallReadPlan = CallReadPlan {
@@ -105,37 +114,46 @@ data CallReadPlan = CallReadPlan {
, crCallPlan :: CallPlan , crCallPlan :: CallPlan
, crTxMode :: SQL.Mode , crTxMode :: SQL.Mode
, crProc :: Routine , crProc :: Routine
, crResAgg :: ResultAggregate , crHandler :: MediaHandler
, crMedia :: MediaType
, crIdent :: QualifiedIdentifier
}
data InspectPlan = InspectPlan {
ipMedia :: MediaType
, ipTxmode :: SQL.Mode
} }
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error WrappedReadPlan wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error WrappedReadPlan
wrappedReadPlan identifier conf sCache apiRequest = do wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} = do
rPlan <- readPlan identifier conf sCache apiRequest rPlan <- readPlan identifier conf sCache apiRequest
binField <- mapLeft ApiRequestError $ binaryField conf (iAcceptMediaType apiRequest) Nothing rPlan (hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache)
return $ WrappedReadPlan rPlan SQL.Read $ mediaToAggregate (iAcceptMediaType apiRequest) binField apiRequest if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
return $ WrappedReadPlan rPlan SQL.Read hdler mediaType identifier
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error MutateReadPlan mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error MutateReadPlan
mutateReadPlan mutation apiRequest identifier conf sCache = do mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do
rPlan <- readPlan identifier conf sCache apiRequest rPlan <- readPlan identifier conf sCache apiRequest
binField <- mapLeft ApiRequestError $ binaryField conf (iAcceptMediaType apiRequest) Nothing rPlan
mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan
return $ MutateReadPlan rPlan mPlan SQL.Write $ mediaToAggregate (iAcceptMediaType apiRequest) binField apiRequest if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
(hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache)
return $ MutateReadPlan rPlan mPlan SQL.Write hdler mediaType identifier
callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CallReadPlan callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CallReadPlan
callReadPlan identifier conf sCache apiRequest invMethod = do callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} invMethod = do
let paramKeys = case invMethod of let paramKeys = case invMethod of
InvGet -> S.fromList $ fst <$> qsParams' InvGet -> S.fromList $ fst <$> qsParams'
InvHead -> S.fromList $ fst <$> qsParams' InvHead -> S.fromList $ fst <$> qsParams'
InvPost -> iColumns apiRequest InvPost -> iColumns
proc@Function{..} <- mapLeft ApiRequestError $ proc@Function{..} <- mapLeft ApiRequestError $
findProc identifier paramKeys (preferParameters == Just SingleObject) (dbRoutines sCache) (iContentMediaType apiRequest) (invMethod == InvPost) findProc identifier paramKeys (preferParameters == Just SingleObject) (dbRoutines sCache) iContentMediaType (invMethod == InvPost)
let relIdentifier = QualifiedIdentifier pdSchema (fromMaybe pdName $ Routine.funcTableName proc) -- done so a set returning function can embed other relations let relIdentifier = QualifiedIdentifier pdSchema (fromMaybe pdName $ Routine.funcTableName proc) -- done so a set returning function can embed other relations
rPlan <- readPlan relIdentifier conf sCache apiRequest rPlan <- readPlan relIdentifier conf sCache apiRequest
let args = case (invMethod, iContentMediaType apiRequest) of let args = case (invMethod, iContentMediaType) of
(InvGet, _) -> jsonRpcParams proc qsParams' (InvGet, _) -> jsonRpcParams proc qsParams'
(InvHead, _) -> jsonRpcParams proc qsParams' (InvHead, _) -> jsonRpcParams proc qsParams'
(InvPost, MTUrlEncoded) -> maybe mempty (jsonRpcParams proc . payArray) $ iPayload apiRequest (InvPost, MTUrlEncoded) -> maybe mempty (jsonRpcParams proc . payArray) iPayload
(InvPost, _) -> maybe mempty payRaw $ iPayload apiRequest (InvPost, _) -> maybe mempty payRaw iPayload
txMode = case (invMethod, pdVolatility) of txMode = case (invMethod, pdVolatility) of
(InvGet, _) -> SQL.Read (InvGet, _) -> SQL.Read
(InvHead, _) -> SQL.Read (InvHead, _) -> SQL.Read
@@ -143,11 +161,20 @@ callReadPlan identifier conf sCache apiRequest invMethod = do
(InvPost, Routine.Immutable) -> SQL.Read (InvPost, Routine.Immutable) -> SQL.Read
(InvPost, Routine.Volatile) -> SQL.Write (InvPost, Routine.Volatile) -> SQL.Write
cPlan = callPlan proc apiRequest paramKeys args rPlan cPlan = callPlan proc apiRequest paramKeys args rPlan
binField <- mapLeft ApiRequestError $ binaryField conf (iAcceptMediaType apiRequest) (Just proc) rPlan (hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest relIdentifier iAcceptMediaType (dbMediaHandlers sCache)
return $ CallReadPlan rPlan cPlan txMode proc $ mediaToAggregate (iAcceptMediaType apiRequest) binField apiRequest if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
return $ CallReadPlan rPlan cPlan txMode proc hdler mediaType relIdentifier
where where
Preferences{..} = iPreferences apiRequest qsParams' = QueryParams.qsParams iQueryParams
qsParams' = QueryParams.qsParams (iQueryParams apiRequest)
inspectPlan :: ApiRequest -> Either Error InspectPlan
inspectPlan apiRequest = do
let producedMTs = [MTOpenAPI, MTApplicationJSON, MTAny]
accepts = iAcceptMediaType apiRequest
mediaType <- if not . null $ L.intersect accepts producedMTs
then Right MTOpenAPI
else Left . ApiRequestError . MediaTypeError $ MediaType.toMime <$> accepts
return $ InspectPlan mediaType SQL.Read
{-| {-|
Search a pg proc by matching name and arguments keys to parameters. Since a function can be overloaded, Search a pg proc by matching name and arguments keys to parameters. Since a function can be overloaded,
@@ -206,9 +233,6 @@ findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPo
-- and can match any or none of the default parameters. -- and can match any or none of the default parameters.
(reqParams, optParams) -> argumentsKeys `S.difference` S.fromList (ppName <$> optParams) == S.fromList (ppName <$> reqParams) (reqParams, optParams) -> argumentsKeys `S.difference` S.fromList (ppName <$> optParams) == S.fromList (ppName <$> reqParams)
inspectPlanTxMode :: SQL.Mode
inspectPlanTxMode = SQL.Read
-- | During planning we need to resolve Field -> CoercibleField (finding the context specific target type and map function). -- | During planning we need to resolve Field -> CoercibleField (finding the context specific target type and map function).
-- | ResolverContext facilitates this without the need to pass around a laundry list of parameters. -- | ResolverContext facilitates this without the need to pass around a laundry list of parameters.
data ResolverContext = ResolverContext data ResolverContext = ResolverContext
@@ -226,22 +250,19 @@ resolveTableFieldName table fieldName =
fromMaybe (unknownField fieldName []) $ HMI.lookup fieldName (tableColumns table) >>= fromMaybe (unknownField fieldName []) $ HMI.lookup fieldName (tableColumns table) >>=
Just . resolveColumnField Just . resolveColumnField
resolveTableField :: Table -> Field -> CoercibleField
resolveTableField table (fieldName, []) = resolveTableFieldName table fieldName
resolveTableField table (fieldName, jp) =
case resolveTableFieldName table fieldName of
-- types that are already json/jsonb don't need to be converted with `to_jsonb` for using arrow operators `data->attr`
-- this prevents indexes not applying https://github.com/PostgREST/postgrest/issues/2594
cf@CoercibleField{cfIRType="json"} -> cf{cfJsonPath=jp}
cf@CoercibleField{cfIRType="jsonb"} -> cf{cfJsonPath=jp}
-- other types will get converted `to_jsonb(col)->attr`
cf -> cf{cfJsonPath=jp, cfToJson=True}
-- | Resolve a type within the context based on the given field name and JSON path. Although there are situations where failure to resolve a field is considered an error (see `resolveOrError`), there are also situations where we allow it (RPC calls). If it should be an error and `resolveOrError` doesn't fit, ensure to check the `cfIRType` isn't empty. -- | Resolve a type within the context based on the given field name and JSON path. Although there are situations where failure to resolve a field is considered an error (see `resolveOrError`), there are also situations where we allow it (RPC calls). If it should be an error and `resolveOrError` doesn't fit, ensure to check the `cfIRType` isn't empty.
resolveTypeOrUnknown :: ResolverContext -> Field -> CoercibleField resolveTypeOrUnknown :: ResolverContext -> Field -> CoercibleField
resolveTypeOrUnknown ResolverContext{..} field@(fn, jp) = resolveTypeOrUnknown ResolverContext{..} (fn, jp) =
fromMaybe (unknownField fn jp) $ HM.lookup qi tables >>= case res of
Just . flip resolveTableField field -- types that are already json/jsonb don't need to be converted with `to_jsonb` for using arrow operators `data->attr`
-- this prevents indexes not applying https://github.com/PostgREST/postgrest/issues/2594
cf@CoercibleField{cfIRType="json"} -> cf{cfJsonPath=jp, cfToJson=False}
cf@CoercibleField{cfIRType="jsonb"} -> cf{cfJsonPath=jp, cfToJson=False}
-- other types will get converted `to_jsonb(col)->attr`, even unknown types
cf -> cf{cfJsonPath=jp, cfToJson=True}
where
res = fromMaybe (unknownField fn jp) $ HM.lookup qi tables >>=
Just . flip resolveTableFieldName fn
-- | Install any pre-defined data representation from source to target to coerce this reference. -- | Install any pre-defined data representation from source to target to coerce this reference.
-- --
@@ -277,18 +298,21 @@ resolveQueryInputField ctx field = withTextParse ctx $ resolveTypeOrUnknown ctx
-- | Adds filters, order, limits on its respective nodes. -- | Adds filters, order, limits on its respective nodes.
-- | Adds joins conditions obtained from resource embedding. -- | Adds joins conditions obtained from resource embedding.
readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows} SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest = readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregates} SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest =
let let
-- JSON output format hardcoded for now. In the future we might want to support other output mappings such as CSV. -- JSON output format hardcoded for now. In the future we might want to support other output mappings such as CSV.
ctx = ResolverContext dbTables dbRepresentations qi "json" ctx = ResolverContext dbTables dbRepresentations qi "json"
in in
mapLeft ApiRequestError $ mapLeft ApiRequestError $
treeRestrictRange configDbMaxRows (iAction apiRequest) =<< treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
validateAggFunctions configDbAggregates =<<
hoistSpreadAggFunctions =<<
addRelSelects =<<
addNullEmbedFilters =<< addNullEmbedFilters =<<
validateSpreadEmbeds =<< validateSpreadEmbeds =<<
addRelatedOrders =<< addRelatedOrders =<<
addDataRepresentationAliases =<< addAliases =<<
expandStarsForDataRepresentations ctx =<< expandStars ctx =<<
addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<< addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<<
addLogicTrees ctx apiRequest =<< addLogicTrees ctx apiRequest =<<
addRanges apiRequest =<< addRanges apiRequest =<<
@@ -301,7 +325,7 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} =
foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} [] foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} []
where where
rootDepth = 0 rootDepth = 0
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing False rootDepth defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing False [] rootDepth
treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree
treeEntry depth (Node si fldForest) (Node q rForest) = treeEntry depth (Node si fldForest) (Node q rForest) =
let nxtDepth = succ depth in let nxtDepth = succ depth in
@@ -317,49 +341,86 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} =
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relIsSpread=True} []) (Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relIsSpread=True} [])
fldForest:rForest fldForest:rForest
SelectField{..} -> SelectField{..} ->
Node q{select=(resolveOutputField ctx{qi=from q} selField, selCast, selAlias):select q} rForest Node q{select=CoercibleSelectField (resolveOutputField ctx{qi=from q} selField) selAggregateFunction selAggregateCast selCast selAlias:select q} rForest
-- | Preserve the original field name if data representation is used to coerce the value. -- If an alias is explicitly specified, it is always respected. However, an alias may be
addDataRepresentationAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree -- determined automatically in the case of a select term with a JSON path, or in the case
addDataRepresentationAliases rPlanTree = Right $ fmap (\rPlan@ReadPlan{select=sel} -> rPlan{select=map aliasSelectItem sel}) rPlanTree -- of domain representations.
addAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
addAliases = Right . fmap addAliasToPlan
where where
aliasSelectItem :: (CoercibleField, Maybe Cast, Maybe Alias) -> (CoercibleField, Maybe Cast, Maybe Alias) addAliasToPlan rp@ReadPlan{select=sel} = rp{select=map aliasSelectField sel}
-- If there already is an alias, don't overwrite it.
aliasSelectItem (fld@(CoercibleField{cfName=fieldName, cfTransform=(Just _)}), Nothing, Nothing) = (fld, Nothing, Just fieldName) aliasSelectField :: CoercibleSelectField -> CoercibleSelectField
aliasSelectItem fld = fld aliasSelectField field@CoercibleSelectField{csField=fieldDetails, csAggFunction=aggFun, csAlias=alias}
| isJust alias || isJust aggFun = field
| isJsonKeyPath fieldDetails, Just key <- lastJsonKey fieldDetails = field { csAlias = Just key }
| isTransformPath fieldDetails = field { csAlias = Just (cfName fieldDetails) }
| otherwise = field
isJsonKeyPath CoercibleField{cfJsonPath=(_: _)} = True
isJsonKeyPath _ = False
isTransformPath CoercibleField{cfTransform=(Just _), cfName=_} = True
isTransformPath _ = False
lastJsonKey CoercibleField{cfName=fieldName, cfJsonPath=jsonPath} =
case jOp <$> lastMay jsonPath of
Just (JKey key) -> Just key
Just (JIdx _) -> Just $ fromMaybe fieldName 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 jsonPath)
Nothing -> Nothing
knownColumnsInContext :: ResolverContext -> [Column] knownColumnsInContext :: ResolverContext -> [Column]
knownColumnsInContext ResolverContext{..} = knownColumnsInContext ResolverContext{..} =
fromMaybe [] $ HM.lookup qi tables >>= fromMaybe [] $ HM.lookup qi tables >>=
Just . tableColumnsList Just . tableColumnsList
-- | Expand "select *" into explicit field names of the table, if necessary to apply data representations. -- | Expand "select *" into explicit field names of the table in the following situations:
expandStarsForDataRepresentations :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree -- * When there are data representations present.
expandStarsForDataRepresentations ctx@ResolverContext{qi} rPlanTree = Right $ fmap expandStars rPlanTree -- * When there is an aggregate function in a given ReadPlan or its parent.
expandStars :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
where where
expandStars :: ReadPlan -> ReadPlan expandStarsForReadPlan :: Bool -> ReadPlanTree -> ReadPlanTree
expandStarsForReadPlan hasAgg (Node rp@ReadPlan{select, from=fromQI, fromAlias=alias} children) =
let
newHasAgg = hasAgg || any (isJust . csAggFunction) select
newCtx = adjustContext ctx fromQI alias
newRPlan = expandStarsForTable newCtx newHasAgg rp
in Node newRPlan (map (expandStarsForReadPlan newHasAgg) children)
-- Choose the appropriate context based on whether we're dealing with "pgrst_source"
adjustContext :: ResolverContext -> QualifiedIdentifier -> Maybe Text -> ResolverContext
-- When the schema is "" and the table is the source CTE, we assume the true source table is given in the from -- When the schema is "" and the table is the source CTE, we assume the true source table is given in the from
-- alias and belongs to the request schema. See the bit in `addRels` with `newFrom = ...`. -- alias and belongs to the request schema. See the bit in `addRels` with `newFrom = ...`.
expandStars rPlan@ReadPlan{from=(QualifiedIdentifier "" "pgrst_source"), fromAlias=(Just tblAlias)} = adjustContext context@ResolverContext{qi=ctxQI} (QualifiedIdentifier "" "pgrst_source") (Just a) = context{qi=ctxQI{qiName=a}}
expandStarsForTable ctx{qi=qi{qiName=tblAlias}} rPlan adjustContext context fromQI _ = context{qi=fromQI}
expandStars rPlan@ReadPlan{from=fromTable} =
expandStarsForTable ctx{qi=fromTable} rPlan
expandStarsForTable :: ResolverContext -> ReadPlan -> ReadPlan expandStarsForTable :: ResolverContext -> Bool -> ReadPlan -> ReadPlan
expandStarsForTable ctx@ResolverContext{representations, outputType} rplan@ReadPlan{select=selectItems} = expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields}
-- If we have a '*' select AND the target table has at least one data representation, expand. -- We expand if either of the below are true:
if ("*" `elem` map (\(field, _, _) -> cfName field) selectItems) && any hasOutputRep knownColumns -- * We have a '*' select AND there is an aggregate function in this ReadPlan's sub-tree.
then rplan{select=concatMap (expandStarSelectItem knownColumns) selectItems} -- * We have a '*' select AND the target table has at least one data representation.
else rplan -- We ignore any '*' selects that have an aggregate function attached (i.e for COUNT(*)).
| hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField knownColumns) selectFields}
| otherwise = rp
where where
hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields
filteredSelectFields = filter (isNothing . csAggFunction) selectFields
hasDataRepresentation = any hasOutputRep knownColumns
knownColumns = knownColumnsInContext ctx knownColumns = knownColumnsInContext ctx
hasOutputRep :: Column -> Bool hasOutputRep :: Column -> Bool
hasOutputRep col = HM.member (colNominalType col, outputType) representations hasOutputRep col = HM.member (colNominalType col, outputType) representations
expandStarSelectItem :: [Column] -> (CoercibleField, Maybe Cast, Maybe Alias) -> [(CoercibleField, Maybe Cast, Maybe Alias)] expandStarSelectField :: [Column] -> CoercibleSelectField -> [CoercibleSelectField]
expandStarSelectItem columns (CoercibleField{cfName="*", cfJsonPath=[]}, b, c) = map (\col -> (withOutputFormat ctx $ resolveColumnField col, b, c)) columns expandStarSelectField columns sel@CoercibleSelectField{csField=CoercibleField{cfName="*", cfJsonPath=[]}, csAggFunction=Nothing} =
expandStarSelectItem _ selectItem = [selectItem] map (\col -> sel { csField = withOutputFormat ctx $ resolveColumnField col }) columns
expandStarSelectField _ selectField = [selectField]
-- | Enforces the `max-rows` config on the result -- | Enforces the `max-rows` config on the result
treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either ApiRequestError ReadPlanTree treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
@@ -383,7 +444,7 @@ addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,re
Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r} rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r}
ComputedRelationship{} -> ComputedRelationship{} ->
rPlan{from=relForeignTable r, relToParent=Just r{relTable=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias} rPlan{from=relForeignTable r, relToParent=Just r{relTableAlias=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias}
_ -> _ ->
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, fromAlias=newAlias, relJoinConds=getJoinConditions newAlias parentAlias r} rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, fromAlias=newAlias, relJoinConds=getJoinConditions newAlias parentAlias r}
) <$> rel ) <$> rel
@@ -516,6 +577,123 @@ findRel schema allRels origin target hint =
) )
) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier schema origin, schema) allRels ) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier schema origin, schema) allRels
addRelSelects :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
addRelSelects node@(Node rp forest)
| null forest = Right node
| otherwise =
let newForest = rights $ addRelSelects <$> forest
newRelSelects = mapMaybe generateRelSelectField newForest
in Right $ Node rp { relSelect = newRelSelects } newForest
generateRelSelectField :: ReadPlanTree -> Maybe RelSelectField
generateRelSelectField (Node rp@ReadPlan{relToParent=Just _, relAggAlias, relIsSpread = True} _) =
Just $ Spread { rsSpreadSel = generateSpreadSelectFields rp, rsAggAlias = relAggAlias }
generateRelSelectField (Node ReadPlan{relToParent=Just rel, select, relName, relAlias, relAggAlias, relIsSpread = False} forest) =
Just $ JsonEmbed { rsEmbedMode, rsSelName, rsAggAlias = relAggAlias, rsEmptyEmbed }
where
rsSelName = fromMaybe relName relAlias
rsEmbedMode = if relIsToOne rel then JsonObject else JsonArray
rsEmptyEmbed = null select && null forest
generateRelSelectField _ = Nothing
generateSpreadSelectFields :: ReadPlan -> [SpreadSelectField]
generateSpreadSelectFields ReadPlan{select, relSelect} =
-- We combine the select and relSelect fields into a single list of SpreadSelectField.
selectSpread ++ relSelectSpread
where
selectSpread = map selectToSpread select
selectToSpread :: CoercibleSelectField -> SpreadSelectField
selectToSpread CoercibleSelectField{csField = CoercibleField{cfName}, csAlias} =
SpreadSelectField { ssSelName = fromMaybe cfName csAlias, ssSelAggFunction = Nothing, ssSelAggCast = Nothing, ssSelAlias = Nothing }
relSelectSpread = concatMap relSelectToSpread relSelect
relSelectToSpread :: RelSelectField -> [SpreadSelectField]
relSelectToSpread (JsonEmbed{rsSelName}) =
[SpreadSelectField { ssSelName = rsSelName, ssSelAggFunction = Nothing, ssSelAggCast = Nothing, ssSelAlias = Nothing }]
relSelectToSpread (Spread{rsSpreadSel}) =
rsSpreadSel
-- When aggregates are present in a ReadPlan that will be spread, we "hoist"
-- to the highest level possible so that their semantics make sense. For instance,
-- imagine the user performs the following request:
-- `GET /projects?select=client_id,...project_invoices(invoice_total.sum())`
--
-- In this case, it is sensible that we would expect to receive the sum of the
-- `invoice_total`, grouped by the `client_id`. Without hoisting, the sum would
-- be performed in the sub-query for the joined table `project_invoices`, thus
-- making it essentially a no-op. With hoisting, we hoist the aggregate function
-- so that the aggregate function is performed in a more sensible context.
--
-- We will try to hoist the aggregate function to the highest possible level,
-- which means that we hoist until we reach the root node, or until we reach a
-- ReadPlan that will be embedded a JSON object or JSON array.
-- This type alias represents an aggregate that is to be hoisted to the next
-- level up. The first tuple of `Alias` and `FieldName` contain the alias for
-- the joined table and the original field name for the hoisted field.
--
-- The second tuple contains the aggregate function to be applied, the cast, and
-- the alias, if it was supplied by the user or otherwise determined.
type HoistedAgg = ((Alias, FieldName), (AggregateFunction, Maybe Cast, Maybe Alias))
hoistSpreadAggFunctions :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
hoistSpreadAggFunctions tree = Right $ fst $ applySpreadAggHoistingToNode tree
applySpreadAggHoistingToNode :: ReadPlanTree -> (ReadPlanTree, [HoistedAgg])
applySpreadAggHoistingToNode (Node rp@ReadPlan{relAggAlias, relToParent, relIsSpread} children) =
let (newChildren, childAggLists) = unzip $ map applySpreadAggHoistingToNode children
allChildAggLists = concat childAggLists
(newSelects, aggList) = if depth rp == 0 || (isJust relToParent && not relIsSpread)
then (select rp, [])
else hoistFromSelectFields relAggAlias (select rp)
newRelSelects = if null children
then relSelect rp
else map (hoistIntoRelSelectFields allChildAggLists) $ relSelect rp
in (Node rp { select = newSelects, relSelect = newRelSelects } newChildren, aggList)
-- Hoist aggregate functions from the select list of a ReadPlan, and return the
-- updated select list and the list of hoisted aggregates.
hoistFromSelectFields :: Alias -> [CoercibleSelectField] -> ([CoercibleSelectField], [HoistedAgg])
hoistFromSelectFields aggAlias fields =
let (newFields, maybeAggs) = foldr processField ([], []) fields
in (newFields, catMaybes maybeAggs)
where
processField field (newFields, aggList) =
let (modifiedField, maybeAgg) = modifyField field
in (modifiedField : newFields, maybeAgg : aggList)
modifyField field =
case csAggFunction field of
Just aggFunc ->
( field { csAggFunction = Nothing, csAggCast = Nothing },
Just ((aggAlias, determineFieldName field), (aggFunc, csAggCast field, csAlias field)))
Nothing -> (field, Nothing)
determineFieldName field = fromMaybe (cfName $ csField field) (csAlias field)
-- Taking the hoisted aggregates, modify the rel selects to apply the aggregates,
-- and any applicable casts or aliases.
hoistIntoRelSelectFields :: [HoistedAgg] -> RelSelectField -> RelSelectField
hoistIntoRelSelectFields aggList r@(Spread {rsSpreadSel = spreadSelects, rsAggAlias = aggAlias}) =
r { rsSpreadSel = map updateSelect spreadSelects }
where
updateSelect s =
case lookup (aggAlias, ssSelName s) aggList of
Just (aggFunc, aggCast, fldAlias) ->
s { ssSelAggFunction = Just aggFunc,
ssSelAggCast = aggCast,
ssSelAlias = fldAlias }
Nothing -> s
hoistIntoRelSelectFields _ r = r
validateAggFunctions :: Bool -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest)
| aggFunctionsAllowed = Node rp <$> traverse (validateAggFunctions aggFunctionsAllowed) forest
| any (isJust . csAggFunction) select = Left AggregatesNotAllowed
| otherwise = Node rp <$> traverse (validateAggFunctions aggFunctionsAllowed) forest
addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addFilters ctx ApiRequest{..} rReq = addFilters ctx ApiRequest{..} rReq =
foldr addFilterToNode (Right rReq) flts foldr addFilterToNode (Right rReq) flts
@@ -589,7 +767,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
-- relName = "projects", -- relName = "projects",
-- relToParent = Nothing, -- relToParent = Nothing,
-- relJoinConds = [], -- relJoinConds = [],
-- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relIsSpread = False, depth = 1 -- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relIsSpread = False, depth = 1,
-- relSelect = []
-- }, -- },
-- subForest = [] -- subForest = []
-- } -- }
@@ -614,7 +793,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
-- ) -- )
-- ], -- ],
-- order = [], range_ = fullRange, relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing, -- order = [], range_ = fullRange, relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing,
-- relJoinType = Nothing, relIsSpread = False, depth = 0 -- relJoinType = Nothing, relIsSpread = False, depth = 0,
-- relSelect = []
-- }, -- },
-- subForest = subForst -- subForest = subForst
-- } -- }
@@ -770,7 +950,7 @@ inferColsEmbedNeeds (Node ReadPlan{select} forest) pkCols
| "*" `elem` fldNames = ["*"] | "*" `elem` fldNames = ["*"]
| otherwise = returnings | otherwise = returnings
where where
fldNames = cfName . (\(f, _, _) -> f) <$> select fldNames = cfName . csField <$> select
-- Without fkCols, when a mutatePlan to -- Without fkCols, when a mutatePlan to
-- /projects?select=name,clients(name) occurs, the RETURNING SQL part would -- /projects?select=name,clients(name) occurs, the RETURNING SQL part would
-- be `RETURNING name`(see QueryBuilder). This would make the embedding -- be `RETURNING name`(see QueryBuilder). This would make the embedding
@@ -812,64 +992,31 @@ inferColsEmbedNeeds (Node ReadPlan{select} forest) pkCols
addFilterToLogicForest :: CoercibleFilter -> [CoercibleLogicTree] -> [CoercibleLogicTree] addFilterToLogicForest :: CoercibleFilter -> [CoercibleLogicTree] -> [CoercibleLogicTree]
addFilterToLogicForest flt lf = CoercibleStmnt flt : lf addFilterToLogicForest flt lf = CoercibleStmnt flt : lf
-- | If raw(binary) output is requested, check that MediaType is one of the -- | Do content negotiation. i.e. choose a media type based on the intersection of accepted/produced media types.
-- admitted rawMediaTypes and that`?select=...` contains only one field other negotiateContent :: AppConfig -> ApiRequest -> QualifiedIdentifier -> [MediaType] -> MediaHandlerMap -> Either ApiRequestError ResolvedHandler
-- than `*` negotiateContent conf ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} identifier accepts produces =
binaryField :: AppConfig -> MediaType -> Maybe Routine -> ReadPlanTree -> Either ApiRequestError (Maybe FieldName) case (act, firstAcceptedPick) of
binaryField AppConfig{configRawMediaTypes} acceptMediaType proc rpTree (_, Nothing) -> Left . MediaTypeError $ map MediaType.toMime accepts
| isRawMediaType = (ActionMutate _, Just (x, mt)) -> Right (if rep == Just Full then x else NoAgg, mt)
if (funcReturnsScalar <$> proc) == Just True || -- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849
(funcReturnsSetOfScalar <$> proc) == Just True -- TODO: despite no aggregate, these are responding with a Content-Type, which is not correct.
then Right $ Just "pgrst_scalar" (ActionRead True, Just (_, mt)) -> Right (NoAgg, mt)
else (ActionInvoke InvHead, Just (_, mt)) -> Right (NoAgg, mt)
let (_, Just (x, mt)) -> Right (x, mt)
fieldName = fstFieldName rpTree
in
case fieldName of
Just fld -> Right $ Just fld
Nothing -> Left $ BinaryFieldError acceptMediaType
| otherwise =
Right Nothing
where where
isRawMediaType = acceptMediaType `elem` configRawMediaTypes `L.union` [MTOctetStream, MTTextPlain, MTTextXML] || isRawPlan acceptMediaType firstAcceptedPick = listToMaybe $ mapMaybe matchMT accepts -- If there are multiple accepted media types, pick the first. This is usual in content negotiation.
isRawPlan mt = case mt of matchMT mt = case mt of
MTPlan MTOctetStream _ _ -> True -- all the vendored media types have special handling as they have media type parameters, they cannot be overridden
MTPlan MTTextPlain _ _ -> True m@(MTVndSingularJSON strip) -> Just (BuiltinAggSingleJson strip, m)
MTPlan MTTextXML _ _ -> True m@MTVndArrayJSONStrip -> Just (BuiltinAggArrayJsonStrip, m)
_ -> False m@(MTVndPlan (MTVndSingularJSON strip) _ _) -> mtPlanToNothing $ Just (BuiltinAggSingleJson strip, m)
m@(MTVndPlan MTVndArrayJSONStrip _ _) -> mtPlanToNothing $ Just (BuiltinAggArrayJsonStrip, m)
fstFieldName :: ReadPlanTree -> Maybe FieldName -- TODO the plan should have its own MediaHandler instead of relying on MediaType
fstFieldName (Node ReadPlan{select=(CoercibleField{cfName="*", cfJsonPath=[]}, _, _):_} []) = Nothing m@(MTVndPlan mType _ _) -> mtPlanToNothing $ (,) <$> (fst <$> lookupHandler mType) <*> pure m
fstFieldName (Node ReadPlan{select=[(CoercibleField{cfName=fld, cfJsonPath=[]}, _, _)]} []) = Just fld -- all the other media types can be overridden
fstFieldName _ = Nothing x -> lookupHandler x
mtPlanToNothing x = if configDbPlanEnabled conf then x else Nothing -- don't find anything if the plan media type is not allowed
lookupHandler mt =
mediaToAggregate :: MediaType -> Maybe FieldName -> ApiRequest -> ResultAggregate HM.lookup (RelId identifier, MTAny) produces <|> -- lookup for identifier and `*/*`
mediaToAggregate mt binField apiReq@ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} = HM.lookup (RelId identifier, mt) produces <|> -- lookup for identifier and a particular media type
if noAgg then NoAgg HM.lookup (RelAnyElement, mt) produces -- lookup for anyelement and a particular media type
else case mt of
MTApplicationJSON -> BuiltinAggJson
MTSingularJSON strip -> BuiltinAggSingleJson strip
MTArrayJSONStrip -> BuiltinAggArrayJsonStrip
MTGeoJSON -> BuiltinAggGeoJson
MTTextCSV -> BuiltinAggCsv
MTAny -> BuiltinAggJson
MTOpenAPI -> BuiltinAggJson
MTUrlEncoded -> NoAgg -- TODO: unreachable since a previous step (producedMediaTypes) whitelists the media types that can become aggregates.
-- binary types
MTTextPlain -> BuiltinAggBinary binField
MTTextXML -> BuiltinAggXml binField
MTOctetStream -> BuiltinAggBinary binField
MTOther _ -> BuiltinAggBinary binField
-- Doing `Accept: application/vnd.pgrst.plan; for="application/vnd.pgrst.plan"` doesn't make sense, so we just empty the body.
-- TODO: fail instead to be more strict
MTPlan (MTPlan{}) _ _ -> NoAgg
MTPlan media _ _ -> mediaToAggregate media binField apiReq
where
noAgg = case act of
ActionMutate _ -> rep == Just HeadersOnly || rep == Just None || isNothing rep
ActionRead _isHead -> _isHead -- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849
ActionInvoke invMethod -> invMethod == InvHead
_ -> False
+7 -5
View File
@@ -6,11 +6,12 @@ module PostgREST.Plan.ReadPlan
import Data.Tree (Tree (..)) import Data.Tree (Tree (..))
import PostgREST.ApiRequest.Types (Alias, Cast, Depth, Hint, import PostgREST.ApiRequest.Types (Alias, Depth, Hint,
JoinType, NodeName) JoinType, NodeName)
import PostgREST.Plan.Types (CoercibleField (..), import PostgREST.Plan.Types (CoercibleLogicTree,
CoercibleLogicTree, CoercibleOrderTerm,
CoercibleOrderTerm) CoercibleSelectField (..),
RelSelectField (..))
import PostgREST.RangeQuery (NonnegRange) import PostgREST.RangeQuery (NonnegRange)
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier) QualifiedIdentifier)
@@ -28,7 +29,7 @@ data JoinCondition =
deriving (Eq, Show) deriving (Eq, Show)
data ReadPlan = ReadPlan data ReadPlan = ReadPlan
{ select :: [(CoercibleField, Maybe Cast, Maybe Alias)] { select :: [CoercibleSelectField]
, from :: QualifiedIdentifier , from :: QualifiedIdentifier
, fromAlias :: Maybe Alias , fromAlias :: Maybe Alias
, where_ :: [CoercibleLogicTree] , where_ :: [CoercibleLogicTree]
@@ -42,6 +43,7 @@ data ReadPlan = ReadPlan
, relHint :: Maybe Hint , relHint :: Maybe Hint
, relJoinType :: Maybe JoinType , relJoinType :: Maybe JoinType
, relIsSpread :: Bool , relIsSpread :: Bool
, relSelect :: [RelSelectField]
, depth :: Depth , depth :: Depth
-- ^ used for aliasing -- ^ used for aliasing
} }
+40 -1
View File
@@ -1,13 +1,18 @@
module PostgREST.Plan.Types module PostgREST.Plan.Types
( CoercibleField(..) ( CoercibleField(..)
, CoercibleSelectField(..)
, unknownField , unknownField
, CoercibleLogicTree(..) , CoercibleLogicTree(..)
, CoercibleFilter(..) , CoercibleFilter(..)
, TransformerProc , TransformerProc
, CoercibleOrderTerm(..) , CoercibleOrderTerm(..)
, RelSelectField(..)
, RelJsonEmbedMode(..)
, SpreadSelectField(..)
) where ) where
import PostgREST.ApiRequest.Types (Field, JsonPath, LogicOperator, import PostgREST.ApiRequest.Types (AggregateFunction, Alias, Cast,
Field, JsonPath, LogicOperator,
OpExpr, OrderDirection, OrderNulls) OpExpr, OrderDirection, OrderNulls)
import PostgREST.SchemaCache.Identifiers (FieldName) import PostgREST.SchemaCache.Identifiers (FieldName)
@@ -65,3 +70,37 @@ data CoercibleOrderTerm
, coNullOrder :: Maybe OrderNulls , coNullOrder :: Maybe OrderNulls
} }
deriving (Eq, Show) deriving (Eq, Show)
data CoercibleSelectField = CoercibleSelectField
{ csField :: CoercibleField
, csAggFunction :: Maybe AggregateFunction
, csAggCast :: Maybe Cast
, csCast :: Maybe Cast
, csAlias :: Maybe Alias
}
deriving (Eq, Show)
data RelJsonEmbedMode = JsonObject | JsonArray
deriving (Show, Eq)
data RelSelectField
= JsonEmbed
{ rsSelName :: FieldName
, rsAggAlias :: Alias
, rsEmbedMode :: RelJsonEmbedMode
, rsEmptyEmbed :: Bool
}
| Spread
{ rsSpreadSel :: [SpreadSelectField]
, rsAggAlias :: Alias
}
deriving (Eq, Show)
data SpreadSelectField =
SpreadSelectField
{ ssSelName :: FieldName
, ssSelAggFunction :: Maybe AggregateFunction
, ssSelAggCast :: Maybe Cast
, ssSelAlias :: Maybe Alias
}
deriving (Eq, Show)
+53 -60
View File
@@ -14,35 +14,32 @@ module PostgREST.Query
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as LBS import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.Set as S import qualified Data.Set as S
import qualified Data.Text.Encoding as T
import qualified Hasql.Decoders as HD import qualified Hasql.Decoders as HD
import qualified Hasql.DynamicStatements.Snippet as SQL (Snippet) import qualified Hasql.DynamicStatements.Snippet as SQL (Snippet)
import qualified Hasql.DynamicStatements.Statement as SQL import qualified Hasql.DynamicStatements.Statement as SQL
import qualified Hasql.Transaction as SQL import qualified Hasql.Transaction as SQL
import qualified PostgREST.ApiRequest.Types as ApiRequestTypes
import qualified PostgREST.Error as Error import qualified PostgREST.Error as Error
import qualified PostgREST.Query.QueryBuilder as QueryBuilder import qualified PostgREST.Query.QueryBuilder as QueryBuilder
import qualified PostgREST.Query.Statements as Statements import qualified PostgREST.Query.Statements as Statements
import qualified PostgREST.RangeQuery as RangeQuery import qualified PostgREST.RangeQuery as RangeQuery
import qualified PostgREST.SchemaCache as SchemaCache import qualified PostgREST.SchemaCache as SchemaCache
import Data.Scientific (FPFormat (..), formatScientific, isInteger)
import PostgREST.ApiRequest (ApiRequest (..)) import PostgREST.ApiRequest (ApiRequest (..))
import PostgREST.ApiRequest.Preferences (PreferCount (..), import PostgREST.ApiRequest.Preferences (PreferCount (..),
PreferTimezone (..),
PreferTransaction (..), PreferTransaction (..),
Preferences (..), Preferences (..),
shouldCount) shouldCount)
import PostgREST.Config (AppConfig (..), import PostgREST.Config (AppConfig (..),
OpenAPIMode (..)) OpenAPIMode (..))
import PostgREST.Config.PgVersion (PgVersion (..), import PostgREST.Config.PgVersion (PgVersion (..))
pgVersion140)
import PostgREST.Error (Error) import PostgREST.Error (Error)
import PostgREST.MediaType (MediaType (..)) import PostgREST.MediaType (MediaType (..))
import PostgREST.Plan (CallReadPlan (..), import PostgREST.Plan (CallReadPlan (..),
@@ -51,8 +48,9 @@ import PostgREST.Plan (CallReadPlan (..),
import PostgREST.Plan.MutatePlan (MutatePlan (..)) import PostgREST.Plan.MutatePlan (MutatePlan (..))
import PostgREST.Query.SqlFragment (escapeIdentList, fromQi, import PostgREST.Query.SqlFragment (escapeIdentList, fromQi,
intercalateSnippet, intercalateSnippet,
setConfigLocal, setConfigWithConstantName,
setConfigLocalJson) setConfigWithConstantNameJSON,
setConfigWithDynamicName)
import PostgREST.Query.Statements (ResultSet (..)) import PostgREST.Query.Statements (ResultSet (..))
import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..), import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
@@ -65,11 +63,12 @@ import Protolude hiding (Handler)
type DbHandler = ExceptT Error SQL.Transaction type DbHandler = ExceptT Error SQL.Transaction
readQuery :: WrappedReadPlan -> AppConfig -> ApiRequest -> DbHandler ResultSet readQuery :: WrappedReadPlan -> AppConfig -> ApiRequest -> DbHandler ResultSet
readQuery WrappedReadPlan{wrReadPlan, wrResAgg} conf@AppConfig{..} apiReq@ApiRequest{iPreferences=Preferences{..}, ..} = do readQuery WrappedReadPlan{..} conf@AppConfig{..} apiReq@ApiRequest{iPreferences=Preferences{..}} = do
let countQuery = QueryBuilder.readPlanToCountQuery wrReadPlan let countQuery = QueryBuilder.readPlanToCountQuery wrReadPlan
resultSet <- resultSet <-
lift . SQL.statement mempty $ lift . SQL.statement mempty $
Statements.prepareRead Statements.prepareRead
wrIdent
(QueryBuilder.readPlanToQuery wrReadPlan) (QueryBuilder.readPlanToQuery wrReadPlan)
(if preferCount == Just EstimatedCount then (if preferCount == Just EstimatedCount then
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed -- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
@@ -78,10 +77,10 @@ readQuery WrappedReadPlan{wrReadPlan, wrResAgg} conf@AppConfig{..} apiReq@ApiReq
countQuery countQuery
) )
(shouldCount preferCount) (shouldCount preferCount)
iAcceptMediaType wrMedia
wrResAgg wrHandler
configDbPreparedStatements configDbPreparedStatements
failNotSingular iAcceptMediaType resultSet failNotSingular wrMedia resultSet
optionalRollback conf apiReq optionalRollback conf apiReq
resultSetWTotal conf apiReq resultSet countQuery resultSetWTotal conf apiReq resultSet countQuery
@@ -108,16 +107,16 @@ resultSetWTotal AppConfig{..} ApiRequest{iPreferences=Preferences{..}} rs@RSStan
configDbPreparedStatements configDbPreparedStatements
createQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet createQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
createQuery mrPlan apiReq@ApiRequest{..} conf = do createQuery mrPlan@MutateReadPlan{mrMedia} apiReq conf = do
resultSet <- writeQuery mrPlan apiReq conf resultSet <- writeQuery mrPlan apiReq conf
failNotSingular iAcceptMediaType resultSet failNotSingular mrMedia resultSet
optionalRollback conf apiReq optionalRollback conf apiReq
pure resultSet pure resultSet
updateQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet updateQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
updateQuery mrPlan apiReq@ApiRequest{..} conf = do updateQuery mrPlan@MutateReadPlan{mrMedia} apiReq@ApiRequest{..} conf = do
resultSet <- writeQuery mrPlan apiReq conf resultSet <- writeQuery mrPlan apiReq conf
failNotSingular iAcceptMediaType resultSet failNotSingular mrMedia resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq optionalRollback conf apiReq
pure resultSet pure resultSet
@@ -139,32 +138,33 @@ failPut RSPlan{} = pure ()
failPut RSStandard{rsQueryTotal=queryTotal} = failPut RSStandard{rsQueryTotal=queryTotal} =
when (queryTotal /= 1) $ do when (queryTotal /= 1) $ do
lift SQL.condemn lift SQL.condemn
throwError Error.PutMatchingPkError throwError $ Error.ApiRequestError ApiRequestTypes.PutMatchingPkError
deleteQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet deleteQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
deleteQuery mrPlan apiReq@ApiRequest{..} conf = do deleteQuery mrPlan@MutateReadPlan{mrMedia} apiReq@ApiRequest{..} conf = do
resultSet <- writeQuery mrPlan apiReq conf resultSet <- writeQuery mrPlan apiReq conf
failNotSingular iAcceptMediaType resultSet failNotSingular mrMedia resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq optionalRollback conf apiReq
pure resultSet pure resultSet
invokeQuery :: Routine -> CallReadPlan -> ApiRequest -> AppConfig -> PgVersion -> DbHandler ResultSet invokeQuery :: Routine -> CallReadPlan -> ApiRequest -> AppConfig -> PgVersion -> DbHandler ResultSet
invokeQuery rout CallReadPlan{crReadPlan, crCallPlan, crResAgg} apiReq@ApiRequest{iPreferences=Preferences{..}, ..} conf@AppConfig{..} pgVer = do invokeQuery rout CallReadPlan{..} apiReq@ApiRequest{iPreferences=Preferences{..}} conf@AppConfig{..} pgVer = do
resultSet <- resultSet <-
lift . SQL.statement mempty $ lift . SQL.statement mempty $
Statements.prepareCall Statements.prepareCall
crIdent
rout rout
(QueryBuilder.callPlanToQuery crCallPlan pgVer) (QueryBuilder.callPlanToQuery crCallPlan pgVer)
(QueryBuilder.readPlanToQuery crReadPlan) (QueryBuilder.readPlanToQuery crReadPlan)
(QueryBuilder.readPlanToCountQuery crReadPlan) (QueryBuilder.readPlanToCountQuery crReadPlan)
(shouldCount preferCount) (shouldCount preferCount)
iAcceptMediaType crMedia
crResAgg crHandler
configDbPreparedStatements configDbPreparedStatements
optionalRollback conf apiReq optionalRollback conf apiReq
failNotSingular iAcceptMediaType resultSet failNotSingular crMedia resultSet
pure resultSet pure resultSet
openApiQuery :: SchemaCache -> PgVersion -> AppConfig -> Schema -> DbHandler (Maybe (TablesMap, RoutineMap, Maybe Text)) openApiQuery :: SchemaCache -> PgVersion -> AppConfig -> Schema -> DbHandler (Maybe (TablesMap, RoutineMap, Maybe Text))
@@ -185,18 +185,21 @@ openApiQuery sCache pgVer AppConfig{..} tSchema =
pure Nothing pure Nothing
writeQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet writeQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
writeQuery MutateReadPlan{mrReadPlan, mrMutatePlan, mrResAgg} apiReq@ApiRequest{iPreferences=Preferences{..}} conf = writeQuery MutateReadPlan{..} ApiRequest{iPreferences=Preferences{..}} conf =
let let
(isInsert, pkCols) = case mrMutatePlan of {Insert{insPkCols} -> (True, insPkCols); _ -> (False, mempty);} (isPut, isInsert, pkCols) = case mrMutatePlan of {Insert{where_,insPkCols} -> ((not . null) where_, True, insPkCols); _ -> (False,False, mempty);}
in in
lift . SQL.statement mempty $ lift . SQL.statement mempty $
Statements.prepareWrite Statements.prepareWrite
mrIdent
(QueryBuilder.readPlanToQuery mrReadPlan) (QueryBuilder.readPlanToQuery mrReadPlan)
(QueryBuilder.mutatePlanToQuery mrMutatePlan) (QueryBuilder.mutatePlanToQuery mrMutatePlan)
isInsert isInsert
(iAcceptMediaType apiReq) isPut
mrResAgg mrMedia
mrHandler
preferRepresentation preferRepresentation
preferResolution
pkCols pkCols
(configDbPreparedStatements conf) (configDbPreparedStatements conf)
@@ -206,9 +209,9 @@ writeQuery MutateReadPlan{mrReadPlan, mrMutatePlan, mrResAgg} apiReq@ApiRequest{
failNotSingular :: MediaType -> ResultSet -> DbHandler () failNotSingular :: MediaType -> ResultSet -> DbHandler ()
failNotSingular _ RSPlan{} = pure () failNotSingular _ RSPlan{} = pure ()
failNotSingular mediaType RSStandard{rsQueryTotal=queryTotal} = failNotSingular mediaType RSStandard{rsQueryTotal=queryTotal} =
when (elem mediaType [MTSingularJSON True,MTSingularJSON False] && queryTotal /= 1) $ do when (elem mediaType [MTVndSingularJSON True, MTVndSingularJSON False] && queryTotal /= 1) $ do
lift SQL.condemn lift SQL.condemn
throwError $ Error.singularityError queryTotal throwError $ Error.ApiRequestError . ApiRequestTypes.SingularityError $ toInteger queryTotal
failsChangesOffLimits :: Maybe Integer -> ResultSet -> DbHandler () failsChangesOffLimits :: Maybe Integer -> ResultSet -> DbHandler ()
failsChangesOffLimits _ RSPlan{} = pure () failsChangesOffLimits _ RSPlan{} = pure ()
@@ -216,7 +219,7 @@ failsChangesOffLimits Nothing _ = pure ()
failsChangesOffLimits (Just maxChanges) RSStandard{rsQueryTotal=queryTotal} = failsChangesOffLimits (Just maxChanges) RSStandard{rsQueryTotal=queryTotal} =
when (queryTotal > fromIntegral maxChanges) $ do when (queryTotal > fromIntegral maxChanges) $ do
lift SQL.condemn lift SQL.condemn
throwError $ Error.OffLimitsChangesError queryTotal maxChanges throwError $ Error.ApiRequestError $ ApiRequestTypes.OffLimitsChangesError queryTotal maxChanges
-- | Set a transaction to roll back if requested -- | Set a transaction to roll back if requested
optionalRollback :: AppConfig -> ApiRequest -> DbHandler () optionalRollback :: AppConfig -> ApiRequest -> DbHandler ()
@@ -226,43 +229,33 @@ optionalRollback AppConfig{..} ApiRequest{iPreferences=Preferences{..}} = do
SQL.condemn SQL.condemn
where where
shouldCommit = shouldCommit =
configDbTxAllowOverride && preferTransaction == Just Commit preferTransaction == Just Commit
shouldRollback = shouldRollback =
configDbTxAllowOverride && preferTransaction == Just Rollback preferTransaction == Just Rollback
-- | Runs local (transaction scoped) GUCs for every request. -- | Set transaction scoped settings
setPgLocals :: AppConfig -> KM.KeyMap JSON.Value -> BS.ByteString -> [(ByteString, ByteString)] -> setPgLocals :: AppConfig -> KM.KeyMap JSON.Value -> BS.ByteString -> [(ByteString, ByteString)] ->
ApiRequest -> PgVersion -> DbHandler () ApiRequest -> Maybe Text -> DbHandler ()
setPgLocals AppConfig{..} claims role roleSettings req actualPgVersion = lift $ setPgLocals AppConfig{..} claims role roleSettings ApiRequest{..} tout = lift $
SQL.statement mempty $ SQL.dynamicallyParameterized SQL.statement mempty $ SQL.dynamicallyParameterized
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ roleSettingsSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql)) -- To ensure `GRANT SET ON PARAMETER <superuser_setting> TO authenticator` works, the role settings must be set before the impersonated role.
-- Otherwise the GRANT SET would have to be applied to the impersonated role. See https://github.com/PostgREST/postgrest/issues/3045
("select " <> intercalateSnippet ", " (searchPathSql : roleSettingsSql ++ roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ timezoneSql ++ timeoutSql ++ appSettingsSql))
HD.noResult configDbPreparedStatements HD.noResult configDbPreparedStatements
where where
methodSql = setConfigLocal mempty ("request.method", iMethod req) methodSql = setConfigWithConstantName ("request.method", iMethod)
pathSql = setConfigLocal mempty ("request.path", iPath req) pathSql = setConfigWithConstantName ("request.path", iPath)
headersSql = if usesLegacyGucs headersSql = setConfigWithConstantNameJSON "request.headers" iHeaders
then setConfigLocal "request.header." <$> iHeaders req cookiesSql = setConfigWithConstantNameJSON "request.cookies" iCookies
else setConfigLocalJson "request.headers" (iHeaders req) claimsSql = [setConfigWithConstantName ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
cookiesSql = if usesLegacyGucs roleSql = [setConfigWithConstantName ("role", role)]
then setConfigLocal "request.cookie." <$> iCookies req roleSettingsSql = setConfigWithDynamicName <$> roleSettings
else setConfigLocalJson "request.cookies" (iCookies req) appSettingsSql = setConfigWithDynamicName <$> (join bimap toUtf8 <$> configAppSettings)
claimsSql = if usesLegacyGucs timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) $ preferTimezone iPreferences
then setConfigLocal "request.jwt.claim." <$> [(toUtf8 $ K.toText c, toUtf8 $ unquoted v) | (c,v) <- KM.toList claims] timeoutSql = maybe mempty ((\t -> [setConfigWithConstantName ("statement_timeout", t)]) . encodeUtf8) tout
else [setConfigLocal mempty ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
roleSql = [setConfigLocal mempty ("role", role)]
roleSettingsSql = setConfigLocal mempty <$> roleSettings
appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings)
searchPathSql = searchPathSql =
let schemas = escapeIdentList (iSchema req : configDbExtraSearchPath) in let schemas = escapeIdentList (iSchema : configDbExtraSearchPath) in
setConfigLocal mempty ("search_path", schemas) setConfigWithConstantName ("search_path", schemas)
usesLegacyGucs = configDbUseLegacyGucs && actualPgVersion < pgVersion140
unquoted :: JSON.Value -> Text
unquoted (JSON.String t) = t
unquoted (JSON.Number n) =
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
unquoted (JSON.Bool b) = show b
unquoted v = T.decodeUtf8 . LBS.toStrict $ JSON.encode v
-- | Runs the pre-request function. -- | Runs the pre-request function.
runPreReq :: AppConfig -> DbHandler () runPreReq :: AppConfig -> DbHandler ()
+73 -32
View File
@@ -19,7 +19,8 @@ module PostgREST.Query.QueryBuilder
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Hasql.DynamicStatements.Snippet as SQL import qualified Hasql.DynamicStatements.Snippet as SQL
import Data.Tree (Tree (..)) import Data.Maybe (fromJust)
import Data.Tree (Tree (..))
import PostgREST.ApiRequest.Preferences (PreferResolution (..)) import PostgREST.ApiRequest.Preferences (PreferResolution (..))
import PostgREST.Config.PgVersion (PgVersion, pgVersion110, import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
@@ -27,8 +28,7 @@ import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
Relationship (..), Relationship (..))
relIsToOne)
import PostgREST.SchemaCache.Routine (RoutineParam (..)) import PostgREST.SchemaCache.Routine (RoutineParam (..))
import PostgREST.ApiRequest.Types import PostgREST.ApiRequest.Types
@@ -42,52 +42,78 @@ import PostgREST.RangeQuery (allRange)
import Protolude import Protolude
readPlanToQuery :: ReadPlanTree -> SQL.Snippet readPlanToQuery :: ReadPlanTree -> SQL.Snippet
readPlanToQuery (Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds} forest) = readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds, relSelect} forest) =
"SELECT " <> "SELECT " <>
intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ selects) <> " " <> intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ joinsSelects) <> " " <>
fromFrag <> " " <> fromFrag <> " " <>
intercalateSnippet " " joins <> " " <> intercalateSnippet " " joins <> " " <>
(if null logicForest && null relJoinConds (if null logicForest && null relJoinConds
then mempty then mempty
else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition relJoinConds)) <> " " <> else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition relJoinConds)) <> " " <>
groupF qi select relSelect <> " " <>
orderF qi order <> " " <> orderF qi order <> " " <>
limitOffsetF readRange limitOffsetF readRange
where where
fromFrag = fromF relToParent mainQi fromAlias fromFrag = fromF relToParent mainQi fromAlias
qi = getQualifiedIdentifier relToParent mainQi fromAlias qi = getQualifiedIdentifier relToParent mainQi fromAlias
defSelect = [(unknownField "*" [], Nothing, Nothing)] -- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage -- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage
(selects, joins) = foldr getSelectsJoins ([],[]) forest defSelect = [CoercibleSelectField (unknownField "*" []) Nothing Nothing Nothing Nothing]
joins = getJoins node
joinsSelects = getJoinSelects node
getSelectsJoins :: ReadPlanTree -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet]) getJoinSelects :: ReadPlanTree -> [SQL.Snippet]
getSelectsJoins (Node ReadPlan{relToParent=Nothing} _) _ = ([], []) getJoinSelects (Node ReadPlan{relSelect} _) =
getSelectsJoins rr@(Node ReadPlan{select, relName, relToParent=Just rel, relAggAlias, relAlias, relJoinType, relIsSpread} forest) (selects,joins) = mapMaybe relSelectToSnippet relSelect
where
relSelectToSnippet :: RelSelectField -> Maybe SQL.Snippet
relSelectToSnippet fld =
let aggAlias = pgFmtIdent $ rsAggAlias fld
in
case fld of
JsonEmbed{rsEmptyEmbed = True} ->
Nothing
JsonEmbed{rsSelName, rsEmbedMode = JsonObject} ->
Just $ "row_to_json(" <> aggAlias <> ".*)::jsonb AS " <> pgFmtIdent rsSelName
JsonEmbed{rsSelName, rsEmbedMode = JsonArray} ->
Just $ "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> pgFmtIdent rsSelName
Spread{rsSpreadSel, rsAggAlias} ->
Just $ intercalateSnippet ", " (pgFmtSpreadSelectItem rsAggAlias <$> rsSpreadSel)
getJoins :: ReadPlanTree -> [SQL.Snippet]
getJoins (Node _ []) = []
getJoins (Node ReadPlan{relSelect} forest) =
map (\fld ->
let alias = rsAggAlias fld
matchingNode = fromJust $ find (\(Node ReadPlan{relAggAlias} _) -> alias == relAggAlias) forest
in getJoin fld matchingNode
) relSelect
getJoin :: RelSelectField -> ReadPlanTree -> SQL.Snippet
getJoin fld node@(Node ReadPlan{relJoinType} _) =
let let
subquery = readPlanToQuery rr
aliasOrName = pgFmtIdent $ fromMaybe relName relAlias
aggAlias = pgFmtIdent relAggAlias
correlatedSubquery sub al cond = correlatedSubquery sub al cond =
(if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond (if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond
(sel, joi) = if relIsToOne rel subquery = readPlanToQuery node
then aggAlias = pgFmtIdent $ rsAggAlias fld
( if relIsSpread
then aggAlias <> ".*"
else "row_to_json(" <> aggAlias <> ".*) AS " <> aliasOrName
, correlatedSubquery subquery aggAlias "TRUE")
else
( "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> aliasOrName
, correlatedSubquery (
"SELECT json_agg(" <> aggAlias <> ") AS " <> aggAlias <>
"FROM (" <> subquery <> " ) AS " <> aggAlias
) aggAlias $ if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE")
in in
(if null select && null forest then selects else sel:selects, joi:joins) case fld of
JsonEmbed{rsEmbedMode = JsonObject} ->
correlatedSubquery subquery aggAlias "TRUE"
Spread{} ->
correlatedSubquery subquery aggAlias "TRUE"
JsonEmbed{rsEmbedMode = JsonArray} ->
let
subq = "SELECT json_agg(" <> aggAlias <> ")::jsonb AS " <> aggAlias <> " FROM (" <> subquery <> " ) AS " <> aggAlias
condition = if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE"
in correlatedSubquery subq aggAlias condition
mutatePlanToQuery :: MutatePlan -> SQL.Snippet mutatePlanToQuery :: MutatePlan -> SQL.Snippet
mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _ applyDefaults) = mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _ applyDefaults) =
"INSERT INTO " <> fromQi mainQi <> (if null iCols then " " else "(" <> cols <> ") ") <> "INSERT INTO " <> fromQi mainQi <> (if null iCols then " " else "(" <> cols <> ") ") <>
fromJsonBodyF body iCols True False applyDefaults <> fromJsonBodyF body iCols True False applyDefaults <>
-- Only used for PUT -- Only used for PUT
(if null putConditions then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "pgrst_body") <$> putConditions)) <> (if null putConditions then mempty else "WHERE " <> addConfigPgrstInserted True <> " AND " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "pgrst_body") <$> putConditions)) <>
(if null putConditions && mergeDups then "WHERE " <> addConfigPgrstInserted True else mempty) <>
maybe mempty (\(oncDo, oncCols) -> maybe mempty (\(oncDo, oncCols) ->
if null oncCols then if null oncCols then
mempty mempty
@@ -98,11 +124,12 @@ mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _
MergeDuplicates -> MergeDuplicates ->
if null iCols if null iCols
then "DO NOTHING" then "DO NOTHING"
else "DO UPDATE SET " <> intercalateSnippet ", " ((pgFmtIdent . cfName) <> const " = EXCLUDED." <> (pgFmtIdent . cfName) <$> iCols) else "DO UPDATE SET " <> intercalateSnippet ", " ((pgFmtIdent . cfName) <> const " = EXCLUDED." <> (pgFmtIdent . cfName) <$> iCols) <> (if null putConditions && not mergeDups then mempty else "WHERE " <> addConfigPgrstInserted False)
) onConflct <> " " <> ) onConflct <> " " <>
returningF mainQi returnings returningF mainQi returnings
where where
cols = intercalateSnippet ", " $ pgFmtIdent . cfName <$> iCols cols = intercalateSnippet ", " $ pgFmtIdent . cfName <$> iCols
mergeDups = case onConflct of {Just (MergeDuplicates,_) -> True; _ -> False;}
-- An update without a limit is always filtered with a WHERE -- An update without a limit is always filtered with a WHERE
mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings applyDefaults) mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings applyDefaults)
@@ -203,7 +230,7 @@ readPlanToCountQuery (Node ReadPlan{from=mainQi, fromAlias=tblAlias, where_=logi
then mempty then mempty
else " WHERE " ) <> else " WHERE " ) <>
intercalateSnippet " AND " ( intercalateSnippet " AND " (
map (pgFmtLogicTree qi) logicForest ++ map (pgFmtLogicTreeCount qi) logicForest ++
map pgFmtJoinCondition relJoinConds ++ map pgFmtJoinCondition relJoinConds ++
subQueries subQueries
) )
@@ -216,6 +243,18 @@ readPlanToCountQuery (Node ReadPlan{from=mainQi, fromAlias=tblAlias, where_=logi
if joinType == Just JTInner if joinType == Just JTInner
then ("EXISTS (" <> readPlanToCountQuery readReq <> " )"):rest then ("EXISTS (" <> readPlanToCountQuery readReq <> " )"):rest
else rest else rest
findNullEmbedRel fld = find (\(Node ReadPlan{relAggAlias} _) -> fld == relAggAlias) forest
-- https://github.com/PostgREST/postgrest/pull/2930#discussion_r1325293698
pgFmtLogicTreeCount :: QualifiedIdentifier -> CoercibleLogicTree -> SQL.Snippet
pgFmtLogicTreeCount qiCount (CoercibleExpr hasNot op frst) = SQL.sql notOp <> " (" <> intercalateSnippet (opSql op) (pgFmtLogicTreeCount qiCount <$> frst) <> ")"
where
notOp = if hasNot then "NOT" else mempty
opSql And = " AND "
opSql Or = " OR "
pgFmtLogicTreeCount _ (CoercibleStmnt (CoercibleFilterNullEmbed hasNot fld)) =
maybe mempty (\x -> (if not hasNot then "NOT " else mempty) <> "EXISTS (" <> readPlanToCountQuery x <> ")") (findNullEmbedRel fld)
pgFmtLogicTreeCount qiCount (CoercibleStmnt flt) = pgFmtFilter qiCount flt
limitedQuery :: SQL.Snippet -> Maybe Integer -> SQL.Snippet limitedQuery :: SQL.Snippet -> Maybe Integer -> SQL.Snippet
limitedQuery query maxRows = query <> SQL.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows) limitedQuery query maxRows = query <> SQL.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows)
@@ -230,8 +269,10 @@ getQualifiedIdentifier rel mainQi tblAlias = case rel of
fromF :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> SQL.Snippet fromF :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> SQL.Snippet
fromF rel mainQi tblAlias = "FROM " <> fromF rel mainQi tblAlias = "FROM " <>
(case rel of (case rel of
Just ComputedRelationship{relFunction,relTable} -> fromQi relFunction <> "(" <> pgFmtIdent (qiName relTable) <> ")" -- Due to the use of CTEs on RPC, we need to cast the parameter to the table name in case of function overloading.
_ -> fromQi mainQi) <> -- See https://github.com/PostgREST/postgrest/issues/2963#issuecomment-1736557386
Just ComputedRelationship{relFunction,relTableAlias,relTable} -> fromQi relFunction <> "(" <> pgFmtIdent (qiName relTableAlias) <> "::" <> fromQi relTable <> ")"
_ -> fromQi mainQi) <>
maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias <>
(case rel of (case rel of
Just Relationship{relCardinality=M2M Junction{junTable=jt}} -> ", " <> fromQi jt Just Relationship{relCardinality=M2M Junction{junTable=jt}} -> ", " <> fromQi jt
+109 -47
View File
@@ -7,22 +7,27 @@ Description : Helper functions for PostgREST.QueryBuilder.
-} -}
module PostgREST.Query.SqlFragment module PostgREST.Query.SqlFragment
( noLocationF ( noLocationF
, aggF , handlerF
, countF , countF
, groupF
, fromQi , fromQi
, limitOffsetF , limitOffsetF
, locationF , locationF
, mutRangeF , mutRangeF
, orderF , orderF
, pgFmtColumn , pgFmtColumn
, pgFmtFilter
, pgFmtIdent , pgFmtIdent
, pgFmtJoinCondition , pgFmtJoinCondition
, pgFmtLogicTree , pgFmtLogicTree
, pgFmtOrderTerm , pgFmtOrderTerm
, pgFmtSelectItem , pgFmtSelectItem
, pgFmtSpreadSelectItem
, fromJsonBodyF , fromJsonBodyF
, responseHeadersF , responseHeadersF
, responseStatusF , responseStatusF
, addConfigPgrstInserted
, currentSettingF
, returningF , returningF
, singleParameter , singleParameter
, sourceCTE , sourceCTE
@@ -30,8 +35,9 @@ module PostgREST.Query.SqlFragment
, unknownEncoder , unknownEncoder
, intercalateSnippet , intercalateSnippet
, explainF , explainF
, setConfigLocal , setConfigWithConstantName
, setConfigLocalJson , setConfigWithDynamicName
, setConfigWithConstantNameJSON
, escapeIdent , escapeIdent
, escapeIdentList , escapeIdentList
) where ) where
@@ -50,7 +56,8 @@ import Control.Arrow ((***))
import Data.Foldable (foldr1) import Data.Foldable (foldr1)
import Text.InterpolatedString.Perl6 (qc) import Text.InterpolatedString.Perl6 (qc)
import PostgREST.ApiRequest.Types (Alias, Cast, import PostgREST.ApiRequest.Types (AggregateFunction (..),
Alias, Cast,
FtsOperator (..), FtsOperator (..),
JsonOperand (..), JsonOperand (..),
JsonOperation (..), JsonOperation (..),
@@ -64,25 +71,28 @@ import PostgREST.ApiRequest.Types (Alias, Cast,
QuantOperator (..), QuantOperator (..),
SimpleOperator (..), SimpleOperator (..),
TrileanVal (..)) TrileanVal (..))
import PostgREST.MediaType (MTPlanFormat (..), import PostgREST.MediaType (MTVndPlanFormat (..),
MTPlanOption (..)) MTVndPlanOption (..))
import PostgREST.Plan.ReadPlan (JoinCondition (..)) import PostgREST.Plan.ReadPlan (JoinCondition (..))
import PostgREST.Plan.Types (CoercibleField (..), import PostgREST.Plan.Types (CoercibleField (..),
CoercibleFilter (..), CoercibleFilter (..),
CoercibleLogicTree (..), CoercibleLogicTree (..),
CoercibleOrderTerm (..), CoercibleOrderTerm (..),
CoercibleSelectField (..),
RelSelectField (..),
SpreadSelectField (..),
unknownField) unknownField)
import PostgREST.RangeQuery (NonnegRange, allRange, import PostgREST.RangeQuery (NonnegRange, allRange,
rangeLimit, rangeOffset) rangeLimit, rangeOffset)
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..)) QualifiedIdentifier (..))
import PostgREST.SchemaCache.Routine (ResultAggregate (..), import PostgREST.SchemaCache.Routine (MediaHandler (..),
Routine (..), Routine (..),
funcReturnsScalar, funcReturnsScalar,
funcReturnsSetOfScalar, funcReturnsSetOfScalar,
funcReturnsSingleComposite) funcReturnsSingleComposite)
import Protolude hiding (cast) import Protolude hiding (Sum, cast)
sourceCTEName :: Text sourceCTEName :: Text
sourceCTEName = "pgrst_source" sourceCTEName = "pgrst_source"
@@ -208,19 +218,13 @@ asJsonF rout strip
Just r -> (funcReturnsSingleComposite r, funcReturnsScalar r, funcReturnsSetOfScalar r) Just r -> (funcReturnsSingleComposite r, funcReturnsScalar r, funcReturnsSetOfScalar r)
Nothing -> (False, False, False) Nothing -> (False, False, False)
asXmlF :: Maybe FieldName -> SQL.Snippet
asXmlF (Just fieldName) = "coalesce(xmlagg(_postgrest_t." <> pgFmtIdent fieldName <> "), '')"
-- TODO unreachable because a previous step(binaryField) will validate that there's a field. This will be cleared once custom media types are implemented.
asXmlF Nothing = "coalesce(xmlagg(_postgrest_t), '')"
asGeoJsonF :: SQL.Snippet asGeoJsonF :: SQL.Snippet
asGeoJsonF = "json_build_object('type', 'FeatureCollection', 'features', coalesce(json_agg(ST_AsGeoJSON(_postgrest_t)::json), '[]'))" asGeoJsonF = "json_build_object('type', 'FeatureCollection', 'features', coalesce(json_agg(ST_AsGeoJSON(_postgrest_t)::json), '[]'))"
asBinaryF :: Maybe FieldName -> SQL.Snippet customFuncF :: Maybe Routine -> QualifiedIdentifier -> QualifiedIdentifier -> SQL.Snippet
asBinaryF (Just fieldName) = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')" customFuncF rout funcQi target
-- TODO unreachable because a previous step(binaryField) will validate that there's a field. This will be cleared once custom media types are implemented. | (funcReturnsScalar <$> rout) == Just True = fromQi funcQi <> "(_postgrest_t.pgrst_scalar)"
asBinaryF Nothing = "coalesce(string_agg(_postgrest_t, ''), '')" | otherwise = fromQi funcQi <> "(_postgrest_t::" <> fromQi target <> ")"
locationF :: [Text] -> SQL.Snippet locationF :: [Text] -> SQL.Snippet
locationF pKeys = [qc|( locationF pKeys = [qc|(
@@ -260,12 +264,34 @@ pgFmtCoerceNamed :: CoercibleField -> SQL.Snippet
pgFmtCoerceNamed CoercibleField{cfName=fn, cfTransform=(Just formatterProc)} = pgFmtCallUnary formatterProc (pgFmtIdent fn) <> " AS " <> pgFmtIdent fn pgFmtCoerceNamed CoercibleField{cfName=fn, cfTransform=(Just formatterProc)} = pgFmtCallUnary formatterProc (pgFmtIdent fn) <> " AS " <> pgFmtIdent fn
pgFmtCoerceNamed CoercibleField{cfName=fn} = pgFmtIdent fn pgFmtCoerceNamed CoercibleField{cfName=fn} = pgFmtIdent fn
pgFmtSelectItem :: QualifiedIdentifier -> (CoercibleField, Maybe Cast, Maybe Alias) -> SQL.Snippet pgFmtSelectItem :: QualifiedIdentifier -> CoercibleSelectField -> SQL.Snippet
pgFmtSelectItem table (fld, Nothing, alias) = pgFmtTableCoerce table fld <> pgFmtAs (cfName fld) (cfJsonPath fld) alias pgFmtSelectItem table CoercibleSelectField{csField=fld, csAggFunction=agg, csAggCast=aggCast, csCast=cast, csAlias=alias} =
pgFmtApplyAggregate agg aggCast (pgFmtApplyCast cast (pgFmtTableCoerce table fld)) <> pgFmtAs alias
pgFmtSpreadSelectItem :: Alias -> SpreadSelectField -> SQL.Snippet
pgFmtSpreadSelectItem aggAlias SpreadSelectField{ssSelName, ssSelAggFunction, ssSelAggCast, ssSelAlias} =
pgFmtApplyAggregate ssSelAggFunction ssSelAggCast fullSelName <> pgFmtAs ssSelAlias
where
fullSelName = case ssSelName of
"*" -> pgFmtIdent aggAlias <> ".*"
_ -> pgFmtIdent aggAlias <> "." <> pgFmtIdent ssSelName
pgFmtApplyAggregate :: Maybe AggregateFunction -> Maybe Cast -> SQL.Snippet -> SQL.Snippet
pgFmtApplyAggregate Nothing _ snippet = snippet
pgFmtApplyAggregate (Just agg) aggCast snippet =
pgFmtApplyCast aggCast aggregatedSnippet
where
convertAggFunction :: AggregateFunction -> SQL.Snippet
-- Convert from e.g. Sum (the data type) to SUM
convertAggFunction = SQL.sql . BS.map toUpper . BS.pack . show
aggregatedSnippet = convertAggFunction agg <> "(" <> snippet <> ")"
pgFmtApplyCast :: Maybe Cast -> SQL.Snippet -> SQL.Snippet
pgFmtApplyCast Nothing snippet = snippet
-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc. -- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc.
-- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting. -- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting.
-- Not quoting should be fine, we validate the input on Parsers. -- Not quoting should be fine, we validate the input on Parsers.
pgFmtSelectItem table (fld, Just cast, alias) = "CAST (" <> pgFmtTableCoerce table fld <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> pgFmtAs (cfName fld) (cfJsonPath fld) alias pgFmtApplyCast (Just cast) snippet = "CAST( " <> snippet <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )"
-- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body -- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body
fromJsonBodyF :: Maybe LBS.ByteString -> [CoercibleField] -> Bool -> Bool -> Bool -> SQL.Snippet fromJsonBodyF :: Maybe LBS.ByteString -> [CoercibleField] -> Bool -> Bool -> Bool -> SQL.Snippet
@@ -336,7 +362,7 @@ pgFmtArrayLiteralForField values _ = unknownLiteral (pgBuildArrayLiteral values)
pgFmtFilter :: QualifiedIdentifier -> CoercibleFilter -> SQL.Snippet pgFmtFilter :: QualifiedIdentifier -> CoercibleFilter -> SQL.Snippet
pgFmtFilter _ (CoercibleFilterNullEmbed hasNot fld) = pgFmtIdent fld <> " IS " <> (if hasNot then "NOT" else mempty) <> " NULL" pgFmtFilter _ (CoercibleFilterNullEmbed hasNot fld) = pgFmtIdent fld <> " IS " <> (if not hasNot then "NOT " else mempty) <> "DISTINCT FROM NULL"
pgFmtFilter _ (CoercibleFilter _ (NoOpExpr _)) = mempty -- TODO unreachable because NoOpExpr is filtered on QueryParams pgFmtFilter _ (CoercibleFilter _ (NoOpExpr _)) = mempty -- TODO unreachable because NoOpExpr is filtered on QueryParams
pgFmtFilter table (CoercibleFilter fld (OpExpr hasNot oper)) = notOp <> " " <> pgFmtField table fld <> case oper of pgFmtFilter table (CoercibleFilter fld (OpExpr hasNot oper)) = notOp <> " " <> pgFmtField table fld <> case oper of
Op op val -> " " <> simpleOperator op <> " " <> pgFmtUnknownLiteralForField (unknownLiteral val) fld Op op val -> " " <> simpleOperator op <> " " <> pgFmtUnknownLiteralForField (unknownLiteral val) fld
@@ -397,17 +423,40 @@ pgFmtJsonPath = \case
pgFmtJsonOperand (JKey k) = unknownLiteral k pgFmtJsonOperand (JKey k) = unknownLiteral k
pgFmtJsonOperand (JIdx i) = unknownLiteral i <> "::int" pgFmtJsonOperand (JIdx i) = unknownLiteral i <> "::int"
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SQL.Snippet pgFmtAs :: Maybe Alias -> SQL.Snippet
pgFmtAs _ [] Nothing = mempty pgFmtAs Nothing = mempty
pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of pgFmtAs (Just alias) = " AS " <> pgFmtIdent alias
Just (JKey key) -> " AS " <> pgFmtIdent key
Just (JIdx _) -> " AS " <> pgFmtIdent (fromMaybe fName lastKey) groupF :: QualifiedIdentifier -> [CoercibleSelectField] -> [RelSelectField] -> SQL.Snippet
-- We get the lastKey because on: groupF qi select relSelect
-- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ] | (noSelectsAreAggregated && noRelSelectsAreAggregated) || null groupTerms = mempty
-- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ] | otherwise = " GROUP BY " <> intercalateSnippet ", " groupTerms
where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp) where
Nothing -> mempty noSelectsAreAggregated = null $ [s | s@(CoercibleSelectField { csAggFunction = Just _ }) <- select]
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias noRelSelectsAreAggregated = all (\case Spread sels _ -> all (isNothing . ssSelAggFunction) sels; _ -> True) relSelect
groupTermsFromSelect = mapMaybe (pgFmtGroup qi) select
groupTermsFromRelSelect = mapMaybe groupTermFromRelSelectField relSelect
groupTerms = groupTermsFromSelect ++ groupTermsFromRelSelect
groupTermFromRelSelectField :: RelSelectField -> Maybe SQL.Snippet
groupTermFromRelSelectField (JsonEmbed { rsSelName }) =
Just $ pgFmtIdent rsSelName
groupTermFromRelSelectField (Spread { rsSpreadSel, rsAggAlias }) =
if null groupTerms
then Nothing
else
Just $ intercalateSnippet ", " groupTerms
where
processField :: SpreadSelectField -> Maybe SQL.Snippet
processField SpreadSelectField{ssSelAggFunction = Just _} = Nothing
processField SpreadSelectField{ssSelName, ssSelAlias} =
Just $ pgFmtIdent rsAggAlias <> "." <> pgFmtIdent (fromMaybe ssSelName ssSelAlias)
groupTerms = mapMaybe processField rsSpreadSel
pgFmtGroup :: QualifiedIdentifier -> CoercibleSelectField -> Maybe SQL.Snippet
pgFmtGroup _ CoercibleSelectField{csAggFunction=Just _} = Nothing
pgFmtGroup _ CoercibleSelectField{csAlias=Just alias, csAggFunction=Nothing} = Just $ pgFmtIdent alias
pgFmtGroup qi CoercibleSelectField{csField=fld, csAlias=Nothing, csAggFunction=Nothing} = Just $ pgFmtField qi fld
countF :: SQL.Snippet -> Bool -> (SQL.Snippet, SQL.Snippet) countF :: SQL.Snippet -> Bool -> (SQL.Snippet, SQL.Snippet)
countF countQuery shouldCount = countF countQuery shouldCount =
@@ -438,6 +487,11 @@ responseHeadersF = currentSettingF "response.headers"
responseStatusF :: SQL.Snippet responseStatusF :: SQL.Snippet
responseStatusF = currentSettingF "response.status" responseStatusF = currentSettingF "response.status"
addConfigPgrstInserted :: Bool -> SQL.Snippet
addConfigPgrstInserted add =
let (symbol, num) = if add then ("+", "0") else ("-", "-1") in
"set_config('pgrst.inserted', (coalesce(" <> currentSettingF "pgrst.inserted" <> "::int, 0) " <> symbol <> " 1)::text, true) <> '" <> num <> "'"
currentSettingF :: SQL.Snippet -> SQL.Snippet currentSettingF :: SQL.Snippet -> SQL.Snippet
currentSettingF setting = currentSettingF setting =
-- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15 -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
@@ -465,13 +519,13 @@ intercalateSnippet :: ByteString -> [SQL.Snippet] -> SQL.Snippet
intercalateSnippet _ [] = mempty intercalateSnippet _ [] = mempty
intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets
explainF :: MTPlanFormat -> [MTPlanOption] -> SQL.Snippet -> SQL.Snippet explainF :: MTVndPlanFormat -> [MTVndPlanOption] -> SQL.Snippet -> SQL.Snippet
explainF fmt opts snip = explainF fmt opts snip =
"EXPLAIN (" <> "EXPLAIN (" <>
SQL.sql (BS.intercalate ", " (fmtPlanFmt fmt : (fmtPlanOpt <$> opts))) <> SQL.sql (BS.intercalate ", " (fmtPlanFmt fmt : (fmtPlanOpt <$> opts))) <>
") " <> snip ") " <> snip
where where
fmtPlanOpt :: MTPlanOption -> BS.ByteString fmtPlanOpt :: MTVndPlanOption -> BS.ByteString
fmtPlanOpt PlanAnalyze = "ANALYZE" fmtPlanOpt PlanAnalyze = "ANALYZE"
fmtPlanOpt PlanVerbose = "VERBOSE" fmtPlanOpt PlanVerbose = "VERBOSE"
fmtPlanOpt PlanSettings = "SETTINGS" fmtPlanOpt PlanSettings = "SETTINGS"
@@ -482,27 +536,35 @@ explainF fmt opts snip =
fmtPlanFmt PlanJSON = "FORMAT JSON" fmtPlanFmt PlanJSON = "FORMAT JSON"
-- | Do a pg set_config(setting, value, true) call. This is equivalent to a SET LOCAL. -- | Do a pg set_config(setting, value, true) call. This is equivalent to a SET LOCAL.
setConfigLocal :: ByteString -> (ByteString, ByteString) -> SQL.Snippet setConfigLocal :: (SQL.Snippet, ByteString) -> SQL.Snippet
setConfigLocal prefix (k, v) = setConfigLocal (k, v) =
"set_config(" <> unknownEncoder (prefix <> k) <> ", " <> unknownEncoder v <> ", true)" "set_config(" <> k <> ", " <> unknownEncoder v <> ", true)"
-- | For when the settings are hardcoded and not parameterized
setConfigWithConstantName :: (SQL.Snippet, ByteString) -> SQL.Snippet
setConfigWithConstantName (k, v) = setConfigLocal ("'" <> k <> "'", v)
-- | For when the settings need to be parameterized
setConfigWithDynamicName :: (ByteString, ByteString) -> SQL.Snippet
setConfigWithDynamicName (k, v) =
setConfigLocal (unknownEncoder k, v)
-- | Starting from PostgreSQL v14, some characters are not allowed for config names (mostly affecting headers with "-"). -- | Starting from PostgreSQL v14, some characters are not allowed for config names (mostly affecting headers with "-").
-- | A JSON format string is used to avoid this problem. See https://github.com/PostgREST/postgrest/issues/1857 -- | A JSON format string is used to avoid this problem. See https://github.com/PostgREST/postgrest/issues/1857
setConfigLocalJson :: ByteString -> [(ByteString, ByteString)] -> [SQL.Snippet] setConfigWithConstantNameJSON :: SQL.Snippet -> [(ByteString, ByteString)] -> [SQL.Snippet]
setConfigLocalJson prefix keyVals = [setConfigLocal mempty (prefix, gucJsonVal keyVals)] setConfigWithConstantNameJSON prefix keyVals = [setConfigWithConstantName (prefix, gucJsonVal keyVals)]
where where
gucJsonVal :: [(ByteString, ByteString)] -> ByteString gucJsonVal :: [(ByteString, ByteString)] -> ByteString
gucJsonVal = LBS.toStrict . JSON.encode . HM.fromList . arrayByteStringToText gucJsonVal = LBS.toStrict . JSON.encode . HM.fromList . arrayByteStringToText
arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)] arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)]
arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal
aggF :: Maybe Routine -> ResultAggregate -> SQL.Snippet handlerF :: Maybe Routine -> QualifiedIdentifier -> MediaHandler -> SQL.Snippet
aggF rout = \case handlerF rout target = \case
BuiltinAggJson -> asJsonF rout False
BuiltinAggArrayJsonStrip -> asJsonF rout True BuiltinAggArrayJsonStrip -> asJsonF rout True
BuiltinAggSingleJson strip -> asJsonSingleF rout strip BuiltinAggSingleJson strip -> asJsonSingleF rout strip
BuiltinAggGeoJson -> asGeoJsonF BuiltinOvAggJson -> asJsonF rout False
BuiltinAggCsv -> asCsvF BuiltinOvAggGeoJson -> asGeoJsonF
BuiltinAggXml bField -> asXmlF bField BuiltinOvAggCsv -> asCsvF
BuiltinAggBinary bField -> asBinaryF bField CustomFunc funcQi -> customFuncF rout funcQi target
NoAgg -> "''::text" NoAgg -> "''::text"
+42 -30
View File
@@ -25,11 +25,12 @@ import qualified Hasql.Statement as SQL
import Control.Lens ((^?)) import Control.Lens ((^?))
import PostgREST.ApiRequest.Preferences import PostgREST.ApiRequest.Preferences
import PostgREST.MediaType (MTPlanFormat (..), import PostgREST.MediaType (MTVndPlanFormat (..),
MediaType (..)) MediaType (..))
import PostgREST.Query.SqlFragment import PostgREST.Query.SqlFragment
import PostgREST.SchemaCache.Routine (ResultAggregate (..), import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier)
Routine) import PostgREST.SchemaCache.Routine (MediaHandler (..), Routine,
funcReturnsSingle)
import Protolude import Protolude
@@ -49,24 +50,29 @@ data ResultSet
-- ^ the HTTP headers to be added to the response -- ^ the HTTP headers to be added to the response
, rsGucStatus :: Maybe Text , rsGucStatus :: Maybe Text
-- ^ the HTTP status to be added to the response -- ^ the HTTP status to be added to the response
, rsInserted :: Maybe Int64
-- ^ the number of rows inserted (Only used for upserts)
} }
| RSPlan BS.ByteString -- ^ the plan of the query | RSPlan BS.ByteString -- ^ the plan of the query
prepareWrite :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> ResultAggregate -> prepareWrite :: QualifiedIdentifier -> SQL.Snippet -> SQL.Snippet -> Bool -> Bool -> MediaType -> MediaHandler ->
Maybe PreferRepresentation -> [Text] -> Bool -> SQL.Statement () ResultSet Maybe PreferRepresentation -> Maybe PreferResolution -> [Text] -> Bool -> SQL.Statement () ResultSet
prepareWrite selectQuery mutateQuery isInsert mt rAgg rep pKeys = prepareWrite qi selectQuery mutateQuery isInsert isPut mt handler rep resolution pKeys =
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
checkUpsert snip = if isInsert && (isPut || resolution == Just MergeDuplicates) then snip else "''"
pgrstInsertedF = checkUpsert "nullif(current_setting('pgrst.inserted', true),'')::int"
snippet = snippet =
"WITH " <> sourceCTE <> " AS (" <> mutateQuery <> ") " <> "WITH " <> sourceCTE <> " AS (" <> mutateQuery <> ") " <>
"SELECT " <> "SELECT " <>
"'' AS total_result_set, " <> "'' AS total_result_set, " <>
"pg_catalog.count(_postgrest_t) AS page_total, " <> "pg_catalog.count(_postgrest_t) AS page_total, " <>
locF <> " AS header, " <> locF <> " AS header, " <>
aggF Nothing rAgg <> " AS body, " <> handlerF Nothing qi handler <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <> responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status " <> responseStatusF <> " AS response_status, " <>
pgrstInsertedF <> " AS response_inserted " <>
"FROM (" <> selectF <> ") _postgrest_t" "FROM (" <> selectF <> ") _postgrest_t"
locF = locF =
@@ -80,16 +86,16 @@ prepareWrite selectQuery mutateQuery isInsert mt rAgg rep pKeys =
selectF selectF
-- prevent using any of the column names in ?select= when no response is returned from the CTE -- prevent using any of the column names in ?select= when no response is returned from the CTE
| rAgg == NoAgg = "SELECT * FROM " <> sourceCTE | handler == NoAgg = "SELECT * FROM " <> sourceCTE
| otherwise = selectQuery | otherwise = selectQuery
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mt of decodeIt = case mt of
MTPlan{} -> planRow MTVndPlan{} -> planRow
_ -> fromMaybe (RSStandard Nothing 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow False) _ -> fromMaybe (RSStandard Nothing 0 mempty mempty Nothing Nothing Nothing) <$> HD.rowMaybe (standardRow False)
prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> ResultAggregate -> Bool -> SQL.Statement () ResultSet prepareRead :: QualifiedIdentifier -> SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> MediaHandler -> Bool -> SQL.Statement () ResultSet
prepareRead selectQuery countQuery countTotal mt rAgg = prepareRead qi selectQuery countQuery countTotal mt handler =
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
snippet = snippet =
@@ -98,22 +104,23 @@ prepareRead selectQuery countQuery countTotal mt rAgg =
"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, " <>
aggF Nothing rAgg <> " AS body, " <> handlerF Nothing qi handler <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <> responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status " <> responseStatusF <> " AS response_status, " <>
"''" <> " AS response_inserted " <>
"FROM ( SELECT * FROM " <> sourceCTE <> " ) _postgrest_t" "FROM ( SELECT * FROM " <> sourceCTE <> " ) _postgrest_t"
(countCTEF, countResultF) = countF countQuery countTotal (countCTEF, countResultF) = countF countQuery countTotal
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mt of decodeIt = case mt of
MTPlan{} -> planRow MTVndPlan{} -> planRow
_ -> HD.singleRow $ standardRow True _ -> HD.singleRow $ standardRow True
prepareCall :: Routine -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool -> prepareCall :: QualifiedIdentifier -> Routine -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool ->
MediaType -> ResultAggregate -> Bool -> MediaType -> MediaHandler -> Bool ->
SQL.Statement () ResultSet SQL.Statement () ResultSet
prepareCall rout callProcQuery selectQuery countQuery countTotal mt rAgg = prepareCall qi rout callProcQuery selectQuery countQuery countTotal mt handler =
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
snippet = snippet =
@@ -121,18 +128,21 @@ prepareCall rout callProcQuery selectQuery countQuery countTotal mt rAgg =
countCTEF <> countCTEF <>
"SELECT " <> "SELECT " <>
countResultF <> " AS total_result_set, " <> countResultF <> " AS total_result_set, " <>
"pg_catalog.count(_postgrest_t) AS page_total, " <> (if funcReturnsSingle rout
aggF (Just rout) rAgg <> " AS body, " <> then "1"
else "pg_catalog.count(_postgrest_t)") <> " AS page_total, " <>
handlerF (Just rout) qi handler <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <> responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status " <> responseStatusF <> " AS response_status, " <>
"''" <> " AS response_inserted " <>
"FROM (" <> selectQuery <> ") _postgrest_t" "FROM (" <> selectQuery <> ") _postgrest_t"
(countCTEF, countResultF) = countF countQuery countTotal (countCTEF, countResultF) = countF countQuery countTotal
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mt of decodeIt = case mt of
MTPlan{} -> planRow MTVndPlan{} -> planRow
_ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow True) _ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty Nothing Nothing Nothing) <$> HD.rowMaybe (standardRow True)
preparePlanRows :: SQL.Snippet -> Bool -> SQL.Statement () (Maybe Int64) preparePlanRows :: SQL.Snippet -> Bool -> SQL.Statement () (Maybe Int64)
preparePlanRows countQuery = preparePlanRows countQuery =
@@ -147,9 +157,11 @@ preparePlanRows countQuery =
standardRow :: Bool -> HD.Row ResultSet standardRow :: Bool -> HD.Row ResultSet
standardRow noLocation = standardRow noLocation =
RSStandard <$> nullableColumn HD.int8 <*> column HD.int8 RSStandard <$> nullableColumn HD.int8 <*> column HD.int8
<*> (if noLocation then pure mempty else fmap splitKeyValue <$> arrayColumn HD.bytea) <*> column HD.bytea <*> (if noLocation then pure mempty else fmap splitKeyValue <$> arrayColumn HD.bytea)
<*> (fromMaybe mempty <$> nullableColumn HD.bytea)
<*> nullableColumn HD.bytea <*> nullableColumn HD.bytea
<*> nullableColumn HD.text <*> nullableColumn HD.text
<*> nullableColumn HD.int8
where where
splitKeyValue :: ByteString -> (ByteString, ByteString) splitKeyValue :: ByteString -> (ByteString, ByteString)
splitKeyValue kv = splitKeyValue kv =
@@ -158,8 +170,8 @@ standardRow noLocation =
mtSnippet :: MediaType -> SQL.Snippet -> SQL.Snippet mtSnippet :: MediaType -> SQL.Snippet -> SQL.Snippet
mtSnippet mediaType snippet = case mediaType of mtSnippet mediaType snippet = case mediaType of
MTPlan _ fmt opts -> explainF fmt opts snippet MTVndPlan _ fmt opts -> explainF fmt opts snippet
_ -> snippet _ -> snippet
-- | We use rowList because when doing EXPLAIN (FORMAT TEXT), the result comes as many rows. FORMAT JSON comes as one. -- | We use rowList because when doing EXPLAIN (FORMAT TEXT), the result comes as many rows. FORMAT JSON comes as one.
planRow :: HD.Result ResultSet planRow :: HD.Result ResultSet
+140 -183
View File
@@ -15,24 +15,18 @@ module PostgREST.Response
, readResponse , readResponse
, singleUpsertResponse , singleUpsertResponse
, updateResponse , updateResponse
, addRetryHint , PgrstResponse(..)
, isServiceUnavailable
, optionalRollback
, concatPrefAppsHeaders
, addPrefToHeaders
, traceHeaderMiddleware
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.List as L import Data.Maybe (fromJust)
import Data.Text.Read (decimal) import Data.Text.Read (decimal)
import qualified Network.HTTP.Types.Header as HTTP import qualified Network.HTTP.Types.Header as HTTP
import qualified Network.HTTP.Types.Status as HTTP import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.HTTP.Types.URI as HTTP import qualified Network.HTTP.Types.URI as HTTP
import qualified Network.Wai as Wai
import qualified PostgREST.Error as Error import qualified PostgREST.Error as Error
import qualified PostgREST.MediaType as MediaType import qualified PostgREST.MediaType as MediaType
@@ -42,14 +36,16 @@ import qualified PostgREST.Response.OpenAPI as OpenAPI
import PostgREST.ApiRequest (ApiRequest (..), import PostgREST.ApiRequest (ApiRequest (..),
InvokeMethod (..)) InvokeMethod (..))
import PostgREST.ApiRequest.Preferences (PreferRepresentation (..), import PostgREST.ApiRequest.Preferences (PreferRepresentation (..),
PreferTransaction (..), PreferResolution (..),
Preferences (..), Preferences (..),
shouldCount, prefAppliedHeader,
toAppliedHeader) shouldCount)
import PostgREST.ApiRequest.QueryParams (QueryParams (..)) import PostgREST.ApiRequest.QueryParams (QueryParams (..))
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.MediaType (MediaType (..)) import PostgREST.MediaType (MediaType (..))
import PostgREST.Plan (MutateReadPlan (..)) import PostgREST.Plan (CallReadPlan (..),
MutateReadPlan (..),
WrappedReadPlan (..))
import PostgREST.Plan.MutatePlan (MutatePlan (..)) import PostgREST.Plan.MutatePlan (MutatePlan (..))
import PostgREST.Query.Statements (ResultSet (..)) import PostgREST.Query.Statements (ResultSet (..))
import PostgREST.Response.GucHeader (GucHeader, unwrapGucHeader) import PostgREST.Response.GucHeader (GucHeader, unwrapGucHeader)
@@ -66,38 +62,50 @@ import qualified PostgREST.SchemaCache.Routine as Routine
import Protolude hiding (Handler, toS) import Protolude hiding (Handler, toS)
import Protolude.Conv (toS) import Protolude.Conv (toS)
data PgrstResponse = PgrstResponse {
pgrstStatus :: HTTP.Status
, pgrstHeaders :: [HTTP.Header]
, pgrstBody :: LBS.ByteString
}
readResponse :: Bool -> QualifiedIdentifier -> ApiRequest -> ResultSet -> Wai.Response readResponse :: WrappedReadPlan -> Bool -> QualifiedIdentifier -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
readResponse headersOnly identifier ctxApiRequest@ApiRequest{..} resultSet = case resultSet of readResponse WrappedReadPlan{wrMedia} headersOnly identifier ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} resultSet =
RSStandard{..} -> do case resultSet of
let RSStandard{..} -> do
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal let
response = gucResponse rsGucStatus rsGucHeaders (status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
headers = prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
[ contentRange headers =
, ( "Content-Location" [ contentRange
, "/" , ( "Content-Location"
<> toUtf8 (qiName identifier) , "/"
<> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams <> toUtf8 (qiName identifier)
) <> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams
] )
++ contentTypeHeaders ctxApiRequest ]
rsOrErrBody = if status == HTTP.status416 ++ contentTypeHeaders wrMedia ctxApiRequest
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange ++ prefHeader
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
else LBS.fromStrict rsBody
response status headers $ if headersOnly then mempty else rsOrErrBody (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers
RSPlan plan -> let bod | status == HTTP.status416 = Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange $
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
| headersOnly = mempty
| otherwise = LBS.fromStrict rsBody
createResponse :: QualifiedIdentifier -> MutateReadPlan -> ApiRequest -> ResultSet -> Wai.Response Right $ PgrstResponse ovStatus ovHeaders bod
createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}, ..} resultSet = case resultSet of
RSPlan plan ->
Right $ PgrstResponse HTTP.status200 (contentTypeHeaders wrMedia ctxApiRequest) $ LBS.fromStrict plan
createResponse :: QualifiedIdentifier -> MutateReadPlan -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan, mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}, ..} resultSet = case resultSet of
RSStandard{..} -> do RSStandard{..} -> do
let let
pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;} pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;}
response = gucResponse rsGucStatus rsGucHeaders prefHeader = prefAppliedHeader $
Preferences (if null pkCols && isNothing (qsOnConflict iQueryParams) then Nothing else preferResolution)
preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone []
headers = headers =
catMaybes catMaybes
[ if null rsLocation then [ if null rsLocation then
@@ -111,81 +119,98 @@ createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan} ctxApiReques
) )
, Just . RangeQuery.contentRangeH 1 0 $ , Just . RangeQuery.contentRangeH 1 0 $
if shouldCount preferCount then Just rsQueryTotal else Nothing if shouldCount preferCount then Just rsQueryTotal else Nothing
, if null pkCols && isNothing (qsOnConflict iQueryParams) then , prefHeader ]
Nothing
else
toAppliedHeader <$> preferResolution
, toAppliedHeader <$> preferMissing
]
case preferRepresentation of let isInsertIfGTZero i =
Just Full -> response HTTP.status201 (addPrefToHeaders headers Full ++ contentTypeHeaders ctxApiRequest) (LBS.fromStrict rsBody) if i <= 0 && preferResolution == Just MergeDuplicates then
Just None -> response HTTP.status201 (addPrefToHeaders headers None) mempty HTTP.status200
Just HeadersOnly -> response HTTP.status201 (addPrefToHeaders headers HeadersOnly) mempty else
Nothing -> response HTTP.status201 headers mempty HTTP.status201
status = maybe HTTP.status200 isInsertIfGTZero rsInserted
(headers', bod) = case preferRepresentation of
Just Full -> (headers ++ contentTypeHeaders mrMedia ctxApiRequest, LBS.fromStrict rsBody)
Just None -> (headers, mempty)
Just HeadersOnly -> (headers, mempty)
Nothing -> (headers, mempty)
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers'
Right $ PgrstResponse ovStatus ovHeaders bod
RSPlan plan -> RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan
updateResponse :: MutateReadPlan -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
updateResponse :: ApiRequest -> ResultSet -> Wai.Response updateResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
updateResponse ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
RSStandard{..} -> do RSStandard{..} -> do
let let
response = gucResponse rsGucStatus rsGucHeaders
contentRangeHeader = contentRangeHeader =
Just . RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $ Just . RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
if shouldCount preferCount then Just rsQueryTotal else Nothing if shouldCount preferCount then Just rsQueryTotal else Nothing
headers = catMaybes [contentRangeHeader, toAppliedHeader <$> preferMissing] prefHeader = prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone []
headers = catMaybes [contentRangeHeader, prefHeader]
case preferRepresentation of let (status, headers', body) =
Just Full -> response HTTP.status200 (addPrefToHeaders headers Full ++ contentTypeHeaders ctxApiRequest) case preferRepresentation of
(LBS.fromStrict rsBody) Just Full -> (HTTP.status200, headers ++ contentTypeHeaders mrMedia ctxApiRequest, LBS.fromStrict rsBody)
Just None -> response HTTP.status204 (addPrefToHeaders headers None) mempty Just None -> (HTTP.status204, headers, mempty)
_ -> response HTTP.status204 headers mempty _ -> (HTTP.status204, headers, mempty)
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers'
Right $ PgrstResponse ovStatus ovHeaders body
RSPlan plan -> RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan
singleUpsertResponse :: ApiRequest -> ResultSet -> Wai.Response singleUpsertResponse :: MutateReadPlan -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
singleUpsertResponse ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of singleUpsertResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
RSStandard {..} -> do RSStandard {..} -> do
let let
response = gucResponse rsGucStatus rsGucHeaders prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
cTHeader = contentTypeHeaders mrMedia ctxApiRequest
case preferRepresentation of let isInsertIfGTZero i = if i > 0 then HTTP.status201 else HTTP.status200
Just Full -> response HTTP.status200 (contentTypeHeaders ctxApiRequest ++ [toAppliedHeader Full]) (LBS.fromStrict rsBody) upsertStatus = isInsertIfGTZero $ fromJust rsInserted
Just None -> response HTTP.status204 [toAppliedHeader None] mempty (status, headers, body) =
_ -> response HTTP.status204 [] mempty case preferRepresentation of
Just Full -> (upsertStatus, cTHeader ++ prefHeader, LBS.fromStrict rsBody)
Just None -> (HTTP.status204, prefHeader, mempty)
_ -> (HTTP.status204, prefHeader, mempty)
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers
Right $ PgrstResponse ovStatus ovHeaders body
RSPlan plan -> RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan
deleteResponse :: ApiRequest -> ResultSet -> Wai.Response deleteResponse :: MutateReadPlan -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
deleteResponse ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of deleteResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
RSStandard {..} -> do RSStandard {..} -> do
let let
response = gucResponse rsGucStatus rsGucHeaders
contentRangeHeader = contentRangeHeader =
RangeQuery.contentRangeH 1 0 $ RangeQuery.contentRangeH 1 0 $
if shouldCount preferCount then Just rsQueryTotal else Nothing if shouldCount preferCount then Just rsQueryTotal else Nothing
headers = [contentRangeHeader] prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
headers = contentRangeHeader : prefHeader
case preferRepresentation of let (status, headers', body) =
Just Full -> response HTTP.status200 (addPrefToHeaders headers Full ++ contentTypeHeaders ctxApiRequest) case preferRepresentation of
(LBS.fromStrict rsBody) Just Full -> (HTTP.status200, headers ++ contentTypeHeaders mrMedia ctxApiRequest, LBS.fromStrict rsBody)
Just None -> response HTTP.status204 (addPrefToHeaders headers None) mempty Just None -> (HTTP.status204, headers, mempty)
_ -> response HTTP.status204 headers mempty _ -> (HTTP.status204, headers, mempty)
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers'
Right $ PgrstResponse ovStatus ovHeaders body
RSPlan plan -> RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan
infoIdentResponse :: QualifiedIdentifier -> SchemaCache -> Wai.Response infoIdentResponse :: QualifiedIdentifier -> SchemaCache -> Either Error.Error PgrstResponse
infoIdentResponse identifier sCache = infoIdentResponse identifier sCache = do
case HM.lookup identifier (dbTables sCache) of case HM.lookup identifier (dbTables sCache) of
Just tbl -> respondInfo $ allowH tbl Just tbl -> respondInfo $ allowH tbl
Nothing -> Error.errorResponseFor $ Error.ApiRequestError ApiRequestTypes.NotFound Nothing -> Left $ Error.ApiRequestError ApiRequestTypes.NotFound
where where
allowH table = allowH table =
let hasPK = not . null $ tablePKCols table in let hasPK = not . null $ tablePKCols table in
@@ -196,72 +221,70 @@ infoIdentResponse identifier sCache =
["PATCH" | tableUpdatable table] ++ ["PATCH" | tableUpdatable table] ++
["DELETE" | tableDeletable table] ["DELETE" | tableDeletable table]
infoProcResponse :: Routine -> Wai.Response infoProcResponse :: Routine -> Either Error.Error PgrstResponse
infoProcResponse proc | pdVolatility proc == Volatile = respondInfo "OPTIONS,POST" infoProcResponse proc | pdVolatility proc == Volatile = respondInfo "OPTIONS,POST"
| otherwise = respondInfo "OPTIONS,GET,HEAD,POST" | otherwise = respondInfo "OPTIONS,GET,HEAD,POST"
infoRootResponse :: Wai.Response infoRootResponse :: Either Error.Error PgrstResponse
infoRootResponse = respondInfo "OPTIONS,GET,HEAD" infoRootResponse = respondInfo "OPTIONS,GET,HEAD"
respondInfo :: ByteString -> Wai.Response respondInfo :: ByteString -> Either Error.Error PgrstResponse
respondInfo allowHeader = respondInfo allowHeader =
let allOrigins = ("Access-Control-Allow-Origin", "*") in let allOrigins = ("Access-Control-Allow-Origin", "*") in
Wai.responseLBS HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty Right $ PgrstResponse HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty
invokeResponse :: InvokeMethod -> Routine -> ApiRequest -> ResultSet -> Wai.Response invokeResponse :: CallReadPlan -> InvokeMethod -> Routine -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
invokeResponse invMethod proc ctxApiRequest@ApiRequest{..} resultSet = case resultSet of invokeResponse CallReadPlan{crMedia} invMethod proc ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} resultSet = case resultSet of
RSStandard {..} -> do RSStandard {..} -> do
let let
response = gucResponse rsGucStatus rsGucHeaders
(status, contentRange) = (status, contentRange) =
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
rsOrErrBody = if status == HTTP.status416 rsOrErrBody = if status == HTTP.status416
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal) $ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
else LBS.fromStrict rsBody else LBS.fromStrict rsBody
headers = [contentRange] prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferParameters preferCount preferTransaction Nothing preferHandling preferTimezone []
headers = contentRange : prefHeader
if Routine.funcReturnsVoid proc then let (status', headers', body) =
response HTTP.status204 headers mempty if Routine.funcReturnsVoid proc then
else (HTTP.status204, headers, mempty)
response status else
(headers ++ contentTypeHeaders ctxApiRequest) (status,
(if invMethod == InvHead then mempty else rsOrErrBody) headers ++ contentTypeHeaders crMedia ctxApiRequest,
if invMethod == InvHead then mempty else rsOrErrBody)
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status' headers'
Right $ PgrstResponse ovStatus ovHeaders body
RSPlan plan -> RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan Right $ PgrstResponse HTTP.status200 (contentTypeHeaders crMedia ctxApiRequest) $ LBS.fromStrict plan
openApiResponse :: (Text, Text) -> Bool -> Maybe (TablesMap, RoutineMap, Maybe Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Wai.Response openApiResponse :: (Text, Text) -> Bool -> Maybe (TablesMap, RoutineMap, Maybe Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Either Error.Error PgrstResponse
openApiResponse versions headersOnly body conf sCache schema negotiatedByProfile = openApiResponse versions headersOnly body conf sCache schema negotiatedByProfile =
Wai.responseLBS HTTP.status200 Right $ PgrstResponse HTTP.status200
(MediaType.toContentType MTOpenAPI : maybeToList (profileHeader schema negotiatedByProfile)) (MediaType.toContentType MTOpenAPI : maybeToList (profileHeader schema negotiatedByProfile))
(maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache x y z) body) (maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache x y z) body)
-- | Response with headers and status overridden from GUCs. -- Status and headers can be overridden as per https://postgrest.org/en/stable/references/transactions.html#response-headers
gucResponse overrideStatusHeaders :: Maybe Text -> Maybe BS.ByteString -> HTTP.Status -> [HTTP.Header]-> Either Error.Error (HTTP.Status, [HTTP.Header])
:: Maybe Text overrideStatusHeaders rsGucStatus rsGucHeaders pgrstStatus pgrstHeaders = do
-> Maybe BS.ByteString gucStatus <- decodeGucStatus rsGucStatus
-> HTTP.Status gucHeaders <- decodeGucHeaders rsGucHeaders
-> [HTTP.Header] Right (fromMaybe pgrstStatus gucStatus, addHeadersIfNotIncluded pgrstHeaders $ map unwrapGucHeader gucHeaders)
-> LBS.ByteString
-> Wai.Response
gucResponse rsGucStatus rsGucHeaders status headers body =
case (,) <$> decodeGucStatus rsGucStatus <*> decodeGucHeaders rsGucHeaders of
Left err -> Error.errorResponseFor err
Right (gucStatus, gucHeaders) ->
Wai.responseLBS (fromMaybe status gucStatus) (addHeadersIfNotIncluded headers (map unwrapGucHeader gucHeaders)) body
decodeGucHeaders :: Maybe BS.ByteString -> Either Error.Error [GucHeader] decodeGucHeaders :: Maybe BS.ByteString -> Either Error.Error [GucHeader]
decodeGucHeaders = decodeGucHeaders =
maybe (Right []) $ first (const Error.GucHeadersError) . JSON.eitherDecode . LBS.fromStrict maybe (Right []) $ first (const . Error.ApiRequestError $ ApiRequestTypes.GucHeadersError) . JSON.eitherDecode . LBS.fromStrict
decodeGucStatus :: Maybe Text -> Either Error.Error (Maybe HTTP.Status) decodeGucStatus :: Maybe Text -> Either Error.Error (Maybe HTTP.Status)
decodeGucStatus = decodeGucStatus =
maybe (Right Nothing) $ first (const Error.GucStatusError) . fmap (Just . toEnum . fst) . decimal maybe (Right Nothing) $ first (const . Error.ApiRequestError $ ApiRequestTypes.GucStatusError) . fmap (Just . toEnum . fst) . decimal
contentTypeHeaders :: ApiRequest -> [HTTP.Header] contentTypeHeaders :: MediaType -> ApiRequest -> [HTTP.Header]
contentTypeHeaders ApiRequest{..} = contentTypeHeaders mediaType ApiRequest{..} =
MediaType.toContentType iAcceptMediaType : maybeToList (profileHeader iSchema iNegotiatedByProfile) MediaType.toContentType mediaType : maybeToList (profileHeader iSchema iNegotiatedByProfile)
profileHeader :: Schema -> Bool -> Maybe HTTP.Header profileHeader :: Schema -> Bool -> Maybe HTTP.Header
profileHeader schema negotiatedByProfile = profileHeader schema negotiatedByProfile =
@@ -270,74 +293,8 @@ profileHeader schema negotiatedByProfile =
else else
Nothing Nothing
addRetryHint :: Int -> Wai.Response -> Wai.Response
addRetryHint delay response = do
let h = ("Retry-After", BS.pack $ show delay)
Wai.mapResponseHeaders (\hs -> if isServiceUnavailable response then h:hs else hs) response
isServiceUnavailable :: Wai.Response -> Bool
isServiceUnavailable response = Wai.responseStatus response == HTTP.status503
optionalRollback :: AppConfig -> ApiRequest -> ExceptT Error.Error IO Wai.Response -> ExceptT Error.Error IO Wai.Response
optionalRollback AppConfig{..} ApiRequest{iPreferences=Preferences{..}} resp = do
newRes <- catchError resp $ return . Error.errorResponseFor
return $ Wai.mapResponseHeaders preferenceApplied newRes
where
shouldCommit =
configDbTxAllowOverride && preferTransaction == Just Commit
shouldRollback =
configDbTxAllowOverride && preferTransaction == Just Rollback
preferenceApplied
| shouldCommit =
addHeadersIfNotIncluded
[toAppliedHeader Commit]
| shouldRollback =
addHeadersIfNotIncluded
[toAppliedHeader Rollback]
| otherwise =
identity
-- | Add headers not already included to allow the user to override them instead of duplicating them -- | Add headers not already included to allow the user to override them instead of duplicating them
addHeadersIfNotIncluded :: [HTTP.Header] -> [HTTP.Header] -> [HTTP.Header] addHeadersIfNotIncluded :: [HTTP.Header] -> [HTTP.Header] -> [HTTP.Header]
addHeadersIfNotIncluded newHeaders initialHeaders = addHeadersIfNotIncluded newHeaders initialHeaders =
filter (\(nk, nv) -> isNothing $ find (\(ik, iv) -> ik == nk && nv == iv) initialHeaders) newHeaders ++ filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
initialHeaders initialHeaders
-- | Filters out multiple Preference-Applied Headers from the list and concatenate them into a single Preference-Applied header:
--
-- >>> :{
-- concatPrefAppsHeaders
-- [("Content-Type","application/json")
-- , ("Preference-Applied","tx=commit")
-- , ("Preference-Applied","return=minimal")]
-- :}
-- [("Content-Type","application/json"),("Preference-Applied","tx=commit, return=minimal")]
concatPrefAppsHeaders :: [HTTP.Header] -> [HTTP.Header]
concatPrefAppsHeaders headers = otherHeaders ++ [(HTTP.hPreferenceApplied, combinedPrefApps)]
where
(prefApps, otherHeaders) = L.partition (\(k, _) -> k == HTTP.hPreferenceApplied) headers
prefAppsValues = [ v | (_,v) <- prefApps]
combinedPrefApps = BS.intercalate ", " prefAppsValues
-- | Given response headers and a preferRepresentation value, add
-- preferRepresentation to Preference-Applied
--
-- >>> :{
-- addPrefToHeaders
-- [("Content-Type", "application/json")
-- , ("Preference-Applied", "tx=commit")]
-- None
-- :}
-- [("Content-Type","application/json"),("Preference-Applied","tx=commit, return=minimal")]
addPrefToHeaders :: [HTTP.Header] -> PreferRepresentation -> [HTTP.Header]
addPrefToHeaders headers pref = concatPrefAppsHeaders (headers ++ [toAppliedHeader pref])
traceHeaderMiddleware :: AppConfig -> Wai.Middleware
traceHeaderMiddleware AppConfig{configServerTraceHeader} app req respond =
case configServerTraceHeader of
Nothing -> app req respond
Just hdr ->
let hdrVal = L.lookup hdr $ Wai.requestHeaders req in
app req (respond . Wai.mapResponseHeaders ([(hdr, fromMaybe mempty hdrVal)] ++))
+3 -3
View File
@@ -350,7 +350,7 @@ makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe)
& summary .~ pSum & summary .~ pSum
& description .~ mfilter (/="") pDesc & description .~ mfilter (/="") pDesc
& tags .~ Set.fromList ["(rpc) " <> pdName pd] & tags .~ Set.fromList ["(rpc) " <> pdName pd]
& produces ?~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False] & produces ?~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False]
& at 200 ?~ "OK" & at 200 ?~ "OK"
getOp = procOp getOp = procOp
& parameters .~ makeProcGetParams (pdParams pd) & parameters .~ makeProcGetParams (pdParams pd)
@@ -406,8 +406,8 @@ postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecu
& definitions .~ fromList (makeTableDef rels <$> ti) & definitions .~ fromList (makeTableDef rels <$> ti)
& parameters .~ fromList (makeParamDefs ti) & parameters .~ fromList (makeParamDefs ti)
& paths .~ makePathItems pds ti & paths .~ makePathItems pds ti
& produces .~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False, MTTextCSV] & produces .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& consumes .~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False, MTTextCSV] & consumes .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef & securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef
& security .~ [SecurityRequirement (fromList [(securityDefName, [])]) | allowSecurityDef] & security .~ [SecurityRequirement (fromList [(securityDefName, [])]) | allowSecurityDef]
where where
+36
View File
@@ -0,0 +1,36 @@
module PostgREST.Response.Performance
( ServerTiming (..)
, serverTimingHeader
)
where
import qualified Data.ByteString.Char8 as BS
import qualified Network.HTTP.Types as HTTP
import Numeric (showFFloat)
import Protolude
data ServerTiming =
ServerTiming
{ jwt :: Maybe Double
, parse :: Maybe Double
, plan :: Maybe Double
, transaction :: Maybe Double
, response :: Maybe Double
}
deriving (Show)
-- | Render the Server-Timing header from a ServerTimingData
--
-- >>> serverTimingHeader ServerTiming { plan=Just 0.1, transaction=Just 0.2, response=Just 0.3, jwt=Just 0.4, parse=Just 0.5}
-- ("Server-Timing","jwt;dur=400000.0, parse;dur=500000.0, plan;dur=100000.0, transaction;dur=200000.0, response;dur=300000.0")
serverTimingHeader :: ServerTiming -> HTTP.Header
serverTimingHeader timing =
("Server-Timing", renderTiming)
where
renderMetric metric = maybe "" (\dur -> BS.concat [metric, BS.pack $ ";dur=" <> showFFloat (Just 1) (dur * 1000000) ""])
renderTiming = BS.intercalate ", " $ (\(k, v) -> renderMetric k (v timing)) <$>
[ ("jwt", jwt)
, ("parse", parse)
, ("plan", plan)
, ("transaction", transaction)
, ("response", response)
]
+125 -5
View File
@@ -28,7 +28,9 @@ module PostgREST.SchemaCache
import Control.Monad.Extra (whenJust) import Control.Monad.Extra (whenJust)
import Data.Aeson ((.=))
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.Aeson.Types as JSON
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.HashMap.Strict.InsOrd as HMI import qualified Data.HashMap.Strict.InsOrd as HMI
import qualified Data.Set as S import qualified Data.Set as S
@@ -41,14 +43,16 @@ import Contravariant.Extras (contrazip2)
import Text.InterpolatedString.Perl6 (q) import Text.InterpolatedString.Perl6 (q)
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.Config.Database (pgVersionStatement, import PostgREST.Config.Database (TimezoneNames,
pgVersionStatement,
toIsolationLevel) toIsolationLevel)
import PostgREST.Config.PgVersion (PgVersion, pgVersion100, import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
pgVersion110, pgVersion110,
pgVersion120) pgVersion120)
import PostgREST.SchemaCache.Identifiers (AccessSet, FieldName, import PostgREST.SchemaCache.Identifiers (AccessSet, FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
Schema) RelIdentifier (..),
Schema, isAnyElement)
import PostgREST.SchemaCache.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
Relationship (..), Relationship (..),
@@ -56,6 +60,8 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
import PostgREST.SchemaCache.Representations (DataRepresentation (..), import PostgREST.SchemaCache.Representations (DataRepresentation (..),
RepresentationsMap) RepresentationsMap)
import PostgREST.SchemaCache.Routine (FuncVolatility (..), import PostgREST.SchemaCache.Routine (FuncVolatility (..),
MediaHandler (..),
MediaHandlerMap,
PgType (..), PgType (..),
RetType (..), RetType (..),
Routine (..), Routine (..),
@@ -64,6 +70,8 @@ import PostgREST.SchemaCache.Routine (FuncVolatility (..),
import PostgREST.SchemaCache.Table (Column (..), ColumnMap, import PostgREST.SchemaCache.Table (Column (..), ColumnMap,
Table (..), TablesMap) Table (..), TablesMap)
import qualified PostgREST.MediaType as MediaType
import Protolude import Protolude
@@ -72,8 +80,19 @@ data SchemaCache = SchemaCache
, dbRelationships :: RelationshipsMap , dbRelationships :: RelationshipsMap
, dbRoutines :: RoutineMap , dbRoutines :: RoutineMap
, dbRepresentations :: RepresentationsMap , dbRepresentations :: RepresentationsMap
, dbMediaHandlers :: MediaHandlerMap
, dbTimezones :: TimezoneNames
} }
deriving (Generic, JSON.ToJSON)
instance JSON.ToJSON SchemaCache where
toJSON (SchemaCache tabs rels routs reps _ _) = JSON.object [
"dbTables" .= JSON.toJSON tabs
, "dbRelationships" .= JSON.toJSON rels
, "dbRoutines" .= JSON.toJSON routs
, "dbRepresentations" .= JSON.toJSON reps
, "dbMediaHandlers" .= JSON.emptyArray
, "dbTimezones" .= JSON.emptyArray
]
-- | A view foreign key or primary key dependency detected on its source table -- | A view foreign key or primary key dependency detected on its source table
-- Each column of the key could be referenced multiple times in the view, e.g. -- Each column of the key could be referenced multiple times in the view, e.g.
@@ -113,6 +132,7 @@ data KeyDep
-- | A SQL query that can be executed independently -- | A SQL query that can be executed independently
type SqlQuery = ByteString type SqlQuery = ByteString
querySchemaCache :: AppConfig -> SQL.Transaction SchemaCache querySchemaCache :: AppConfig -> SQL.Transaction SchemaCache
querySchemaCache AppConfig{..} = do querySchemaCache AppConfig{..} = do
SQL.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object SQL.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
@@ -123,6 +143,8 @@ querySchemaCache AppConfig{..} = do
funcs <- SQL.statement schemas $ allFunctions pgVer prepared funcs <- SQL.statement schemas $ allFunctions pgVer prepared
cRels <- SQL.statement mempty $ allComputedRels prepared cRels <- SQL.statement mempty $ allComputedRels prepared
reps <- SQL.statement schemas $ dataRepresentations prepared reps <- SQL.statement schemas $ dataRepresentations prepared
mHdlers <- SQL.statement schemas $ mediaHandlers pgVer prepared
tzones <- SQL.statement mempty $ timezones prepared
_ <- _ <-
let sleepCall = SQL.Statement "select pg_sleep($1)" (param HE.int4) HD.noResult prepared in let sleepCall = SQL.Statement "select pg_sleep($1)" (param HE.int4) HD.noResult prepared in
whenJust configInternalSCSleep (`SQL.statement` sleepCall) -- only used for testing whenJust configInternalSCSleep (`SQL.statement` sleepCall) -- only used for testing
@@ -135,6 +157,8 @@ querySchemaCache AppConfig{..} = do
, dbRelationships = getOverrideRelationshipsMap rels cRels , dbRelationships = getOverrideRelationshipsMap rels cRels
, dbRoutines = funcs , dbRoutines = funcs
, dbRepresentations = reps , dbRepresentations = reps
, dbMediaHandlers = HM.union mHdlers initialMediaHandlers -- the custom handlers will override the initial ones
, dbTimezones = tzones
} }
where where
schemas = toList configDbSchemas schemas = toList configDbSchemas
@@ -169,6 +193,8 @@ removeInternal schemas dbStruct =
HM.filterWithKey (\(QualifiedIdentifier sch _, _) _ -> sch `elem` schemas ) (dbRelationships dbStruct) HM.filterWithKey (\(QualifiedIdentifier sch _, _) _ -> sch `elem` schemas ) (dbRelationships dbStruct)
, dbRoutines = dbRoutines dbStruct -- procs are only obtained from the exposed schemas, no need to filter them. , dbRoutines = dbRoutines dbStruct -- procs are only obtained from the exposed schemas, no need to filter them.
, dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API , dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API
, dbMediaHandlers = dbMediaHandlers dbStruct
, dbTimezones = dbTimezones dbStruct
} }
where where
hasInternalJunction ComputedRelationship{} = False hasInternalJunction ComputedRelationship{} = False
@@ -271,6 +297,7 @@ decodeFuncs =
<*> (parseVolatility <$> column HD.char) <*> (parseVolatility <$> column HD.char)
<*> column HD.bool <*> column HD.bool
<*> nullableColumn (toIsolationLevel <$> HD.text) <*> nullableColumn (toIsolationLevel <$> HD.text)
<*> nullableColumn HD.text
addKey :: Routine -> (QualifiedIdentifier, Routine) addKey :: Routine -> (QualifiedIdentifier, Routine)
addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd) addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd)
@@ -404,7 +431,8 @@ funcsSqlQuery pgVer = [q|
bt.oid <> bt.base as rettype_is_composite_alias, bt.oid <> bt.base as rettype_is_composite_alias,
p.provolatile, p.provolatile,
p.provariadic > 0 as hasvariadic, p.provariadic > 0 as hasvariadic,
lower((regexp_split_to_array((regexp_split_to_array(config, '='))[2], ','))[1]) AS transaction_isolation_level lower((regexp_split_to_array((regexp_split_to_array(iso_config, '='))[2], ','))[1]) AS transaction_isolation_level,
lower((regexp_split_to_array((regexp_split_to_array(timeout_config, '='))[2], ','))[1]) AS statement_timeout
FROM pg_proc p FROM pg_proc p
LEFT JOIN arguments a ON a.oid = p.oid LEFT JOIN arguments a ON a.oid = p.oid
JOIN pg_namespace pn ON pn.oid = p.pronamespace JOIN pg_namespace pn ON pn.oid = p.pronamespace
@@ -413,7 +441,8 @@ funcsSqlQuery pgVer = [q|
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_description as d ON d.objoid = p.oid LEFT JOIN pg_description as d ON d.objoid = p.oid
LEFT JOIN LATERAL unnest(proconfig) config ON config like 'default_transaction_isolation%' LEFT JOIN LATERAL unnest(proconfig) iso_config ON iso_config like 'default_transaction_isolation%'
LEFT JOIN LATERAL unnest(proconfig) timeout_config ON timeout_config like 'statement_timeout%'
WHERE t.oid <> 'trigger'::regtype AND COALESCE(a.callable, true) WHERE t.oid <> 'trigger'::regtype AND COALESCE(a.callable, true)
|] <> (if pgVer >= pgVersion110 then "AND prokind = 'f'" else "AND NOT (proisagg OR proiswindow)") |] <> (if pgVer >= pgVersion110 then "AND prokind = 'f'" else "AND NOT (proisagg OR proiswindow)")
@@ -882,6 +911,7 @@ allComputedRels =
(QualifiedIdentifier <$> column HD.text <*> column HD.text) <*> (QualifiedIdentifier <$> column HD.text <*> column HD.text) <*>
(QualifiedIdentifier <$> column HD.text <*> column HD.text) <*> (QualifiedIdentifier <$> column HD.text <*> column HD.text) <*>
(QualifiedIdentifier <$> column HD.text <*> column HD.text) <*> (QualifiedIdentifier <$> column HD.text <*> column HD.text) <*>
pure (QualifiedIdentifier mempty mempty) <*>
column HD.bool <*> column HD.bool <*>
column HD.bool column HD.bool
@@ -1083,6 +1113,96 @@ allViewsKeyDependencies =
having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1) having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1)
|] |]
initialMediaHandlers :: MediaHandlerMap
initialMediaHandlers =
HM.insert (RelAnyElement, MediaType.MTAny ) (BuiltinOvAggJson, MediaType.MTApplicationJSON) $
HM.insert (RelAnyElement, MediaType.MTApplicationJSON) (BuiltinOvAggJson, MediaType.MTApplicationJSON) $
HM.insert (RelAnyElement, MediaType.MTTextCSV ) (BuiltinOvAggCsv, MediaType.MTTextCSV) $
HM.insert (RelAnyElement, MediaType.MTGeoJSON ) (BuiltinOvAggGeoJson, MediaType.MTGeoJSON)
HM.empty
mediaHandlers :: PgVersion -> Bool -> SQL.Statement [Schema] MediaHandlerMap
mediaHandlers pgVer =
SQL.Statement sql (arrayParam HE.text) decodeMediaHandlers
where
sql = [q|
with
all_relations as (
select reltype
from pg_class
where relkind in ('v','r','m','f','p')
union
select oid
from pg_type
where typname = 'anyelement'
),
media_types as (
SELECT
t.oid,
lower(t.typname) as typname,
b.oid as base_oid,
b.typname AS basetypname,
t.typnamespace,
case t.typname
when '*/*' then 'application/octet-stream'
else t.typname
end as resolved_media_type
FROM pg_type t
JOIN pg_type b ON t.typbasetype = b.oid
WHERE
t.typbasetype <> 0 and
(t.typname ~* '^[A-Za-z0-9.-]+/[A-Za-z0-9.\+-]+$' or t.typname = '*/*')
)
select
proc_schema.nspname as handler_schema,
proc.proname as handler_name,
arg_schema.nspname::text as target_schema,
arg_name.typname::text as target_name,
media_types.typname as media_type,
media_types.resolved_media_type
from media_types
join pg_proc proc on proc.prorettype = media_types.oid
join pg_namespace proc_schema on proc_schema.oid = proc.pronamespace
join pg_aggregate agg on agg.aggfnoid = proc.oid
join pg_type arg_name on arg_name.oid = proc.proargtypes[0]
join pg_namespace arg_schema on arg_schema.oid = arg_name.typnamespace
where
proc_schema.nspname = ANY($1) and
proc.pronargs = 1 and
arg_name.oid in (select reltype from all_relations)
union
select
typ_sch.nspname as handler_schema,
mtype.typname as handler_name,
pro_sch.nspname as target_schema,
proname as target_name,
mtype.typname as media_type,
mtype.resolved_media_type
from pg_proc proc
join pg_namespace pro_sch on pro_sch.oid = proc.pronamespace
join media_types mtype on proc.prorettype = mtype.oid
join pg_namespace typ_sch on typ_sch.oid = mtype.typnamespace
where
pro_sch.nspname = ANY($1) and NOT proretset
|] <> (if pgVer >= pgVersion110 then " AND prokind = 'f'" else " AND NOT (proisagg OR proiswindow)")
decodeMediaHandlers :: HD.Result MediaHandlerMap
decodeMediaHandlers =
HM.fromList . fmap (\(x, y, z, w) -> ((if isAnyElement y then RelAnyElement else RelId y, z), (CustomFunc x, w)) ) <$> HD.rowList caggRow
where
caggRow = (,,,)
<$> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
<*> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
<*> (MediaType.decodeMediaType . encodeUtf8 <$> column HD.text)
<*> (MediaType.decodeMediaType . encodeUtf8 <$> column HD.text)
timezones :: Bool -> SQL.Statement () TimezoneNames
timezones = SQL.Statement sql HE.noParams decodeTimezones
where
sql = "SELECT name FROM pg_timezone_names"
decodeTimezones :: HD.Result TimezoneNames
decodeTimezones = S.fromList . map encodeUtf8 <$> HD.rowList (column HD.text)
param :: HE.Value a -> HE.Params a param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable param = HE.param . HE.nonNullable
+8
View File
@@ -3,6 +3,8 @@
module PostgREST.SchemaCache.Identifiers module PostgREST.SchemaCache.Identifiers
( QualifiedIdentifier(..) ( QualifiedIdentifier(..)
, RelIdentifier(..)
, isAnyElement
, Schema , Schema
, TableName , TableName
, FieldName , FieldName
@@ -17,6 +19,9 @@ import qualified Data.Text as T
import Protolude import Protolude
data RelIdentifier = RelId QualifiedIdentifier | RelAnyElement
deriving (Eq, Ord, Generic, JSON.ToJSON, JSON.ToJSONKey)
instance Hashable RelIdentifier
-- | Represents a pg identifier with a prepended schema name "schema.table". -- | Represents a pg identifier with a prepended schema name "schema.table".
-- When qiSchema is "", the schema is defined by the pg search_path. -- When qiSchema is "", the schema is defined by the pg search_path.
@@ -28,6 +33,9 @@ data QualifiedIdentifier = QualifiedIdentifier
instance Hashable QualifiedIdentifier instance Hashable QualifiedIdentifier
isAnyElement :: QualifiedIdentifier -> Bool
isAnyElement y = QualifiedIdentifier "pg_catalog" "anyelement" == y
dumpQi :: QualifiedIdentifier -> Text dumpQi :: QualifiedIdentifier -> Text
dumpQi (QualifiedIdentifier s i) = dumpQi (QualifiedIdentifier s i) =
(if T.null s then mempty else s <> ".") <> i (if T.null s then mempty else s <> ".") <> i
@@ -31,6 +31,7 @@ data Relationship = Relationship
{ relFunction :: QualifiedIdentifier { relFunction :: QualifiedIdentifier
, relTable :: QualifiedIdentifier , relTable :: QualifiedIdentifier
, relForeignTable :: QualifiedIdentifier , relForeignTable :: QualifiedIdentifier
, relTableAlias :: QualifiedIdentifier
, relToOne :: Bool , relToOne :: Bool
, relIsSelf :: Bool , relIsSelf :: Bool
} }
+33 -14
View File
@@ -14,17 +14,22 @@ module PostgREST.SchemaCache.Routine
, funcReturnsVoid , funcReturnsVoid
, funcTableName , funcTableName
, funcReturnsCompositeAlias , funcReturnsCompositeAlias
, ResultAggregate(..) , funcReturnsSingle
, MediaHandlerMap
, ResolvedHandler
, MediaHandler(..)
) where ) where
import Data.Aeson ((.=)) import Data.Aeson ((.=))
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Hasql.Transaction.Sessions as SQL import qualified Hasql.Transaction.Sessions as SQL
import qualified PostgREST.MediaType as MediaType
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
RelIdentifier (..), Schema,
TableName)
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema, TableName)
import Protolude import Protolude
@@ -53,11 +58,12 @@ data Routine = Function
, pdVolatility :: FuncVolatility , pdVolatility :: FuncVolatility
, pdHasVariadic :: Bool , pdHasVariadic :: Bool
, pdIsoLvl :: Maybe SQL.IsolationLevel , pdIsoLvl :: Maybe SQL.IsolationLevel
, pdTimeout :: Maybe Text
} }
deriving (Eq, Show, Generic) deriving (Eq, Show, Generic)
-- need to define JSON manually bc SQL.IsolationLevel doesn't have a JSON instance(and we can't define one for that type without getting a compiler error) -- need to define JSON manually bc SQL.IsolationLevel doesn't have a JSON instance(and we can't define one for that type without getting a compiler error)
instance JSON.ToJSON Routine where instance JSON.ToJSON Routine where
toJSON (Function sch nam desc params ret vol hasVar _) = JSON.object toJSON (Function sch nam desc params ret vol hasVar _ tout) = JSON.object
[ [
"pdSchema" .= sch "pdSchema" .= sch
, "pdName" .= nam , "pdName" .= nam
@@ -66,6 +72,7 @@ instance JSON.ToJSON Routine where
, "pdReturnType" .= JSON.toJSON ret , "pdReturnType" .= JSON.toJSON ret
, "pdVolatility" .= JSON.toJSON vol , "pdVolatility" .= JSON.toJSON vol
, "pdHasVariadic" .= JSON.toJSON hasVar , "pdHasVariadic" .= JSON.toJSON hasVar
, "pdTimeout" .= tout
] ]
data RoutineParam = RoutineParam data RoutineParam = RoutineParam
@@ -79,26 +86,34 @@ data RoutineParam = RoutineParam
-- Order by least number of params in the case of overloaded functions -- Order by least number of params in the case of overloaded functions
instance Ord Routine where instance Ord Routine where
Function schema1 name1 des1 prms1 rt1 vol1 hasVar1 iso1 `compare` Function schema2 name2 des2 prms2 rt2 vol2 hasVar2 iso2 Function schema1 name1 des1 prms1 rt1 vol1 hasVar1 iso1 tout1 `compare` Function schema2 name2 des2 prms2 rt2 vol2 hasVar2 iso2 tout2
| schema1 == schema2 && name1 == name2 && length prms1 < length prms2 = LT | schema1 == schema2 && name1 == name2 && length prms1 < length prms2 = LT
| schema2 == schema2 && name1 == name2 && length prms1 > length prms2 = GT | schema2 == schema2 && name1 == name2 && length prms1 > length prms2 = GT
| otherwise = (schema1, name1, des1, prms1, rt1, vol1, hasVar1, iso1) `compare` (schema2, name2, des2, prms2, rt2, vol2, hasVar2, iso2) | otherwise = (schema1, name1, des1, prms1, rt1, vol1, hasVar1, iso1, tout1) `compare` (schema2, name2, des2, prms2, rt2, vol2, hasVar2, iso2, tout2)
-- | A map of all procs, all of which can be overloaded(one entry will have more than one Routine). -- | A map of all procs, all of which can be overloaded(one entry will have more than one Routine).
-- | It uses a HashMap for a faster lookup. -- | It uses a HashMap for a faster lookup.
type RoutineMap = HM.HashMap QualifiedIdentifier [Routine] type RoutineMap = HM.HashMap QualifiedIdentifier [Routine]
data ResultAggregate -- | A media handler can be an aggregate over a composite type or a function over a scalar
= BuiltinAggJson data MediaHandler
| BuiltinAggSingleJson Bool -- non overridable builtins
= BuiltinAggSingleJson Bool
| BuiltinAggArrayJsonStrip | BuiltinAggArrayJsonStrip
| BuiltinAggGeoJson -- these builtins are overridable
| BuiltinAggCsv | BuiltinOvAggJson
| BuiltinAggXml (Maybe FieldName) | BuiltinOvAggGeoJson
| BuiltinAggBinary (Maybe FieldName) | BuiltinOvAggCsv
-- custom
| CustomFunc QualifiedIdentifier
| NoAgg | NoAgg
deriving (Eq, Show) deriving (Eq, Show)
funcReturnsSingle :: Routine -> Bool
funcReturnsSingle proc = case proc of
Function{pdReturnType = Single _} -> True
_ -> False
funcReturnsScalar :: Routine -> Bool funcReturnsScalar :: Routine -> Bool
funcReturnsScalar proc = case proc of funcReturnsScalar proc = case proc of
Function{pdReturnType = Single (Scalar{})} -> True Function{pdReturnType = Single (Scalar{})} -> True
@@ -130,3 +145,7 @@ funcTableName proc = case pdReturnType proc of
SetOf (Composite qi _) -> Just $ qiName qi SetOf (Composite qi _) -> Just $ qiName qi
Single (Composite qi _) -> Just $ qiName qi Single (Composite qi _) -> Just $ qiName qi
_ -> Nothing _ -> Nothing
-- the resolved handler also carries the media type because MTAny (*/*) is resolved to a different media type
type ResolvedHandler = (MediaHandler, MediaType.MediaType)
type MediaHandlerMap = HM.HashMap (RelIdentifier, MediaType.MediaType) ResolvedHandler
+42 -46
View File
@@ -1,57 +1,53 @@
{-# LANGUAGE CPP #-}
module PostgREST.Unix module PostgREST.Unix
( runAppWithSocket ( installSignalHandlers
, installSignalHandlers , createAndBindDomainSocket
) where ) where
import qualified Network.Socket as Socket #ifndef mingw32_HOST_OS
import qualified Network.Wai.Handler.Warp as Warp import qualified System.Posix.Signals as Signals
import qualified System.Posix.Signals as Signals #endif
import System.Posix.Types (FileMode)
import System.PosixCompat.Files (setFileMode)
import Network.Wai (Application) import Data.String (String)
import System.Directory (removeFile) import qualified Network.Socket as NS
import System.IO.Error (isDoesNotExistError) import Protolude
import System.Posix.Files (setFileMode) import System.Directory (removeFile)
import System.Posix.Types (FileMode) import System.IO.Error (isDoesNotExistError)
import qualified PostgREST.AppState as AppState
import Protolude
-- | Run the PostgREST application with user defined socket.
runAppWithSocket :: Warp.Settings -> Application -> FileMode -> FilePath -> IO ()
runAppWithSocket settings app socketFileMode socketFilePath =
bracket createAndBindSocket Socket.close $ \socket -> do
Socket.listen socket Socket.maxListenQueue
Warp.runSettingsSocket settings socket app
where
createAndBindSocket = do
deleteSocketFileIfExist socketFilePath
sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
Socket.bind sock $ Socket.SockAddrUnix socketFilePath
setFileMode socketFilePath socketFileMode
return sock
deleteSocketFileIfExist path =
removeFile path `catch` handleDoesNotExist
handleDoesNotExist e
| isDoesNotExistError e = return ()
| otherwise = throwIO e
-- | Set signal handlers, only for systems with signals -- | Set signal handlers, only for systems with signals
installSignalHandlers :: AppState.AppState -> IO () installSignalHandlers :: ThreadId -> IO () -> IO () -> IO ()
installSignalHandlers appState = do #ifndef mingw32_HOST_OS
let interrupt = throwTo (AppState.getMainThreadId appState) UserInterrupt installSignalHandlers tid usr1 usr2 = do
let interrupt = throwTo tid UserInterrupt
install Signals.sigINT interrupt install Signals.sigINT interrupt
install Signals.sigTERM interrupt install Signals.sigTERM interrupt
install Signals.sigUSR1 usr1
-- The SIGUSR1 signal updates the internal 'SchemaCache' by running install Signals.sigUSR2 usr2
-- 'connectionWorker' exactly as before.
install Signals.sigUSR1 $ AppState.connectionWorker appState
-- Re-read the config on SIGUSR2
install Signals.sigUSR2 $ AppState.reReadConfig False appState
where where
install signal handler = install signal handler =
void $ Signals.installHandler signal (Signals.Catch handler) Nothing void $ Signals.installHandler signal (Signals.Catch handler) Nothing
#else
installSignalHandlers _ _ _ = pass
#endif
-- | Create a unix domain socket and bind it to the given path.
-- | The socket file will be deleted if it already exists.
createAndBindDomainSocket :: String -> FileMode -> IO NS.Socket
createAndBindDomainSocket path mode = do
unless NS.isUnixDomainSocketAvailable $
panic "Cannot run with unix socket on non-unix platforms. Consider deleting the `server-unix-socket` config entry in order to continue."
deleteSocketFileIfExist path
sock <- NS.socket NS.AF_UNIX NS.Stream NS.defaultProtocol
NS.bind sock $ NS.SockAddrUnix path
NS.listen sock (max 2048 NS.maxListenQueue)
setFileMode path mode
return sock
where
deleteSocketFileIfExist path' =
removeFile path' `catch` handleDoesNotExist
handleDoesNotExist e
| isDoesNotExistError e = return ()
| otherwise = throwIO e
+1 -1
View File
@@ -12,5 +12,5 @@ nix:
extra-deps: extra-deps:
- git: https://github.com/PostgREST/postgresql-libpq.git - git: https://github.com/PostgREST/postgresql-libpq.git
commit: 890a0a16cf57dd401420fdc6c7d576fb696003bc commit: 890a0a16cf57dd401420fdc6c7d576fb696003bc
- hasql-notifications-0.2.0.5 - hasql-notifications-0.2.0.6
- hasql-pool-0.10 - hasql-pool-0.10
+3 -3
View File
@@ -16,12 +16,12 @@ packages:
commit: 890a0a16cf57dd401420fdc6c7d576fb696003bc commit: 890a0a16cf57dd401420fdc6c7d576fb696003bc
git: https://github.com/PostgREST/postgresql-libpq.git git: https://github.com/PostgREST/postgresql-libpq.git
- completed: - completed:
hackage: hasql-notifications-0.2.0.5@sha256:6c67d2ee42e948162e89176a2f52a2c6bb562678b33688e96e048be9b3d74122,2028 hackage: hasql-notifications-0.2.0.6@sha256:16d783f5cd1660fad924fd3769380889de5804e057f09b304dcdc3a3ff11eb3c,2028
pantry-tree: pantry-tree:
sha256: 8493755b0817a36b910a37d568769ca49ab715ef5dd935a0d09f0716aa39da51 sha256: 2319743501bb3c0bef801014ce61308b8666cef86ae5a97a0a283c0c1ec12d4f
size: 452 size: 452
original: original:
hackage: hasql-notifications-0.2.0.5 hackage: hasql-notifications-0.2.0.6
- completed: - completed:
hackage: hasql-pool-0.10@sha256:912197a328acb85505f98bb9700d61f366b87659ca45126c5c2d636687b801c3,2112 hackage: hasql-pool-0.10@sha256:912197a328acb85505f98bb9700d61f366b87659ca45126c5c2d636687b801c3,2112
pantry-tree: pantry-tree:
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+1
View File
@@ -16,6 +16,7 @@ main =
, "src/PostgREST/Query/SqlFragment.hs" , "src/PostgREST/Query/SqlFragment.hs"
, "src/PostgREST/ApiRequest/Preferences.hs" , "src/PostgREST/ApiRequest/Preferences.hs"
, "src/PostgREST/ApiRequest/QueryParams.hs" , "src/PostgREST/ApiRequest/QueryParams.hs"
, "src/PostgREST/Response/Performance.hs"
, "src/PostgREST/Error.hs" , "src/PostgREST/Error.hs"
, "src/PostgREST/MediaType.hs" , "src/PostgREST/MediaType.hs"
, "src/PostgREST/Config.hs" , "src/PostgREST/Config.hs"
+5 -2
View File
@@ -1,3 +1,4 @@
db-aggregates-enabled = false
db-anon-role = "" db-anon-role = ""
db-channel = "pgrst" db-channel = "pgrst"
db-channel-enabled = true db-channel-enabled = true
@@ -8,6 +9,7 @@ db-pool = 10
db-pool-acquisition-timeout = 10 db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800 db-pool-max-lifetime = 1800
db-pool-max-idletime = 5 db-pool-max-idletime = 5
db-pool-automatic-recovery = true
db-pre-request = "check_alias" db-pre-request = "check_alias"
db-prepared-statements = true db-prepared-statements = true
db-root-spec = "open_alias" db-root-spec = "open_alias"
@@ -16,19 +18,20 @@ db-config = true
db-pre-config = "" db-pre-config = ""
db-tx-end = "commit" db-tx-end = "commit"
db-uri = "postgresql://" db-uri = "postgresql://"
db-use-legacy-gucs = true
jwt-aud = "" jwt-aud = ""
jwt-role-claim-key = ".\"aliased\"" jwt-role-claim-key = ".\"aliased\""
jwt-secret = "" jwt-secret = ""
jwt-secret-is-base64 = true jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 0
log-level = "error" log-level = "error"
openapi-mode = "follow-privileges" openapi-mode = "follow-privileges"
openapi-security-active = false openapi-security-active = false
openapi-server-proxy-uri = "" openapi-server-proxy-uri = ""
raw-media-types = "" server-cors-allowed-origins = ""
server-host = "!4" server-host = "!4"
server-port = 3000 server-port = 3000
server-trace-header = "" server-trace-header = ""
server-timing-enabled = false
server-unix-socket = "" server-unix-socket = ""
server-unix-socket-mode = "660" server-unix-socket-mode = "660"
admin-server-port = "" admin-server-port = ""
@@ -1,3 +1,4 @@
db-aggregates-enabled = false
db-anon-role = "" db-anon-role = ""
db-channel = "pgrst" db-channel = "pgrst"
db-channel-enabled = true db-channel-enabled = true
@@ -8,6 +9,7 @@ db-pool = 10
db-pool-acquisition-timeout = 10 db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800 db-pool-max-lifetime = 1800
db-pool-max-idletime = 30 db-pool-max-idletime = 30
db-pool-automatic-recovery = true
db-pre-request = "" db-pre-request = ""
db-prepared-statements = false db-prepared-statements = false
db-root-spec = "" db-root-spec = ""
@@ -16,19 +18,20 @@ db-config = true
db-pre-config = "" db-pre-config = ""
db-tx-end = "commit" db-tx-end = "commit"
db-uri = "postgresql://" db-uri = "postgresql://"
db-use-legacy-gucs = true
jwt-aud = "" jwt-aud = ""
jwt-role-claim-key = ".\"role\"" jwt-role-claim-key = ".\"role\""
jwt-secret = "" jwt-secret = ""
jwt-secret-is-base64 = true jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 0
log-level = "error" log-level = "error"
openapi-mode = "follow-privileges" openapi-mode = "follow-privileges"
openapi-security-active = false openapi-security-active = false
openapi-server-proxy-uri = "" openapi-server-proxy-uri = ""
raw-media-types = "" server-cors-allowed-origins = ""
server-host = "!4" server-host = "!4"
server-port = 3000 server-port = 3000
server-trace-header = "" server-trace-header = ""
server-timing-enabled = false
server-unix-socket = "" server-unix-socket = ""
server-unix-socket-mode = "660" server-unix-socket-mode = "660"
admin-server-port = "" admin-server-port = ""
@@ -1,3 +1,4 @@
db-aggregates-enabled = false
db-anon-role = "" db-anon-role = ""
db-channel = "pgrst" db-channel = "pgrst"
db-channel-enabled = true db-channel-enabled = true
@@ -8,6 +9,7 @@ db-pool = 10
db-pool-acquisition-timeout = 10 db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800 db-pool-max-lifetime = 1800
db-pool-max-idletime = 30 db-pool-max-idletime = 30
db-pool-automatic-recovery = true
db-pre-request = "" db-pre-request = ""
db-prepared-statements = false db-prepared-statements = false
db-root-spec = "" db-root-spec = ""
@@ -16,19 +18,20 @@ db-config = true
db-pre-config = "" db-pre-config = ""
db-tx-end = "commit" db-tx-end = "commit"
db-uri = "postgresql://" db-uri = "postgresql://"
db-use-legacy-gucs = true
jwt-aud = "" jwt-aud = ""
jwt-role-claim-key = ".\"role\"" jwt-role-claim-key = ".\"role\""
jwt-secret = "" jwt-secret = ""
jwt-secret-is-base64 = true jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 0
log-level = "error" log-level = "error"
openapi-mode = "follow-privileges" openapi-mode = "follow-privileges"
openapi-security-active = false openapi-security-active = false
openapi-server-proxy-uri = "" openapi-server-proxy-uri = ""
raw-media-types = "" server-cors-allowed-origins = ""
server-host = "!4" server-host = "!4"
server-port = 3000 server-port = 3000
server-trace-header = "" server-trace-header = ""
server-timing-enabled = false
server-unix-socket = "" server-unix-socket = ""
server-unix-socket-mode = "660" server-unix-socket-mode = "660"
admin-server-port = "" admin-server-port = ""
+5 -2
View File
@@ -1,3 +1,4 @@
db-aggregates-enabled = false
db-anon-role = "" db-anon-role = ""
db-channel = "pgrst" db-channel = "pgrst"
db-channel-enabled = true db-channel-enabled = true
@@ -8,6 +9,7 @@ db-pool = 10
db-pool-acquisition-timeout = 10 db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800 db-pool-max-lifetime = 1800
db-pool-max-idletime = 30 db-pool-max-idletime = 30
db-pool-automatic-recovery = true
db-pre-request = "" db-pre-request = ""
db-prepared-statements = true db-prepared-statements = true
db-root-spec = "" db-root-spec = ""
@@ -16,19 +18,20 @@ db-config = false
db-pre-config = "" db-pre-config = ""
db-tx-end = "commit" db-tx-end = "commit"
db-uri = "postgresql://" db-uri = "postgresql://"
db-use-legacy-gucs = true
jwt-aud = "" jwt-aud = ""
jwt-role-claim-key = ".\"role\"" jwt-role-claim-key = ".\"role\""
jwt-secret = "" jwt-secret = ""
jwt-secret-is-base64 = false jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
log-level = "error" log-level = "error"
openapi-mode = "follow-privileges" openapi-mode = "follow-privileges"
openapi-security-active = false openapi-security-active = false
openapi-server-proxy-uri = "" openapi-server-proxy-uri = ""
raw-media-types = "" server-cors-allowed-origins = ""
server-host = "!4" server-host = "!4"
server-port = 3000 server-port = 3000
server-trace-header = "" server-trace-header = ""
server-timing-enabled = false
server-unix-socket = "" server-unix-socket = ""
server-unix-socket-mode = "660" server-unix-socket-mode = "660"
admin-server-port = "" admin-server-port = ""
@@ -1,3 +1,4 @@
db-aggregates-enabled = false
db-anon-role = "pre_config_role" db-anon-role = "pre_config_role"
db-channel = "postgrest" db-channel = "postgrest"
db-channel-enabled = false db-channel-enabled = false
@@ -8,6 +9,7 @@ db-pool = 1
db-pool-acquisition-timeout = 30 db-pool-acquisition-timeout = 30
db-pool-max-lifetime = 3600 db-pool-max-lifetime = 3600
db-pool-max-idletime = 60 db-pool-max-idletime = 60
db-pool-automatic-recovery = false
db-pre-request = "test.other_custom_headers" db-pre-request = "test.other_custom_headers"
db-prepared-statements = false db-prepared-statements = false
db-root-spec = "other_root" db-root-spec = "other_root"
@@ -16,19 +18,20 @@ db-config = true
db-pre-config = "postgrest.pre_config" db-pre-config = "postgrest.pre_config"
db-tx-end = "rollback-allow-override" db-tx-end = "rollback-allow-override"
db-uri = "postgresql://" db-uri = "postgresql://"
db-use-legacy-gucs = false
jwt-aud = "https://otherexample.org" jwt-aud = "https://otherexample.org"
jwt-role-claim-key = ".\"other\".\"pre_config_role\"" jwt-role-claim-key = ".\"other\".\"pre_config_role\""
jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE" jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE"
jwt-secret-is-base64 = true jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 7200
log-level = "info" log-level = "info"
openapi-mode = "disabled" openapi-mode = "disabled"
openapi-security-active = false openapi-security-active = false
openapi-server-proxy-uri = "https://otherexample.org/api" openapi-server-proxy-uri = "https://otherexample.org/api"
raw-media-types = "application/vnd.pgrst.other-db-config" server-cors-allowed-origins = "http://example.com"
server-host = "0.0.0.0" server-host = "0.0.0.0"
server-port = 80 server-port = 80
server-trace-header = "traceparent" server-trace-header = "traceparent"
server-timing-enabled = true
server-unix-socket = "/tmp/pgrst_io_test.sock" server-unix-socket = "/tmp/pgrst_io_test.sock"
server-unix-socket-mode = "777" server-unix-socket-mode = "777"
admin-server-port = 3001 admin-server-port = 3001
@@ -1,3 +1,4 @@
db-aggregates-enabled = false
db-anon-role = "anonymous" db-anon-role = "anonymous"
db-channel = "postgrest" db-channel = "postgrest"
db-channel-enabled = false db-channel-enabled = false
@@ -8,6 +9,7 @@ db-pool = 1
db-pool-acquisition-timeout = 30 db-pool-acquisition-timeout = 30
db-pool-max-lifetime = 3600 db-pool-max-lifetime = 3600
db-pool-max-idletime = 60 db-pool-max-idletime = 60
db-pool-automatic-recovery = false
db-pre-request = "test.custom_headers" db-pre-request = "test.custom_headers"
db-prepared-statements = false db-prepared-statements = false
db-root-spec = "root" db-root-spec = "root"
@@ -16,19 +18,20 @@ db-config = true
db-pre-config = "postgrest.preconf" db-pre-config = "postgrest.preconf"
db-tx-end = "commit-allow-override" db-tx-end = "commit-allow-override"
db-uri = "postgresql://" db-uri = "postgresql://"
db-use-legacy-gucs = false
jwt-aud = "https://example.org" jwt-aud = "https://example.org"
jwt-role-claim-key = ".\"a\".\"role\"" jwt-role-claim-key = ".\"a\".\"role\""
jwt-secret = "OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE" jwt-secret = "OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE"
jwt-secret-is-base64 = false jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 3600
log-level = "info" log-level = "info"
openapi-mode = "ignore-privileges" openapi-mode = "ignore-privileges"
openapi-security-active = true openapi-security-active = true
openapi-server-proxy-uri = "https://example.org/api" openapi-server-proxy-uri = "https://example.org/api"
raw-media-types = "application/vnd.pgrst.db-config" server-cors-allowed-origins = "http://example.com"
server-host = "0.0.0.0" server-host = "0.0.0.0"
server-port = 80 server-port = 80
server-trace-header = "CF-Ray" server-trace-header = "CF-Ray"
server-timing-enabled = true
server-unix-socket = "/tmp/pgrst_io_test.sock" server-unix-socket = "/tmp/pgrst_io_test.sock"
server-unix-socket-mode = "777" server-unix-socket-mode = "777"
admin-server-port = 3001 admin-server-port = 3001
+5 -2
View File
@@ -1,3 +1,4 @@
db-aggregates-enabled = true
db-anon-role = "root" db-anon-role = "root"
db-channel = "postgrest" db-channel = "postgrest"
db-channel-enabled = false db-channel-enabled = false
@@ -8,6 +9,7 @@ db-pool = 1
db-pool-acquisition-timeout = 30 db-pool-acquisition-timeout = 30
db-pool-max-lifetime = 3600 db-pool-max-lifetime = 3600
db-pool-max-idletime = 60 db-pool-max-idletime = 60
db-pool-automatic-recovery = false
db-pre-request = "please_run_fast" db-pre-request = "please_run_fast"
db-prepared-statements = false db-prepared-statements = false
db-root-spec = "openapi_v3" db-root-spec = "openapi_v3"
@@ -16,19 +18,20 @@ db-config = false
db-pre-config = "postgrest.pre_config" db-pre-config = "postgrest.pre_config"
db-tx-end = "rollback-allow-override" db-tx-end = "rollback-allow-override"
db-uri = "tmp_db" db-uri = "tmp_db"
db-use-legacy-gucs = false
jwt-aud = "https://postgrest.org" jwt-aud = "https://postgrest.org"
jwt-role-claim-key = ".\"user\"[0].\"real-role\"" jwt-role-claim-key = ".\"user\"[0].\"real-role\""
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5" jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
jwt-secret-is-base64 = true jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 86400
log-level = "info" log-level = "info"
openapi-mode = "ignore-privileges" openapi-mode = "ignore-privileges"
openapi-security-active = true openapi-security-active = true
openapi-server-proxy-uri = "https://postgrest.org" openapi-server-proxy-uri = "https://postgrest.org"
raw-media-types = "application/vnd.pgrst.config" server-cors-allowed-origins = "http://example.com"
server-host = "0.0.0.0" server-host = "0.0.0.0"
server-port = 80 server-port = 80
server-trace-header = "X-Request-Id" server-trace-header = "X-Request-Id"
server-timing-enabled = true
server-unix-socket = "/tmp/pgrst_io_test.sock" server-unix-socket = "/tmp/pgrst_io_test.sock"
server-unix-socket-mode = "777" server-unix-socket-mode = "777"
admin-server-port = 3001 admin-server-port = 3001
+5 -2
View File
@@ -1,3 +1,4 @@
db-aggregates-enabled = false
db-anon-role = "" db-anon-role = ""
db-channel = "pgrst" db-channel = "pgrst"
db-channel-enabled = true db-channel-enabled = true
@@ -8,6 +9,7 @@ db-pool = 10
db-pool-acquisition-timeout = 10 db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800 db-pool-max-lifetime = 1800
db-pool-max-idletime = 30 db-pool-max-idletime = 30
db-pool-automatic-recovery = true
db-pre-request = "" db-pre-request = ""
db-prepared-statements = true db-prepared-statements = true
db-root-spec = "" db-root-spec = ""
@@ -16,19 +18,20 @@ db-config = true
db-pre-config = "" db-pre-config = ""
db-tx-end = "commit" db-tx-end = "commit"
db-uri = "postgresql://" db-uri = "postgresql://"
db-use-legacy-gucs = true
jwt-aud = "" jwt-aud = ""
jwt-role-claim-key = ".\"role\"" jwt-role-claim-key = ".\"role\""
jwt-secret = "" jwt-secret = ""
jwt-secret-is-base64 = false jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
log-level = "error" log-level = "error"
openapi-mode = "follow-privileges" openapi-mode = "follow-privileges"
openapi-security-active = false openapi-security-active = false
openapi-server-proxy-uri = "" openapi-server-proxy-uri = ""
raw-media-types = "" server-cors-allowed-origins = ""
server-host = "!4" server-host = "!4"
server-port = 3000 server-port = 3000
server-trace-header = "" server-trace-header = ""
server-timing-enabled = false
server-unix-socket = "" server-unix-socket = ""
server-unix-socket-mode = "660" server-unix-socket-mode = "660"
admin-server-port = "" admin-server-port = ""
+5 -1
View File
@@ -1,5 +1,6 @@
PGRST_APP_SETTINGS_test2: test PGRST_APP_SETTINGS_test2: test
PGRST_APP_SETTINGS_test: test PGRST_APP_SETTINGS_test: test
PGRST_DB_AGGREGATES_ENABLED: true
PGRST_DB_ANON_ROLE: root PGRST_DB_ANON_ROLE: root
PGRST_DB_CHANNEL: postgrest PGRST_DB_CHANNEL: postgrest
PGRST_DB_CHANNEL_ENABLED: false PGRST_DB_CHANNEL_ENABLED: false
@@ -10,6 +11,7 @@ PGRST_DB_POOL: 1
PGRST_DB_POOL_ACQUISITION_TIMEOUT: 30 PGRST_DB_POOL_ACQUISITION_TIMEOUT: 30
PGRST_DB_POOL_MAX_LIFETIME: 3600 PGRST_DB_POOL_MAX_LIFETIME: 3600
PGRST_DB_POOL_MAX_IDLETIME: 60 PGRST_DB_POOL_MAX_IDLETIME: 60
PGRST_DB_POOL_AUTOMATIC_RECOVERY: false
PGRST_DB_PREPARED_STATEMENTS: false PGRST_DB_PREPARED_STATEMENTS: false
PGRST_DB_PRE_REQUEST: please_run_fast PGRST_DB_PRE_REQUEST: please_run_fast
PGRST_DB_ROOT_SPEC: openapi_v3 PGRST_DB_ROOT_SPEC: openapi_v3
@@ -23,14 +25,16 @@ PGRST_JWT_AUD: 'https://postgrest.org'
PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"' PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"'
PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5 PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5
PGRST_JWT_SECRET_IS_BASE64: true PGRST_JWT_SECRET_IS_BASE64: true
PGRST_JWT_CACHE_MAX_LIFETIME: 86400
PGRST_LOG_LEVEL: info PGRST_LOG_LEVEL: info
PGRST_OPENAPI_MODE: 'ignore-privileges' PGRST_OPENAPI_MODE: 'ignore-privileges'
PGRST_OPENAPI_SECURITY_ACTIVE: true PGRST_OPENAPI_SECURITY_ACTIVE: true
PGRST_OPENAPI_SERVER_PROXY_URI: 'https://postgrest.org' PGRST_OPENAPI_SERVER_PROXY_URI: 'https://postgrest.org'
PGRST_RAW_MEDIA_TYPES: application/vnd.pgrst.config PGRST_SERVER_CORS_ALLOWED_ORIGINS: "http://example.com"
PGRST_SERVER_HOST: 0.0.0.0 PGRST_SERVER_HOST: 0.0.0.0
PGRST_SERVER_PORT: 80 PGRST_SERVER_PORT: 80
PGRST_SERVER_TRACE_HEADER: X-Request-Id PGRST_SERVER_TRACE_HEADER: X-Request-Id
PGRST_SERVER_TIMING_ENABLED: true
PGRST_SERVER_UNIX_SOCKET: /tmp/pgrst_io_test.sock PGRST_SERVER_UNIX_SOCKET: /tmp/pgrst_io_test.sock
PGRST_SERVER_UNIX_SOCKET_MODE: 777 PGRST_SERVER_UNIX_SOCKET_MODE: 777
PGRST_ADMIN_SERVER_PORT: 3001 PGRST_ADMIN_SERVER_PORT: 3001
+5 -2
View File
@@ -1,3 +1,4 @@
db-aggregates-enabled = true
db-anon-role = "root" db-anon-role = "root"
db-channel = "postgrest" db-channel = "postgrest"
db-channel-enabled = false db-channel-enabled = false
@@ -8,6 +9,7 @@ db-pool = 1
db-pool-acquisition-timeout = 30 db-pool-acquisition-timeout = 30
db-pool-max-lifetime = 3600 db-pool-max-lifetime = 3600
db-pool-max-idletime = 60 db-pool-max-idletime = 60
db-pool-automatic-recovery = false
db-pre-request = "please_run_fast" db-pre-request = "please_run_fast"
db-prepared-statements = false db-prepared-statements = false
db-root-spec = "openapi_v3" db-root-spec = "openapi_v3"
@@ -16,19 +18,20 @@ db-config = false
db-pre-config = "postgrest.pre_config" db-pre-config = "postgrest.pre_config"
db-tx-end = "rollback-allow-override" db-tx-end = "rollback-allow-override"
db-uri = "tmp_db" db-uri = "tmp_db"
db-use-legacy-gucs = false
jwt-aud = "https://postgrest.org" jwt-aud = "https://postgrest.org"
jwt-role-claim-key = ".user[0].\"real-role\"" jwt-role-claim-key = ".user[0].\"real-role\""
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5" jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
jwt-secret-is-base64 = true jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 86400
log-level = "info" log-level = "info"
openapi-mode = "ignore-privileges" openapi-mode = "ignore-privileges"
openapi-security-active = true openapi-security-active = true
openapi-server-proxy-uri = "https://postgrest.org" openapi-server-proxy-uri = "https://postgrest.org"
raw-media-types = "application/vnd.pgrst.config" server-cors-allowed-origins = "http://example.com"
server-host = "0.0.0.0" server-host = "0.0.0.0"
server-port = 80 server-port = 80
server-trace-header = "X-Request-Id" server-trace-header = "X-Request-Id"
server-timing-enabled = true
server-unix-socket = "/tmp/pgrst_io_test.sock" server-unix-socket = "/tmp/pgrst_io_test.sock"
server-unix-socket-mode = "777" server-unix-socket-mode = "777"
admin-server-port = 3001 admin-server-port = 3001
-3
View File
@@ -8,6 +8,3 @@ db-channel-enabled = 13
# expects integer or string # expects integer or string
db-max-rows = true db-max-rows = true
# expects string
raw-media-types = true
+8 -2
View File
@@ -3,10 +3,11 @@ CREATE ROLE db_config_authenticator LOGIN NOINHERIT;
-- reloadable config options -- reloadable config options
ALTER ROLE db_config_authenticator SET pgrst.jwt_aud = 'https://example.org'; ALTER ROLE db_config_authenticator SET pgrst.jwt_aud = 'https://example.org';
ALTER ROLE db_config_authenticator SET pgrst.openapi_server_proxy_uri = 'https://example.org/api'; ALTER ROLE db_config_authenticator SET pgrst.openapi_server_proxy_uri = 'https://example.org/api';
ALTER ROLE db_config_authenticator SET pgrst.raw_media_types = 'application/vnd.pgrst.db-config';
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret = 'REALLY=REALLY=REALLY=REALLY=VERY=SAFE'; ALTER ROLE db_config_authenticator SET pgrst.jwt_secret = 'REALLY=REALLY=REALLY=REALLY=VERY=SAFE';
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret_is_base64 = 'false'; ALTER ROLE db_config_authenticator SET pgrst.jwt_secret_is_base64 = 'false';
ALTER ROLE db_config_authenticator SET pgrst.jwt_role_claim_key = '."a"."role"'; ALTER ROLE db_config_authenticator SET pgrst.jwt_role_claim_key = '."a"."role"';
ALTER ROLE db_config_authenticator SET pgrst.jwt_cache_max_lifetime = '3600';
ALTER ROLE db_config_authenticator SET pgrst.db_aggregates_enabled = 'false';
ALTER ROLE db_config_authenticator SET pgrst.db_anon_role = 'anonymous'; ALTER ROLE db_config_authenticator SET pgrst.db_anon_role = 'anonymous';
ALTER ROLE db_config_authenticator SET pgrst.db_tx_end = 'commit-allow-override'; ALTER ROLE db_config_authenticator SET pgrst.db_tx_end = 'commit-allow-override';
ALTER ROLE db_config_authenticator SET pgrst.db_pre_config = 'postgrest.preconf'; ALTER ROLE db_config_authenticator SET pgrst.db_pre_config = 'postgrest.preconf';
@@ -18,7 +19,9 @@ ALTER ROLE db_config_authenticator SET pgrst.db_pre_request = 'test.custom_heade
ALTER ROLE db_config_authenticator SET pgrst.db_max_rows = '1000'; ALTER ROLE db_config_authenticator SET pgrst.db_max_rows = '1000';
ALTER ROLE db_config_authenticator SET pgrst.db_extra_search_path = 'public, extensions'; ALTER ROLE db_config_authenticator SET pgrst.db_extra_search_path = 'public, extensions';
ALTER ROLE db_config_authenticator SET pgrst.not_existing = 'should be ignored'; ALTER ROLE db_config_authenticator SET pgrst.not_existing = 'should be ignored';
ALTER ROLE db_config_authenticator SET pgrst.server_cors_allowed_origins = 'http://example.com';
ALTER ROLE db_config_authenticator SET pgrst.server_trace_header = 'CF-Ray'; ALTER ROLE db_config_authenticator SET pgrst.server_trace_header = 'CF-Ray';
ALTER ROLE db_config_authenticator SET pgrst.server_timing_enabled = 'true';
-- override with database specific setting -- override with database specific setting
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.jwt_secret = 'OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE'; ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.jwt_secret = 'OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE';
@@ -50,9 +53,10 @@ ALTER ROLE db_config_authenticator SET pgrst.db_config = 'true';
CREATE ROLE other_authenticator LOGIN NOINHERIT; CREATE ROLE other_authenticator LOGIN NOINHERIT;
ALTER ROLE other_authenticator SET pgrst.jwt_aud = 'https://otherexample.org'; ALTER ROLE other_authenticator SET pgrst.jwt_aud = 'https://otherexample.org';
ALTER ROLE other_authenticator SET pgrst.openapi_server_proxy_uri = 'https://otherexample.org/api'; ALTER ROLE other_authenticator SET pgrst.openapi_server_proxy_uri = 'https://otherexample.org/api';
ALTER ROLE other_authenticator SET pgrst.raw_media_types = 'application/vnd.pgrst.other-db-config';
ALTER ROLE other_authenticator SET pgrst.jwt_secret = 'ODERREALLYREALLYREALLYREALLYVERYSAFE'; ALTER ROLE other_authenticator SET pgrst.jwt_secret = 'ODERREALLYREALLYREALLYREALLYVERYSAFE';
ALTER ROLE other_authenticator SET pgrst.jwt_secret_is_base64 = 'true'; ALTER ROLE other_authenticator SET pgrst.jwt_secret_is_base64 = 'true';
ALTER ROLE other_authenticator SET pgrst.jwt_cache_max_lifetime = '7200';
ALTER ROLE other_authenticator SET pgrst.db_aggregates_enabled = 'false';
ALTER ROLE other_authenticator SET pgrst.db_schemas = 'test, other_tenant1, other_tenant2'; ALTER ROLE other_authenticator SET pgrst.db_schemas = 'test, other_tenant1, other_tenant2';
ALTER ROLE other_authenticator SET pgrst.db_root_spec = 'other_root'; ALTER ROLE other_authenticator SET pgrst.db_root_spec = 'other_root';
ALTER ROLE other_authenticator SET pgrst.db_plan_enabled = 'true'; ALTER ROLE other_authenticator SET pgrst.db_plan_enabled = 'true';
@@ -62,8 +66,10 @@ ALTER ROLE other_authenticator SET pgrst.db_max_rows = '100';
ALTER ROLE other_authenticator SET pgrst.db_extra_search_path = 'public, extensions, other'; ALTER ROLE other_authenticator SET pgrst.db_extra_search_path = 'public, extensions, other';
ALTER ROLE other_authenticator SET pgrst.openapi_mode = 'disabled'; ALTER ROLE other_authenticator SET pgrst.openapi_mode = 'disabled';
ALTER ROLE other_authenticator SET pgrst.openapi_security_active = 'false'; ALTER ROLE other_authenticator SET pgrst.openapi_security_active = 'false';
ALTER ROLE other_authenticator SET pgrst.server_cors_allowed_origins = 'http://example.com';
ALTER ROLE other_authenticator SET pgrst.server_trace_header = 'traceparent'; ALTER ROLE other_authenticator SET pgrst.server_trace_header = 'traceparent';
ALTER ROLE other_authenticator SET pgrst.db_pre_config = 'postgrest.pre_config'; ALTER ROLE other_authenticator SET pgrst.db_pre_config = 'postgrest.pre_config';
ALTER ROLE other_authenticator SET pgrst.server_timing_enabled = 'true';
create schema postgrest; create schema postgrest;
grant usage on schema postgrest to db_config_authenticator; grant usage on schema postgrest to db_config_authenticator;
+25 -1
View File
@@ -1,6 +1,7 @@
-- \ir big_schema.sql big schema test currently skipped, see test_io.py -- \ir big_schema.sql big schema test currently skipped, see test_io.py
\ir db_config.sql \ir db_config.sql
set check_function_bodies = false; -- to allow conditionals based on the pg version
set search_path to public; set search_path to public;
CREATE ROLE postgrest_test_anonymous; CREATE ROLE postgrest_test_anonymous;
@@ -18,6 +19,13 @@ CREATE ROLE postgrest_test_w_superuser_settings;
alter role postgrest_test_w_superuser_settings set log_min_duration_statement = 1; alter role postgrest_test_w_superuser_settings set log_min_duration_statement = 1;
alter role postgrest_test_w_superuser_settings set log_min_messages = 'fatal'; alter role postgrest_test_w_superuser_settings set log_min_messages = 'fatal';
DO $do$BEGIN
IF (SELECT current_setting('server_version_num')::INT >= 150000) THEN
ALTER ROLE postgrest_test_w_superuser_settings SET log_min_duration_sample = 12345;
GRANT SET ON PARAMETER log_min_duration_sample to postgrest_test_authenticator;
END IF;
END$do$;
GRANT GRANT
postgrest_test_anonymous, postgrest_test_author, postgrest_test_anonymous, postgrest_test_author,
postgrest_test_serializable, postgrest_test_repeatable_read, postgrest_test_serializable, postgrest_test_repeatable_read,
@@ -173,4 +181,20 @@ select application_name
from pg_stat_activity from pg_stat_activity
where application_name ilike 'postgrest%' where application_name ilike 'postgrest%'
limit 1; limit 1;
$$ $$;
create function terminate_pgrst() returns setof record as $$
select pg_terminate_backend(pid) from pg_stat_activity where application_name iLIKE '%postgrest%';
$$ language sql security definer;
create or replace function one_sec_timeout() returns void as $$
select pg_sleep(3);
$$ language sql set statement_timeout = '1s';
create or replace function four_sec_timeout() returns void as $$
select pg_sleep(3);
$$ language sql set statement_timeout = '4s';
create function get_postgres_version() returns int as $$
select current_setting('server_version_num')::int;
$$ language sql;
-5
View File
@@ -119,11 +119,6 @@ cli:
use_defaultenv: true use_defaultenv: true
env: env:
PGRST_DB_TX_END: rollback PGRST_DB_TX_END: rollback
- name: raw-media-types list
expect: 'raw-media-types = "image/png,image/jpeg"'
use_defaultenv: true
env:
PGRST_RAW_MEDIA_TYPES: ' image/png , image/jpeg '
roleclaims: roleclaims:
- key: '.postgrest.a_role' - key: '.postgrest.a_role'
+15
View File
@@ -17,6 +17,21 @@ import requests_unixsocket
from config import * from config import *
def sleep_until_postgrest_scache_reload():
"Sleep until schema cache reload"
time.sleep(0.3)
def sleep_until_postgrest_config_reload():
"Sleep until config reload"
time.sleep(0.2)
def sleep_until_postgrest_full_reload():
"Sleep until schema cache plus config reload"
time.sleep(0.3)
class PostgrestTimedOut(Exception): class PostgrestTimedOut(Exception):
"Connecting to PostgREST endpoint timed out." "Connecting to PostgREST endpoint timed out."
+332 -20
View File
@@ -1,6 +1,6 @@
"Unit tests for Input/Ouput of PostgREST seen as a black box." "Unit tests for Input/Ouput of PostgREST seen as a black box."
from datetime import datetime from datetime import datetime, timedelta, timezone
from operator import attrgetter from operator import attrgetter
import os import os
import re import re
@@ -169,7 +169,7 @@ def test_app_settings_flush_pool(defaultenv):
# SIGUSR1 causes the postgres connection pool to be flushed # SIGUSR1 causes the postgres connection pool to be flushed
postgrest.process.send_signal(signal.SIGUSR1) postgrest.process.send_signal(signal.SIGUSR1)
time.sleep(0.1) sleep_until_postgrest_scache_reload()
uri = "/rpc/get_guc_value?name=app.settings.external_api_secret" uri = "/rpc/get_guc_value?name=app.settings.external_api_secret"
response = postgrest.session.get(uri) response = postgrest.session.get(uri)
@@ -197,6 +197,13 @@ def test_flush_pool_no_interrupt(defaultenv):
t.join() t.join()
def test_random_port_bound(defaultenv):
"PostgREST should bind to a random port when PGRST_SERVER_PORT is 0."
with run(env=defaultenv, port="0") as postgrest:
assert True # liveness check is done by run(), so we just need to check that it doesn't fail
def test_app_settings_reload(tmp_path, defaultenv): def test_app_settings_reload(tmp_path, defaultenv):
"App settings should be reloaded from file when PostgREST is sent SIGUSR2." "App settings should be reloaded from file when PostgREST is sent SIGUSR2."
config = (CONFIGSDIR / "sigusr2-settings.config").read_text() config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
@@ -213,7 +220,7 @@ def test_app_settings_reload(tmp_path, defaultenv):
# reload # reload
postgrest.process.send_signal(signal.SIGUSR2) postgrest.process.send_signal(signal.SIGUSR2)
time.sleep(0.1) sleep_until_postgrest_config_reload()
response = postgrest.session.get(uri) response = postgrest.session.get(uri)
assert response.text == '"Jane"' assert response.text == '"Jane"'
@@ -237,7 +244,7 @@ def test_jwt_secret_reload(tmp_path, defaultenv):
# reload config # reload config
postgrest.process.send_signal(signal.SIGUSR2) postgrest.process.send_signal(signal.SIGUSR2)
time.sleep(0.1) sleep_until_postgrest_config_reload()
response = postgrest.session.get("/authors_only", headers=headers) response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200 assert response.status_code == 200
@@ -267,14 +274,14 @@ def test_jwt_secret_external_file_reload(tmp_path, defaultenv):
# SIGUSR1 doesn't reload external files, at least when db-config=false # SIGUSR1 doesn't reload external files, at least when db-config=false
postgrest.process.send_signal(signal.SIGUSR1) postgrest.process.send_signal(signal.SIGUSR1)
time.sleep(0.1) sleep_until_postgrest_scache_reload()
response = postgrest.session.get("/authors_only", headers=headers) response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401 assert response.status_code == 401
# reload config and external file with SIGUSR2 # reload config and external file with SIGUSR2
postgrest.process.send_signal(signal.SIGUSR2) postgrest.process.send_signal(signal.SIGUSR2)
time.sleep(0.1) sleep_until_postgrest_config_reload()
response = postgrest.session.get("/authors_only", headers=headers) response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200 assert response.status_code == 200
@@ -285,7 +292,7 @@ def test_jwt_secret_external_file_reload(tmp_path, defaultenv):
# reload config and external file with NOTIFY # reload config and external file with NOTIFY
response = postgrest.session.post("/rpc/reload_pgrst_config") response = postgrest.session.post("/rpc/reload_pgrst_config")
assert response.status_code == 204 assert response.status_code == 204
time.sleep(0.1) sleep_until_postgrest_config_reload()
response = postgrest.session.get("/authors_only", headers=headers) response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401 assert response.status_code == 401
@@ -308,11 +315,11 @@ def test_db_schema_reload(tmp_path, defaultenv):
# reload config # reload config
postgrest.process.send_signal(signal.SIGUSR2) postgrest.process.send_signal(signal.SIGUSR2)
time.sleep(0.1) sleep_until_postgrest_config_reload()
# reload schema cache to verify that the config reload actually happened # reload schema cache to verify that the config reload actually happened
postgrest.process.send_signal(signal.SIGUSR1) postgrest.process.send_signal(signal.SIGUSR1)
time.sleep(0.1) sleep_until_postgrest_scache_reload()
response = postgrest.session.get("/rpc/get_guc_value?name=search_path") response = postgrest.session.get("/rpc/get_guc_value?name=search_path")
assert response.text == '"\\"v1\\", \\"public\\""' assert response.text == '"\\"v1\\", \\"public\\""'
@@ -332,7 +339,7 @@ def test_db_schema_notify_reload(defaultenv):
"/rpc/change_db_schema_and_full_reload", data={"schemas": "v1"} "/rpc/change_db_schema_and_full_reload", data={"schemas": "v1"}
) )
time.sleep(0.2) sleep_until_postgrest_full_reload()
response = postgrest.session.get("/rpc/get_guc_value?name=search_path") response = postgrest.session.get("/rpc/get_guc_value?name=search_path")
assert response.text == '"\\"v1\\", \\"public\\""' assert response.text == '"\\"v1\\", \\"public\\""'
@@ -360,7 +367,7 @@ def test_max_rows_reload(defaultenv):
# reload config # reload config
postgrest.process.send_signal(signal.SIGUSR2) postgrest.process.send_signal(signal.SIGUSR2)
time.sleep(0.1) sleep_until_postgrest_config_reload()
response = postgrest.session.head("/projects") response = postgrest.session.head("/projects")
assert response.status_code == 200 assert response.status_code == 200
@@ -390,7 +397,7 @@ def test_max_rows_notify_reload(defaultenv):
"/rpc/change_max_rows_config", data={"val": 1, "notify": True} "/rpc/change_max_rows_config", data={"val": 1, "notify": True}
) )
time.sleep(0.1) sleep_until_postgrest_config_reload()
response = postgrest.session.head("/projects") response = postgrest.session.head("/projects")
assert response.status_code == 200 assert response.status_code == 200
@@ -501,7 +508,7 @@ def test_change_statement_timeout(defaultenv, metapostgrest):
# trigger schema refresh # trigger schema refresh
postgrest.process.send_signal(signal.SIGUSR1) postgrest.process.send_signal(signal.SIGUSR1)
time.sleep(0.1) sleep_until_postgrest_scache_reload()
response = postgrest.session.get("/rpc/sleep?seconds=1") response = postgrest.session.get("/rpc/sleep?seconds=1")
assert response.status_code == 500 assert response.status_code == 500
@@ -512,7 +519,7 @@ def test_change_statement_timeout(defaultenv, metapostgrest):
# trigger role setting refresh # trigger role setting refresh
postgrest.process.send_signal(signal.SIGUSR1) postgrest.process.send_signal(signal.SIGUSR1)
time.sleep(0.1) sleep_until_postgrest_scache_reload()
response = postgrest.session.get("/rpc/sleep?seconds=1") response = postgrest.session.get("/rpc/sleep?seconds=1")
assert response.status_code == 204 assert response.status_code == 204
@@ -548,13 +555,15 @@ def test_pool_size(defaultenv, metapostgrest):
assert delta > 1 and delta < 1.5 assert delta > 1 and delta < 1.5
def test_pool_acquisition_timeout(defaultenv, metapostgrest): @pytest.mark.parametrize("level", ["crit", "error", "warn", "info"])
def test_pool_acquisition_timeout(level, defaultenv, metapostgrest):
"Verify that PGRST_DB_POOL_ACQUISITION_TIMEOUT times out when the pool is empty" "Verify that PGRST_DB_POOL_ACQUISITION_TIMEOUT times out when the pool is empty"
env = { env = {
**defaultenv, **defaultenv,
"PGRST_DB_POOL": "1", "PGRST_DB_POOL": "1",
"PGRST_DB_POOL_ACQUISITION_TIMEOUT": "1", # 1 second "PGRST_DB_POOL_ACQUISITION_TIMEOUT": "1", # 1 second
"PGRST_LOG_LEVEL": level,
} }
with run(env=env, no_pool_connection_available=True) as postgrest: with run(env=env, no_pool_connection_available=True) as postgrest:
@@ -565,8 +574,12 @@ def test_pool_acquisition_timeout(defaultenv, metapostgrest):
# ensure the message appears on the logs as well # ensure the message appears on the logs as well
output = sorted(postgrest.read_stdout(nlines=2)) output = sorted(postgrest.read_stdout(nlines=2))
assert " 504 " in output[0]
assert "Timed out acquiring connection from connection pool." in output[1] if level == "crit":
assert len(output) == 0
else:
assert " 504 " in output[0]
assert "Timed out acquiring connection from connection pool." in output[1]
def test_change_statement_timeout_held_connection(defaultenv, metapostgrest): def test_change_statement_timeout_held_connection(defaultenv, metapostgrest):
@@ -663,7 +676,7 @@ def test_admin_ready_includes_schema_cache_state(defaultenv, metapostgrest):
# force a reconnection so the new role setting is picked up # force a reconnection so the new role setting is picked up
postgrest.process.send_signal(signal.SIGUSR1) postgrest.process.send_signal(signal.SIGUSR1)
time.sleep(0.1) sleep_until_postgrest_scache_reload()
response = postgrest.admin.get("/ready") response = postgrest.admin.get("/ready")
assert response.status_code == 503 assert response.status_code == 503
@@ -830,7 +843,7 @@ def test_notify_reloading_catalog_cache(defaultenv):
# change it to a bigint # change it to a bigint
response = postgrest.session.post("/rpc/drop_change_cats") response = postgrest.session.post("/rpc/drop_change_cats")
assert response.status_code == 204 assert response.status_code == 204
time.sleep(0.1) sleep_until_postgrest_scache_reload()
# next request should succeed with a bigint value # next request should succeed with a bigint value
response = postgrest.session.get("/cats?id=eq.1") response = postgrest.session.get("/cats?id=eq.1")
@@ -858,7 +871,7 @@ def test_role_settings(defaultenv):
response = postgrest.session.get("/rpc/reload_pgrst_config") response = postgrest.session.get("/rpc/reload_pgrst_config")
assert response.status_code == 204 assert response.status_code == 204
time.sleep(0.1) sleep_until_postgrest_config_reload()
response = postgrest.session.get("/rpc/get_guc_value?name=statement_timeout") response = postgrest.session.get("/rpc/get_guc_value?name=statement_timeout")
assert response.text == '"5s"' assert response.text == '"5s"'
@@ -1063,3 +1076,302 @@ def test_succeed_w_role_having_superuser_settings(defaultenv):
response = postgrest.session.get("/projects", headers=headers) response = postgrest.session.get("/projects", headers=headers)
print(response.text) print(response.text)
assert response.status_code == 200 assert response.status_code == 200
def test_get_granted_superuser_setting(defaultenv):
"Should succeed when the impersonated role has granted superuser settings"
env = {**defaultenv, "PGRST_DB_CONFIG": "true", "PGRST_JWT_SECRET": SECRET}
with run(stdin=SECRET.encode(), env=env) as postgrest:
response_ver = postgrest.session.get("/rpc/get_postgres_version")
pg_ver = eval(response_ver.text)
if pg_ver >= 150000:
headers = jwtauthheader(
{"role": "postgrest_test_w_superuser_settings"}, SECRET
)
response = postgrest.session.get(
"/rpc/get_guc_value?name=log_min_duration_sample", headers=headers
)
assert response.text == '"12345ms"'
def test_fail_with_invalid_dbname_and_automatic_recovery_disabled(defaultenv):
"Should fail without retries when automatic recovery is disabled and dbname is invalid"
dbname = "INVALID"
uri = f'postgresql://?dbname={dbname}&host={defaultenv["PGHOST"]}&user={defaultenv["PGUSER"]}'
env = {
**defaultenv,
"PGRST_DB_URI": uri,
"PGRST_DB_POOL_AUTOMATIC_RECOVERY": "false",
}
with run(env=env, wait_for_readiness=False) as postgrest:
exitCode = wait_until_exit(postgrest)
assert exitCode == 1
def test_fail_with_automatic_recovery_disabled_and_terminated_using_query(defaultenv):
"Should fail without retries when automatic recovery is disabled and pg_terminate_backend(pid) is called"
env = {
**defaultenv,
"PGRST_DB_POOL_AUTOMATIC_RECOVERY": "false",
}
with run(env=env) as postgrest:
os.system(
f'psql -d {defaultenv["PGDATABASE"]} -U {defaultenv["PGUSER"]} -h {defaultenv["PGHOST"]} --set ON_ERROR_STOP=1 -a -c "SELECT terminate_pgrst()"'
)
exitCode = wait_until_exit(postgrest)
assert exitCode == 1
def test_server_timing_jwt_should_decrease_on_subsequent_requests(defaultenv):
"assert that server-timing duration for JWT should decrease on subsequent requests"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
"PGRST_JWT_SECRET": "@/dev/stdin",
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader(
{
"role": "postgrest_test_author",
"exp": int(
(datetime.now(timezone.utc) + timedelta(minutes=30)).timestamp()
),
},
SECRET,
)
with run(stdin=SECRET.encode(), env=env) as postgrest:
first_timings = postgrest.session.get("/authors_only", headers=headers).headers[
"Server-Timing"
]
second_timings = postgrest.session.get(
"/authors_only", headers=headers
).headers["Server-Timing"]
first_dur = parse_server_timings_header(first_timings)["jwt"]
second_dur = parse_server_timings_header(second_timings)["jwt"]
# their difference should be atleast 300, implying
# that JWT Caching is working as expected
assert (first_dur - second_dur) > 300.0
# just added to complete code coverage
def test_jwt_caching_works_with_db_plan_disabled(defaultenv):
"assert that JWT caching words even when Server-Timing header is not returned"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
"PGRST_JWT_SECRET": "@/dev/stdin",
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(stdin=SECRET.encode(), env=env) as postgrest:
first_request = postgrest.session.get("/authors_only", headers=headers)
second_request = postgrest.session.get("/authors_only", headers=headers)
# in this case we don't get server-timing in response headers
# so we can't compare durations, we just check if request succeeds
assert first_request.status_code == 200 and second_request.status_code == 200
def test_server_timing_jwt_should_not_decrease_when_caching_disabled(defaultenv):
"assert than jwt duration should not decrease when disabled"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_LIFETIME": "0", # cache disabled
"PGRST_JWT_SECRET": "@/dev/stdin",
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(stdin=SECRET.encode(), env=env) as postgrest:
warmup_req = postgrest.session.get("/authors_only", headers=headers)
first_timings = postgrest.session.get("/authors_only", headers=headers).headers[
"Server-Timing"
]
second_timings = postgrest.session.get(
"/authors_only", headers=headers
).headers["Server-Timing"]
first_dur = parse_server_timings_header(first_timings)["jwt"]
second_dur = parse_server_timings_header(second_timings)["jwt"]
# their difference should be less than 150
# implying that token is not cached
assert (first_dur - second_dur) < 150.0
def test_jwt_cache_with_no_exp_claim(defaultenv):
"assert than jwt duration should decrease"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
"PGRST_JWT_SECRET": "@/dev/stdin",
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) # no exp
with run(stdin=SECRET.encode(), env=env) as postgrest:
first_timings = postgrest.session.get("/authors_only", headers=headers).headers[
"Server-Timing"
]
second_timings = postgrest.session.get(
"/authors_only", headers=headers
).headers["Server-Timing"]
first_dur = parse_server_timings_header(first_timings)["jwt"]
second_dur = parse_server_timings_header(second_timings)["jwt"]
# their difference should be atleast 300, implying
# that JWT Caching is working as expected
assert (first_dur - second_dur) > 300.0
def test_preflight_request_with_cors_allowed_origin_config(defaultenv):
"OPTIONS preflight request should return Access-Control-Allow-Origin equal to origin"
env = {
**defaultenv,
"PGRST_SERVER_CORS_ALLOWED_ORIGINS": "http://example.com, http://example2.com",
}
headers = {
"Accept": "*/*",
"Origin": "http://example.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Content-Type",
}
with run(env=env) as postgrest:
response = postgrest.session.options("/items", headers=headers)
assert (
response.headers["Access-Control-Allow-Origin"] == "http://example.com"
and response.headers["Access-Control-Allow-Credentials"] == "true"
)
def test_preflight_request_with_empty_cors_allowed_origin_config(defaultenv):
"OPTIONS preflight request should allow all origins when config is present but empty"
env = {
**defaultenv,
"PGRST_SERVER_CORS_ALLOWED_ORIGINS": "",
}
headers = {
"Accept": "*/*",
"Origin": "http://anyorigin.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Content-Type",
}
with run(env=env) as postgrest:
response = postgrest.session.options("/items", headers=headers)
assert response.headers["Access-Control-Allow-Origin"] == "*"
assert "POST" in response.headers["Access-Control-Allow-Methods"]
def test_no_preflight_request_with_CORS_config_should_return_header(defaultenv):
"GET no preflight request should return Access-Control-Allow-Origin equal to origin"
env = {
**defaultenv,
"PGRST_SERVER_CORS_ALLOWED_ORIGINS": "http://example.com, http://example2.com",
}
headers = {
"Accept": "*/*",
"Origin": "http://example.com",
}
with run(env=env) as postgrest:
response = postgrest.session.get("/items", headers=headers)
assert response.headers["Access-Control-Allow-Origin"] == "http://example.com"
def test_no_preflight_request_with_CORS_config_should_not_return_header(defaultenv):
"GET no preflight request should not return Access-Control-Allow-Origin"
env = {
**defaultenv,
"PGRST_SERVER_CORS_ALLOWED_ORIGINS": "http://example.com, http://example2.com",
}
headers = {
"Accept": "*/*",
"Origin": "http://invalid.com",
}
with run(env=env) as postgrest:
response = postgrest.session.get("/items", headers=headers)
assert "Access-Control-Allow-Origin" not in response.headers
def test_fail_with_3_sec_statement_and_1_sec_statement_timeout(defaultenv):
"statement that takes three seconds to execute should fail with one second timeout"
with run(env=defaultenv) as postgrest:
response = postgrest.session.post("/rpc/one_sec_timeout")
assert response.status_code == 500
assert (
response.text
== '{"code":"57014","details":null,"hint":null,"message":"canceling statement due to statement timeout"}'
)
def test_passes_with_3_sec_statement_and_4_sec_statement_timeout(defaultenv):
"statement that takes three seconds to execute should succeed with four second timeout"
with run(env=defaultenv) as postgrest:
response = postgrest.session.post("/rpc/four_sec_timeout")
assert response.status_code == 204
@pytest.mark.parametrize("level", ["crit", "error", "warn", "info"])
def test_db_error_logging_to_stderr(level, defaultenv, metapostgrest):
"verify that DB errors are logged to stderr"
role = "timeout_authenticator"
set_statement_timeout(metapostgrest, role, 500)
env = {
**defaultenv,
"PGUSER": role,
"PGRST_DB_ANON_ROLE": role,
"PGRST_LOG_LEVEL": level,
}
with run(env=env) as postgrest:
response = postgrest.session.get("/rpc/sleep?seconds=1")
assert response.status_code == 500
# ensure the message appears on the logs
output = sorted(postgrest.read_stdout(nlines=2))
if level == "crit":
assert len(output) == 0
else:
assert " 500 " in output[0]
assert "canceling statement due to statement timeout" in output[1]
+17
View File
@@ -40,3 +40,20 @@ def authheader(token):
def jwtauthheader(claim, secret): def jwtauthheader(claim, secret):
"Authorization header with signed JWT." "Authorization header with signed JWT."
return authheader(jwt.encode(claim, secret)) return authheader(jwt.encode(claim, secret))
def parse_server_timings_header(header):
"""Parse the Server-Timing header into a dict of metric names to values.
The header is a comma-separated list of metrics, each of which has a name
and a duration. The duration may be followed by a semicolon and a list of
parameters, but we ignore those.
See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing
"""
timings = {}
for timing in header.split(","):
name, duration_text, *_ = timing.split(";")
_, duration = duration_text.split("=")
timings[name] = float(duration)
return timings
+3 -3
View File
@@ -102,7 +102,7 @@ postJsonArrayTest(){
echo "Running memory usage tests.." echo "Running memory usage tests.."
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "16M" jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "27M"
jsonKeyTest "1M" "POST" "/leak?columns=blob" "16M" jsonKeyTest "1M" "POST" "/leak?columns=blob" "16M"
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "16M" jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "16M"
@@ -114,8 +114,8 @@ jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "172M"
jsonKeyTest "50M" "POST" "/leak?columns=blob" "172M" jsonKeyTest "50M" "POST" "/leak?columns=blob" "172M"
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "172M" jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "172M"
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "14M" postJsonArrayTest "1000" "/perf_articles?columns=id,body" "15M"
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "14M" postJsonArrayTest "10000" "/perf_articles?columns=id,body" "15M"
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "24M" postJsonArrayTest "100000" "/perf_articles?columns=id,body" "24M"
trap - int term exit trap - int term exit
+1 -2
View File
@@ -20,8 +20,7 @@ spec =
"" ""
`shouldRespondWith` `shouldRespondWith`
"" ""
{ matchHeaders = [ "Access-Control-Allow-Origin" <:> "http://example.com" { matchHeaders = [ "Access-Control-Allow-Origin" <:> "*"
, "Access-Control-Allow-Credentials" <:> "true"
, "Access-Control-Allow-Methods" <:> "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD" , "Access-Control-Allow-Methods" <:> "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"
, "Access-Control-Allow-Headers" <:> "Authorization, Foo, Bar, Accept, Accept-Language, Content-Language" , "Access-Control-Allow-Headers" <:> "Authorization, Foo, Bar, Accept, Accept-Language, Content-Language"
, "Access-Control-Max-Age" <:> "86400" ] , "Access-Control-Max-Age" <:> "86400" ]
-68
View File
@@ -1,68 +0,0 @@
module Feature.LegacyGucsSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "remote procedure call with legacy gucs disabled" $ do
it "custom header is set" $
request methodPost "/rpc/get_guc_value" [("Custom-Header", "test")]
[json| { "prefix": "request.headers", "name": "custom-header" } |]
`shouldRespondWith`
[json|"test"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "standard header is set" $
request methodPost "/rpc/get_guc_value" [("Origin", "http://example.com")]
[json| { "prefix": "request.headers", "name": "origin" } |]
`shouldRespondWith`
[json|"http://example.com"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "current role is available as GUC claim" $
request methodPost "/rpc/get_guc_value" []
[json| { "prefix": "request.jwt.claims", "name": "role" } |]
`shouldRespondWith`
[json|"postgrest_test_anonymous"|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson ]
}
it "single cookie ends up as claims" $
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
[json| {"prefix": "request.cookies", "name":"acookie"} |]
`shouldRespondWith`
[json|"cookievalue"|]
{ matchStatus = 200
, matchHeaders = []
}
it "multiple cookies ends up as claims" $
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
[json| {"prefix": "request.cookies", "name":"secondcookie"} |]
`shouldRespondWith`
[json|"anothervalue"|]
{ matchStatus = 200
, matchHeaders = []
}
it "gets the Authorization value" $
request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
[json| {"prefix": "request.headers", "name":"authorization"} |]
`shouldRespondWith`
[json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
{ matchStatus = 200
, matchHeaders = []
}
+5
View File
@@ -35,6 +35,11 @@ spec actualPgVersion = describe "OpenAPI" $ do
(acceptHdrs "application/openapi+json") "" (acceptHdrs "application/openapi+json") ""
`shouldRespondWith` 415 `shouldRespondWith` 415
it "should respond to openapi request with unsupported media type with 415" $
request methodGet "/"
(acceptHdrs "text/csv") ""
`shouldRespondWith` 415
it "includes postgrest.org current version api docs" $ do it "includes postgrest.org current version api docs" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
+11 -2
View File
@@ -3,7 +3,7 @@ module Feature.OpenApi.RootSpec where
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai (Application) import Network.Wai (Application)
import Test.Hspec import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
@@ -12,7 +12,7 @@ import Protolude hiding (get)
spec :: SpecWith ((), Application) spec :: SpecWith ((), Application)
spec = spec =
describe "root spec function" $ do describe "root spec function" $ do
it "accepts application/openapi+json" $ it "accepts application/openapi+json" $ do
request methodGet "/" request methodGet "/"
[("Accept","application/openapi+json")] "" `shouldRespondWith` [("Accept","application/openapi+json")] "" `shouldRespondWith`
[json|{ [json|{
@@ -20,3 +20,12 @@ spec =
"info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"} "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}
}|] }|]
{ matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] } { matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] }
it "accepts application/json" $ do
request methodGet "/"
[("Accept","application/json")] "" `shouldRespondWith`
[json|{
"swagger": "2.0",
"info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}
}|]
{ matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] }
@@ -0,0 +1,168 @@
module Feature.Query.AggregateFunctionsSpec where
import Network.Wai (Application)
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
allowed :: SpecWith ((), Application)
allowed =
describe "aggregate functions" $ do
context "performing a count without specifying a field" $ do
it "returns the count of all rows when no other fields are selected" $
get "/entities?select=count()" `shouldRespondWith`
[json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "allows you to specify an alias for the count" $
get "/entities?select=cnt:count()" `shouldRespondWith`
[json|[{ "cnt": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "allows you to cast the result of the count" $
get "/entities?select=count()::text" `shouldRespondWith`
[json|[{ "count": "4" }]|] { matchHeaders = [matchContentTypeJson] }
it "returns the count grouped by all provided fields when other fields are selected" $
get "/projects?select=c:count(),client_id&order=client_id.desc" `shouldRespondWith`
[json|[{ "c": 1, "client_id": null }, { "c": 2, "client_id": 2 }, { "c": 2, "client_id": 1}]|] { matchHeaders = [matchContentTypeJson] }
context "performing a count by using it as a column (backwards compat)" $ do
it "returns the count of all rows when no other fields are selected" $
get "/entities?select=count" `shouldRespondWith`
[json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "returns the embedded count of another resource" $
get "/clients?select=name,projects(count)'" `shouldRespondWith`
[json|[{"name":"Microsoft","projects":[{"count": 2}]}, {"name":"Apple","projects":[{"count": 2}]}]|] { matchHeaders = [matchContentTypeJson] }
context "performing an aggregation on one or more fields" $ do
it "supports sum()" $
get "/project_invoices?select=invoice_total.sum()" `shouldRespondWith`
[json|[{"sum":8800}]|] { matchHeaders = [matchContentTypeJson] }
it "supports avg()" $
get "/project_invoices?select=invoice_total.avg()" `shouldRespondWith`
[json|[{"avg":1100.0000000000000000}]|] { matchHeaders = [matchContentTypeJson] }
it "supports min()" $
get "/project_invoices?select=invoice_total.min()" `shouldRespondWith`
[json|[{ "min": 100 }]|] { matchHeaders = [matchContentTypeJson] }
it "supports max()" $
get "/project_invoices?select=invoice_total.max()" `shouldRespondWith`
[json|[{ "max": 4000 }]|] { matchHeaders = [matchContentTypeJson] }
it "supports count()" $
get "/project_invoices?select=invoice_total.count()" `shouldRespondWith`
[json|[{ "count": 8 }]|] { matchHeaders = [matchContentTypeJson] }
it "groups by any fields selected that do not have an aggregate applied" $
get "/project_invoices?select=invoice_total.sum(),invoice_total.max(),invoice_total.min(),project_id&order=project_id.desc" `shouldRespondWith`
[json|[
{"sum":4100,"max":4000,"min":100,"project_id":4},
{"sum":3200,"max":2000,"min":1200,"project_id":3},
{"sum":1200,"max":700,"min":500,"project_id":2},
{"sum":300,"max":200,"min":100,"project_id":1} ]|]
{ matchHeaders = [matchContentTypeJson] }
it "supports the use of aliases on fields that will be used in the group by" $
get "/project_invoices?select=invoice_total.sum(),invoice_total.max(),invoice_total.min(),pid:project_id&order=project_id.desc" `shouldRespondWith`
[json|[
{"sum":4100,"max":4000,"min":100,"pid":4},
{"sum":3200,"max":2000,"min":1200,"pid":3},
{"sum":1200,"max":700,"min":500,"pid":2},
{"sum":300,"max":200,"min":100,"pid":1}]|]
{ matchHeaders = [matchContentTypeJson] }
it "allows you to specify an alias for the aggregate" $
get "/project_invoices?select=total_charged:invoice_total.sum(),project_id&order=project_id.desc" `shouldRespondWith`
[json|[
{"total_charged":4100,"project_id":4},
{"total_charged":3200,"project_id":3},
{"total_charged":1200,"project_id":2},
{"total_charged":300,"project_id":1}]|] { matchHeaders = [matchContentTypeJson] }
it "allows you to cast the result of the aggregate" $
get "/project_invoices?select=total_charged:invoice_total.sum()::text,project_id&order=project_id.desc" `shouldRespondWith`
[json|[
{"total_charged":"4100","project_id":4},
{"total_charged":"3200","project_id":3},
{"total_charged":"1200","project_id":2},
{"total_charged":"300","project_id":1}]|] { matchHeaders = [matchContentTypeJson] }
it "allows you to cast the input argument of the aggregate" $
get "/trash_details?select=jsonb_col->>key::integer.sum()" `shouldRespondWith`
[json|[{"sum": 24}]|] { matchHeaders = [matchContentTypeJson] }
it "allows the combination of an alias, a before cast, and an after cast" $
get "/trash_details?select=s:jsonb_col->>key::integer.sum()::text" `shouldRespondWith`
[json|[{"s": "24"}]|] { matchHeaders = [matchContentTypeJson] }
it "supports use of aggregates on RPC functions that return table values" $
get "/rpc/getallprojects?select=id.max()" `shouldRespondWith`
[json|[{"max": 5}]|] { matchHeaders = [matchContentTypeJson] }
it "allows the use of an JSON-embedded relationship column as part of the group by" $
get "/project_invoices?select=project_id,total:invoice_total.sum(),projects(name)&order=project_id" `shouldRespondWith`
[json|[
{"project_id": 1, "total": 300, "projects": {"name": "Windows 7"}},
{"project_id": 2, "total": 1200, "projects": {"name": "Windows 10"}},
{"project_id": 3, "total": 3200, "projects": {"name": "IOS"}},
{"project_id": 4, "total": 4100, "projects": {"name": "OSX"}}]|] { matchHeaders = [matchContentTypeJson] }
context "performing aggregations that involve JSON-embedded relationships" $ do
it "supports sum()" $
get "/projects?select=name,project_invoices(invoice_total.sum())" `shouldRespondWith`
[json|[
{"name":"Windows 7","project_invoices":[{"sum": 300}]},
{"name":"Windows 10","project_invoices":[{"sum": 1200}]},
{"name":"IOS","project_invoices":[{"sum": 3200}]},
{"name":"OSX","project_invoices":[{"sum": 4100}]},
{"name":"Orphan","project_invoices":[{"sum": null}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "supports max()" $
get "/projects?select=name,project_invoices(invoice_total.max())" `shouldRespondWith`
[json|[{"name":"Windows 7","project_invoices":[{"max": 200}]},
{"name":"Windows 10","project_invoices":[{"max": 700}]},
{"name":"IOS","project_invoices":[{"max": 2000}]},
{"name":"OSX","project_invoices":[{"max": 4000}]},
{"name":"Orphan","project_invoices":[{"max": null}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "supports avg()" $
get "/projects?select=name,project_invoices(invoice_total.avg())" `shouldRespondWith`
[json|[{"name":"Windows 7","project_invoices":[{"avg": 150.0000000000000000}]},
{"name":"Windows 10","project_invoices":[{"avg": 600.0000000000000000}]},
{"name":"IOS","project_invoices":[{"avg": 1600.0000000000000000}]},
{"name":"OSX","project_invoices":[{"avg": 2050.0000000000000000}]},
{"name":"Orphan","project_invoices":[{"avg": null}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "supports min()" $
get "/projects?select=name,project_invoices(invoice_total.min())" `shouldRespondWith`
[json|[{"name":"Windows 7","project_invoices":[{"min": 100}]},
{"name":"Windows 10","project_invoices":[{"min": 500}]},
{"name":"IOS","project_invoices":[{"min": 1200}]},
{"name":"OSX","project_invoices":[{"min": 100}]},
{"name":"Orphan","project_invoices":[{"min": null}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "supports all at once" $
get "/projects?select=name,project_invoices(invoice_total.max(),invoice_total.min(),invoice_total.avg(),invoice_total.sum(),invoice_total.count())" `shouldRespondWith`
[json|[
{"name":"Windows 7","project_invoices":[{"avg": 150.0000000000000000, "max": 200, "min": 100, "sum": 300, "count": 2}]},
{"name":"Windows 10","project_invoices":[{"avg": 600.0000000000000000, "max": 700, "min": 500, "sum": 1200, "count": 2}]},
{"name":"IOS","project_invoices":[{"avg": 1600.0000000000000000, "max": 2000, "min": 1200, "sum": 3200, "count": 2}]},
{"name":"OSX","project_invoices":[{"avg": 2050.0000000000000000, "max": 4000, "min": 100, "sum": 4100, "count": 2}]},
{"name":"Orphan","project_invoices":[{"avg": null, "max": null, "min": null, "sum": null, "count": 0}]}]|]
{ matchHeaders = [matchContentTypeJson] }
context "performing aggregations on spreaded fields from an embedded resource" $ do
it "supports the use of aggregates on spreaded fields" $ do
get "/budget_expenses?select=total_expenses:expense_amount.sum(),...budget_categories(budget_owner,total_budget:budget_amount.sum())&order=budget_categories(budget_owner)" `shouldRespondWith`
[json|[
{"total_expenses": 600.52,"budget_owner": "Brian Smith", "total_budget": 2000.42},
{"total_expenses": 100.22, "budget_owner": "Jane Clarkson","total_budget": 7000.41},
{"total_expenses": 900.27, "budget_owner": "Sally Hughes", "total_budget": 500.23}]|]
{ matchHeaders = [matchContentTypeJson] }
it "supports the use of aggregates on spreaded fields when only aggregates are supplied" $ do
get "/budget_expenses?select=...budget_categories(total_budget:budget_amount.sum())" `shouldRespondWith`
[json|[{"total_budget": 9501.06}]|]
{ matchHeaders = [matchContentTypeJson] }
disallowed :: SpecWith ((), Application)
disallowed =
describe "attempting to use an aggregate when aggregate functions are disallowed" $ do
it "prevents the use of aggregates" $
get "/project_invoices?select=invoice_total.sum()" `shouldRespondWith`
[json|{
"hint":null,
"details":null,
"code":"PGRST123",
"message":"Use of aggregate functions is not allowed"
}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson] }
@@ -192,3 +192,29 @@ spec = describe "computed relationships" $ do
{"name":"Windows 10","computed_clients":{"name":"Microsoft"}} {"name":"Windows 10","computed_clients":{"name":"Microsoft"}}
]} ]}
]|] { matchHeaders = [matchContentTypeJson] } ]|] { matchHeaders = [matchContentTypeJson] }
-- https://github.com/PostgREST/postgrest/issues/2963
context "can be defined using overloaded functions" $ do
it "tables" $ do
get "/items?select=*,computed_rel_overload(*)&limit=1"
`shouldRespondWith`
[json|
[{"id":1,"computed_rel_overload":[{"id":1}]}]
|] { matchHeaders = [matchContentTypeJson] }
get "/items2?select=*,computed_rel_overload(*)&limit=1"
`shouldRespondWith`
[json|
[{"id":1,"computed_rel_overload":[{"id":1},{"id":2}]}]
|] { matchHeaders = [matchContentTypeJson] }
it "rpc" $ do
get "/rpc/search?id=1&select=*,computed_rel_overload(*)"
`shouldRespondWith`
[json|
[{"id":1,"computed_rel_overload":[{"id":1}]}]
|] { matchHeaders = [matchContentTypeJson] }
get "/rpc/search2?id=1&select=*,computed_rel_overload(*)"
`shouldRespondWith`
[json|
[{"id":1,"computed_rel_overload":[{"id":1},{"id":2}]}]
|] { matchHeaders = [matchContentTypeJson] }
+307
View File
@@ -0,0 +1,307 @@
module Feature.Query.CustomMediaSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc (str)
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec = describe "custom media types" $ do
context "for tables with aggregate" $ do
it "can query if there's an aggregate defined for the table" $ do
r <- request methodGet "/lines" (acceptHdrs "application/vnd.twkb") ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "lines.twkb"
simpleHeaders r `shouldContain` [("Content-Type", "application/vnd.twkb")]
it "can query by id if there's an aggregate defined for the table" $ do
r <- request methodGet "/lines?id=eq.1" (acceptHdrs "application/vnd.twkb") ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "1.twkb"
simpleHeaders r `shouldContain` [("Content-Type", "application/vnd.twkb")]
it "will fail if there's no aggregate defined for the table" $ do
request methodGet "/lines" (acceptHdrs "text/plain") ""
`shouldRespondWith`
[json| {"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: text/plain"} |]
{ matchStatus = 415
, matchHeaders = [matchContentTypeJson]
}
it "can get raw xml output with Accept: text/xml if there's an aggregate defined" $ do
request methodGet "/xmltest" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<myxml>foo</myxml>bar<foobar><baz/></foobar>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
-- TODO SOH (start of heading) is being added to results
context "for tables with anyelement aggregate" $ do
it "will use the application/vnd.geo2+json media type for any table" $
request methodGet "/lines" (acceptHdrs "application/vnd.geo2+json") ""
`shouldRespondWith`
"\SOH{\"type\": \"FeatureCollection\", \"hello\": \"world\"}"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
}
it "will use the more specific application/vnd.geo2 handler for this table" $ do
request methodGet "/shop_bles" (acceptHdrs "application/vnd.geo2+json") ""
`shouldRespondWith`
"\SOH\"anyelement overridden\""
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
}
request methodGet "/rpc/get_shop_bles" (acceptHdrs "application/vnd.geo2+json") ""
`shouldRespondWith`
"\SOH\"anyelement overridden\""
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
}
context "Proc that returns scalar" $ do
it "can get raw output with Accept: text/html" $ do
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
`shouldRespondWith`
[str|
|<html>
| <head>
| <title>PostgREST</title>
| </head>
| <body>
| <h1>Welcome to PostgREST</h1>
| </body>
|</html>
|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/html"]
}
it "can get raw output with Accept: text/plain" $ do
request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
`shouldRespondWith` "Welcome to PostgREST"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $ do
request methodGet "/rpc/return_scalar_xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<my-xml-tag/>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $ do
request methodGet "/rpc/welcome.xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<html>\n <head>\n <title>PostgREST</title>\n </head>\n <body>\n <h1>Welcome to PostgREST</h1>\n </body>\n</html>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "should fail with function returning text and Accept: text/xml" $ do
request methodGet "/rpc/welcome" (acceptHdrs "text/xml") ""
`shouldRespondWith`
[json|
{"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: text/xml"}
|]
{ matchStatus = 415
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
it "should not fail when the function doesn't return a row" $ do
request methodGet "/rpc/get_line?id=777" (acceptHdrs "application/vnd.twkb") ""
`shouldRespondWith` ""
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.twkb"]
}
context "Proc that returns scalar based on a table" $ do
it "can get an image with Accept: image/png" $ do
r <- request methodGet "/rpc/ret_image" (acceptHdrs "image/png") ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "A.png"
simpleHeaders r `shouldContain` [("Content-Type", "image/png")]
context "Proc that returns set of scalars and Accept: text/plain" $
it "will err because only scalars work with media type domains" $ do
request methodGet "/rpc/welcome_twice"
(acceptHdrs "text/plain")
""
`shouldRespondWith`
[json|{"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: text/plain"}|]
{ matchStatus = 415
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
context "Proc that returns rows and accepts custom media type" $ do
it "works if it has an aggregate defined" $ do
r <- request methodGet "/rpc/get_lines" [("Accept", "application/vnd.twkb")] ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "lines.twkb"
simpleHeaders r `shouldContain` [("Content-Type", "application/vnd.twkb")]
it "fails if doesn't have an aggregate defined" $ do
request methodGet "/rpc/get_lines"
(acceptHdrs "application/octet-stream") ""
`shouldRespondWith`
[json| {"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: application/octet-stream"} |]
{ matchStatus = 415 }
-- TODO SOH (start of heading) is being added to results
it "works if there's an anyelement aggregate defined" $ do
request methodGet "/rpc/get_lines" (acceptHdrs "application/vnd.geo2+json") ""
`shouldRespondWith`
"\SOH{\"type\": \"FeatureCollection\", \"hello\": \"world\"}"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
}
context "overriding" $ do
it "will override the application/json handler for a single table" $
request methodGet "/ov_json" (acceptHdrs "application/json") ""
`shouldRespondWith`
[json| {"overridden": "true"} |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
-- TODO SOH (start of heading) is being added to results
it "will override the application/geo+json handler for a single table" $
request methodGet "/lines?id=eq.1" (acceptHdrs "application/geo+json") ""
`shouldRespondWith`
"\SOH{\"crs\": {\"type\": \"name\", \"properties\": {\"name\": \"EPSG:4326\"}}, \"type\": \"FeatureCollection\", \"features\": [{\"type\": \"Feature\", \"geometry\": {\"type\": \"LineString\", \"coordinates\": [[1, 1], [5, 5]]}, \"properties\": {\"id\": 1, \"name\": \"line-1\"}}]}"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/geo+json; charset=utf-8"]
}
it "will not override vendored media types like application/vnd.pgrst.object" $
request methodGet "/projects?id=eq.1" (acceptHdrs "application/vnd.pgrst.object") ""
`shouldRespondWith`
[json|{"id":1,"name":"Windows 7","client_id":1}|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
context "matches requested media type correctly" $ do
-- https://github.com/PostgREST/postgrest/issues/1462
it "will match image/png according to q values" $ do
r1 <- request methodGet "/rpc/ret_image" (acceptHdrs "image/png, */*") ""
liftIO $ do
simpleBody r1 `shouldBe` readFixtureFile "A.png"
simpleHeaders r1 `shouldContain` [("Content-Type", "image/png")]
r2 <- request methodGet "/rpc/ret_image" (acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/png,*/*;q=0.8") ""
liftIO $ do
simpleBody r2 `shouldBe` readFixtureFile "A.png"
simpleHeaders r2 `shouldContain` [("Content-Type", "image/png")]
-- https://github.com/PostgREST/postgrest/issues/2170
it "will match json in presence of text/plain" $ do
r <- request methodGet "/projects?id=eq.1" (acceptHdrs "text/plain, application/json") ""
liftIO $ do
simpleStatus r `shouldBe` status200
simpleHeaders r `shouldContain` [("Content-Type", "application/json; charset=utf-8")]
-- https://github.com/PostgREST/postgrest/issues/1102
it "will match a custom text/tab-separated-values" $ do
request methodGet "/projects?id=in.(1,2)" (acceptHdrs "text/tab-separated-values") ""
`shouldRespondWith`
"id\tname\tclient_id\n1\tWindows 7\t1\n2\tWindows 10\t1\n"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/tab-separated-values"]
}
-- https://github.com/PostgREST/postgrest/issues/1371#issuecomment-519248984
it "will match a custom text/csv with BOM" $ do
r <- request methodGet "/lines" (acceptHdrs "text/csv") ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "lines.csv"
simpleHeaders r `shouldContain` [("Content-Type", "text/csv; charset=utf-8")]
simpleHeaders r `shouldContain` [("Content-Disposition", "attachment; filename=\"lines.csv\"")]
context "any media type" $ do
context "on functions" $ do
it "returns application/json for */* if not explicitly set" $ do
request methodGet "/rpc/ret_any_mt" (acceptHdrs "*/*") ""
`shouldRespondWith` "any"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
it "accepts any media type and sets the generic octet-stream as content type" $ do
request methodGet "/rpc/ret_any_mt" (acceptHdrs "app/bingo") ""
`shouldRespondWith` "any"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
request methodGet "/rpc/ret_any_mt" (acceptHdrs "text/bango") ""
`shouldRespondWith` "any"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
request methodGet "/rpc/ret_any_mt" (acceptHdrs "image/boingo") ""
`shouldRespondWith` "any"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
it "returns custom media type for */* if explicitly set" $ do
request methodGet "/rpc/ret_some_mt" (acceptHdrs "*/*") ""
`shouldRespondWith` "groucho"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "app/groucho"]
}
it "accepts some media types if there's conditional logic" $ do
request methodGet "/rpc/ret_some_mt" (acceptHdrs "app/chico") ""
`shouldRespondWith` "chico"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "app/chico"]
}
request methodGet "/rpc/ret_some_mt" (acceptHdrs "app/harpo") ""
`shouldRespondWith` "harpo"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "app/harpo"]
}
request methodGet "/rpc/ret_some_mt" (acceptHdrs "text/csv") ""
`shouldRespondWith` 415
context "on tables" $ do
it "returns application/json for */* if not explicitly set" $ do
request methodGet "/some_numbers?val=eq.1" (acceptHdrs "*/*") ""
`shouldRespondWith` "anything\n1"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
it "accepts any media type and sets it as a header" $ do
request methodGet "/some_numbers?val=eq.2" (acceptHdrs "magic/number") ""
`shouldRespondWith` "magic\n2"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "magic/number"]
}
request methodGet "/some_numbers?val=eq.3" (acceptHdrs "crazy/bingo") ""
`shouldRespondWith` "crazy\n3"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "crazy/bingo"]
}
request methodGet "/some_numbers?val=eq.4" (acceptHdrs "unknown/unknown") ""
`shouldRespondWith` "anything\n4"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
+1 -1
View File
@@ -38,7 +38,7 @@ spec =
`shouldRespondWith` [json|[{"id":2}]|] `shouldRespondWith` [json|[{"id":2}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/1" , matchHeaders = ["Content-Range" <:> "*/1"
, "Preference-Applied" <:> "return=representation"] , "Preference-Applied" <:> "return=representation, count=exact"]
} }
it "ignores ?select= when return not set or return=minimal" $ do it "ignores ?select= when return not set or return=minimal" $ do
@@ -1,30 +0,0 @@
module Feature.Query.HtmlRawOutputSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Text.Heredoc
import Protolude hiding (get)
import SpecHelper (acceptHdrs)
spec :: SpecWith ((), Application)
spec = describe "When raw-media-types is set to \"text/html\"" $
it "can get raw output with Accept: text/html" $
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
`shouldRespondWith`
[str|
|<html>
| <head>
| <title>PostgREST</title>
| </head>
| <body>
| <h1>Welcome to PostgREST</h1>
| </body>
|</html>
|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/html"]
}
+3 -3
View File
@@ -102,7 +102,7 @@ spec actualPgVersion = do
, matchHeaders = [ matchContentTypeJson , matchHeaders = [ matchContentTypeJson
, matchHeaderAbsent hLocation , matchHeaderAbsent hLocation
, "Content-Range" <:> "*/1" , "Content-Range" <:> "*/1"
, "Preference-Applied" <:> "return=representation"] , "Preference-Applied" <:> "return=representation, count=exact"]
} }
it "can rename and cast the selected columns" $ it "can rename and cast the selected columns" $
@@ -287,7 +287,7 @@ spec actualPgVersion = do
it "fails with 400 and error" $ it "fails with 400 and error" $
post "/simple_pk" "}{ x = 2" post "/simple_pk" "}{ x = 2"
`shouldRespondWith` `shouldRespondWith`
[json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'","code":"PGRST102","details":null,"hint":null}|] [json|{"message":"Empty or invalid json","code":"PGRST102","details":null,"hint":null}|]
{ matchStatus = 400 { matchStatus = 400
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
@@ -296,7 +296,7 @@ spec actualPgVersion = do
it "fails with 400 and error" $ it "fails with 400 and error" $
post "/simple_pk" "" post "/simple_pk" ""
`shouldRespondWith` `shouldRespondWith`
[json|{"message":"Error in $: not enough input","code":"PGRST102","details":null,"hint":null}|] [json|{"message":"Empty or invalid json","code":"PGRST102","details":null,"hint":null}|]
{ matchStatus = 400 { matchStatus = 400
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
@@ -56,6 +56,21 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now [json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "accepts non reserved special characters in the key's name" $
get "/json_arr?id=eq.10&select=data->!@#$%^%26*_d->>!@#$%^%26*_e::integer" `shouldRespondWith`
[json| [{"!@#$%^&*_e":3}] |]
{ matchHeaders = [matchContentTypeJson] }
it "fails when there is a reserved special character in the key's name" $
get "/json_arr?id=eq.10&select=data->(!@#$%^%26*_d->>!@#$%^%26*_e::integer" `shouldRespondWith`
[json| {
"code":"PGRST100",
"details":"unexpected \"(\" expecting \"-\", digit or any non reserved character different from: .,>()",
"hint":null,
"message":"\"failed to parse select parameter (data->(!@#$%^&*_d->>!@#$%^&*_e::integer)\" (line 1, column 7)"}
|]
{ matchStatus = 400 , matchHeaders = [] }
-- TODO the status code for the error is 404, this is because 42883 represents undefined function -- 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 -- 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 it "fails when a double arrow ->> is followed with a single arrow ->" $ do
@@ -178,6 +193,11 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith` 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] } [json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
it "can filter when the key's name has non reserved special characters" $
get "/json_arr?select=data->!@#$%^%26*_d&data->!@#$%^%26*_d->>!@#$%^%26*_e=eq.3" `shouldRespondWith`
[json| [{"!@#$%^&*_d": {"!@#$%^&*_e": 3}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter by array indexes" $ do it "can filter by array indexes" $ do
get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith` get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`
[json| [{"data":[1, 2, 3]}] |] [json| [{"data":[1, 2, 3]}] |]
@@ -300,3 +320,11 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
"code": "PGRST100", "code": "PGRST100",
"hint": null} |] "hint": null} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] } { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
it "works when an RPC returns a dynamic TABLE with a composite type" $
get "/rpc/returns_complex?select=val->r&val->i=gt.0.5&order=val->>i.desc" `shouldRespondWith`
[json|[
{"r":0.3},
{"r":0.2}
]|]
{ matchStatus = 200, matchHeaders = [matchContentTypeJson] }
+32 -6
View File
@@ -150,6 +150,20 @@ spec =
matchStatus = 406 matchStatus = 406
} }
it "succeeds in calling handler with a domain on another schema" $
request methodGet "/another_table" [("Accept-Profile", "v2"), (hAccept, "text/plain")] ""
`shouldRespondWith` "plain"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8", "Content-Profile" <:> "v2"]
}
it "succeeds in calling handler with a domain on an exposed schema" $
request methodGet "/another_table" [("Accept-Profile", "v2"), (hAccept, "text/special")] ""
`shouldRespondWith` "special"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/special", "Content-Profile" <:> "v2"]
}
context "calling procs on different schemas" $ do context "calling procs on different schemas" $ do
it "succeeds in calling the default schema proc" $ it "succeeds in calling the default schema proc" $
request methodGet "/rpc/get_parents_below?id=6" [] "" request methodGet "/rpc/get_parents_below?id=6" [] ""
@@ -194,6 +208,20 @@ spec =
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"] , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
} }
it "succeeds in calling handler with a domain on another schema" $
request methodGet "/rpc/get_plain_text" [("Accept-Profile", "v2"), (hAccept, "text/plain")] ""
`shouldRespondWith` "plain"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8", "Content-Profile" <:> "v2"]
}
it "succeeds in calling handler with a domain on an exposed schema" $
request methodGet "/rpc/get_special_text" [("Accept-Profile", "v2"), (hAccept, "text/special")] ""
`shouldRespondWith` "special"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/special", "Content-Profile" <:> "v2"]
}
context "Modifying tables on different schemas" $ do context "Modifying tables on different schemas" $ do
it "succeeds in patching on the v1 schema and returning its parent" $ it "succeeds in patching on the v1 schema and returning its parent" $
request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")] request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]
@@ -225,13 +253,11 @@ spec =
it "succeeds on PUT on the v2 schema" $ it "succeeds on PUT on the v2 schema" $
request methodPut "/children?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")] request methodPut "/children?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
[json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|] [json|[{"id": 111, "name": "child v2-111", "parent_id": null}]|]
`shouldRespondWith` `shouldRespondWith`
[json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|] [json|[{"id": 111, "name": "child v2-111", "parent_id": null}]|]
{ { matchStatus = 201
matchStatus = 200 , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]}
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
}
context "OpenAPI output" $ do context "OpenAPI output" $ do
it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do
@@ -1,4 +1,4 @@
module Feature.Query.NullsStrip where module Feature.Query.NullsStripSpec where
import Network.Wai (Application) import Network.Wai (Application)
@@ -45,6 +45,16 @@ spec =
, matchHeaders = [matchCTArrayStrip] , matchHeaders = [matchCTArrayStrip]
} }
it "strips nulls when Accept: application/vnd.pgrst.array;nulls=stripped" $
request methodGet "/organizations?select=*"
[("Accept","application/vnd.pgrst.array;nulls=stripped")]
""
`shouldRespondWith`
[json|[{"id":1,"name":"Referee Org","manager_id":1},{"id":2,"name":"Auditor Org","manager_id":2},{"id":3,"name":"Acme","referee":1,"auditor":2,"manager_id":3},{"id":4,"name":"Umbrella","referee":1,"auditor":2,"manager_id":4},{"id":5,"name":"Cyberdyne","referee":3,"auditor":4,"manager_id":5},{"id":6,"name":"Oscorp","referee":3,"auditor":4,"manager_id":6}]|]
{ matchStatus = 200
, matchHeaders = [matchCTArrayStrip]
}
it "strips nulls when Accept: application/vnd.pgrst.object+json;nulls=stripped" $ it "strips nulls when Accept: application/vnd.pgrst.object+json;nulls=stripped" $
request methodGet "/organizations?limit=1" request methodGet "/organizations?limit=1"
[singularStrip] [singularStrip]
+88 -15
View File
@@ -192,7 +192,63 @@ spec actualPgVersion = do
liftIO $ do liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8") resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" } resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe` 1.29 totalCost `shouldBe` 3.55
it "outputs the total cost for 2 upserts" $ do
r <- request methodPost "/tiobe_pls"
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
[json| [ { "name": "Python", "rank": 19 }, { "name": "Go", "rank": 20} ]|]
let totalCost = planCost r
resStatus = simpleStatus r
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe` 5.53
it "outputs the total cost for an upsert with 10 rows" $ do
r <- request methodPost "/tiobe_pls"
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
(getInsertDataForTiobePlsTable 10)
let totalCost = planCost r
resStatus = simpleStatus r
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe` 5.53
it "outputs the total cost for an upsert with 100 rows" $ do
r <- request methodPost "/tiobe_pls"
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
(getInsertDataForTiobePlsTable 100)
let totalCost = planCost r
resStatus = simpleStatus r
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe` 5.53
it "outputs the total cost for an upsert with 1000 rows" $ do
r <- request methodPost "/tiobe_pls"
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
(getInsertDataForTiobePlsTable 1000)
let totalCost = planCost r
resStatus = simpleStatus r
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe` 5.53
it "outputs the plan for application/vnd.pgrst.object" $ do it "outputs the plan for application/vnd.pgrst.object" $ do
r <- request methodDelete "/projects?id=eq.6" r <- request methodDelete "/projects?id=eq.6"
@@ -219,17 +275,6 @@ spec actualPgVersion = do
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" } resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe` 68.56 totalCost `shouldBe` 68.56
it "outputs the plan for text/xml" $ do
r <- request methodGet "/rpc/return_scalar_xml"
(acceptHdrs "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose") ""
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 2
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose; charset=utf-8")
aggCol `shouldBe` Just [aesonQQ| "COALESCE(xmlagg(return_scalar_xml.pgrst_scalar), ''::xml)" |]
describe "text format" $ do describe "text format" $ do
it "outputs the total cost for a function call" $ do it "outputs the total cost for a function call" $ do
r <- request methodGet "/projects?id=in.(1,2,3)" r <- request methodGet "/projects?id=in.(1,2,3)"
@@ -303,12 +348,12 @@ spec actualPgVersion = do
r1 <- request methodGet "/users?select=*,tasks!inner(*)&tasks.id=eq.1" r1 <- request methodGet "/users?select=*,tasks!inner(*)&tasks.id=eq.1"
[planHdr] "" [planHdr] ""
liftIO $ planCost r1 `shouldSatisfy` (< 20876.14) liftIO $ planCost r1 `shouldSatisfy` (< 20888.83)
r2 <- request methodGet "/users?select=*,tasks(*)&tasks.id=eq.1&tasks=not.is.null" r2 <- request methodGet "/users?select=*,tasks(*)&tasks.id=eq.1&tasks=not.is.null"
[planHdr] "" [planHdr] ""
liftIO $ planCost r2 `shouldSatisfy` (< 20876.14) liftIO $ planCost r2 `shouldSatisfy` (< 20888.83)
describe "function call costs" $ do describe "function call costs" $ do
it "should not exceed cost when calling setof composite proc" $ do it "should not exceed cost when calling setof composite proc" $ do
@@ -327,7 +372,7 @@ spec actualPgVersion = do
r <- request methodGet "/rpc/add_them?a=3&b=4" r <- request methodGet "/rpc/add_them?a=3&b=4"
[planHdr] "" [planHdr] ""
liftIO $ planCost r `shouldSatisfy` (< 1.18) liftIO $ planCost r `shouldSatisfy` (< 0.11)
context "function inlining" $ do context "function inlining" $ do
it "should inline a zero argument function(the function won't appear in the plan tree)" $ do it "should inline a zero argument function(the function won't appear in the plan tree)" $ do
@@ -385,6 +430,34 @@ spec actualPgVersion = do
liftIO $ do liftIO $ do
resBody `shouldSatisfy` (\t -> T.isInfixOf "Index" (decodeUtf8 $ LBS.toStrict t)) resBody `shouldSatisfy` (\t -> T.isInfixOf "Index" (decodeUtf8 $ LBS.toStrict t))
describe "custom media types" $ do
it "outputs the plan for a scalar function text/xml" $ do
r <- request methodGet "/rpc/return_scalar_xml"
(acceptHdrs "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose") ""
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 2
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose; charset=utf-8")
aggCol `shouldBe` Just [aesonQQ| "return_scalar_xml.pgrst_scalar" |]
it "outputs the plan for an aggregate application/vnd.twkb" $ do
r <- request methodGet "/lines"
(acceptHdrs "application/vnd.pgrst.plan+json; for=\"application/vnd.twkb\"; options=verbose") ""
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 2
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/vnd.twkb\"; options=verbose; charset=utf-8")
aggCol `shouldBe`
(
if actualPgVersion >= pgVersion120
then Just [aesonQQ| "twkb_agg(ROW(lines.id, lines.name, lines.geom)::lines)" |]
else Just [aesonQQ| "twkb_agg(ROW(pgrst_source.id, pgrst_source.name, pgrst_source.geom)::lines)" |]
)
disabledSpec :: SpecWith ((), Application) disabledSpec :: SpecWith ((), Application)
disabledSpec = disabledSpec =
it "doesn't work if db-plan-enabled=false(the default)" $ do it "doesn't work if db-plan-enabled=false(the default)" $ do
+121
View File
@@ -0,0 +1,121 @@
module Feature.Query.PreferencesSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "test prefer headers and preference-applied headers" $ do
context "check behaviour of Prefer: handling=strict" $ do
it "throws error when handling=strict and invalid prefs are given" $
request methodGet "/items" [("Prefer", "handling=strict, anything")] ""
`shouldRespondWith`
[json|{"details":"Invalid preferences: anything","message":"Invalid preferences given with handling=strict","code":"PGRST122","hint":null}|]
{ matchStatus = 400 }
it "throw error when handling=strict and invalid prefs are given with multiples in separate prefers" $
request methodGet "/items" [("Prefer", "handling=strict"),("Prefer","something, else")] ""
`shouldRespondWith`
[json|{"details":"Invalid preferences: something, else","message":"Invalid preferences given with handling=strict","code":"PGRST122","hint":null}|]
{ matchStatus = 400 }
it "throws error with post request" $
request methodPost "/organizations?select=*"
[("Prefer","return=representation, handling=strict, anything")]
[json|{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}|]
`shouldRespondWith`
[json|{"details":"Invalid preferences: anything","message":"Invalid preferences given with handling=strict","code":"PGRST122","hint":null}|]
{ matchStatus = 400 }
it "throws error with rpc" $
request methodPost "/rpc/overloaded_unnamed_param"
[("Content-Type", "application/json"), ("Prefer", "handling=strict, anything")]
[json|{}|]
`shouldRespondWith`
[json|{"details":"Invalid preferences: anything","message":"Invalid preferences given with handling=strict","code":"PGRST122","hint":null}|]
{ matchStatus = 400 }
context "check behaviour of Prefer: handling=lenient" $ do
it "does not throw error when handling=lenient and invalid prefs" $
request methodGet "/items" [("Prefer", "handling=lenient, anything")] ""
`shouldRespondWith` 200
it "does not throw error when handling=lenient and invalid prefs in multiples prefers" $
request methodGet "/items" [("Prefer", "handling=lenient"), ("Prefer", "anything")] ""
`shouldRespondWith` 200
it "does not throw error with post request" $
request methodPost "/organizations?select=*"
[("Prefer","return=representation, handling=lenient, anything")]
[json|{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}|]
`shouldRespondWith`
[json|[{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}]|]
{ matchStatus = 201
, matchHeaders = [ matchContentTypeJson ]
}
it "does not throw error with rpc" $
request methodPost "/rpc/overloaded_unnamed_param"
[("Content-Type", "application/json"), ("Prefer", "handling=lenient, anything")]
[json|{}|]
`shouldRespondWith`
[json| 1 |]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
context "test Prefer: timezone=America/Los_Angeles" $ do
it "should change timezone with handling=strict" $
request methodGet "/timestamps"
[("Prefer", "handling=strict, timezone=America/Los_Angeles")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T05:37:59.611-07:00"}, {"t":"2023-10-18T07:37:59.611-07:00"}, {"t":"2023-10-18T09:37:59.611-07:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson
, "Preference-Applied" <:> "handling=strict, timezone=America/Los_Angeles"]}
it "should change timezone without handling=strict" $
request methodGet "/timestamps"
[("Prefer", "timezone=America/Los_Angeles")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T05:37:59.611-07:00"}, {"t":"2023-10-18T07:37:59.611-07:00"}, {"t":"2023-10-18T09:37:59.611-07:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson
, "Preference-Applied" <:> "timezone=America/Los_Angeles"] }
context "test Prefer: timezone=Invalid/Timezone" $ do
it "should throw error with handling=strict" $
request methodGet "/timestamps"
[("Prefer", "handling=strict, timezone=Invalid/Timezone")]
""
`shouldRespondWith`
[json|{"code":"PGRST122","details":"Invalid preferences: timezone=Invalid/Timezone","hint":null,"message":"Invalid preferences given with handling=strict"}|]
{ matchStatus = 400 }
it "should return with default timezone without handling or with handling=lenient" $ do
request methodGet "/timestamps"
[("Prefer", "timezone=Invalid/Timezone")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T12:37:59.611+00:00"}, {"t":"2023-10-18T14:37:59.611+00:00"}, {"t":"2023-10-18T16:37:59.611+00:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]}
request methodGet "/timestamps"
[("Prefer", "handling=lenient, timezone=Invalid/Timezone")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T12:37:59.611+00:00"}, {"t":"2023-10-18T14:37:59.611+00:00"}, {"t":"2023-10-18T16:37:59.611+00:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson
, "Preference-Applied" <:> "handling=lenient"]}
+10 -50
View File
@@ -1042,56 +1042,6 @@ spec actualPgVersion = do
[json|[{"a$num$":100}]|] [json|[{"a$num$":100}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
context "binary output" $ do
it "can query if a single column is selected" $
request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
it "can get raw output with Accept: text/plain" $
request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") ""
`shouldRespondWith` "Windows 7"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $
request methodGet "/xmltest?select=xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<myxml>foo</myxml>bar<foobar><baz/></foobar>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "fails if a single column is not selected" $ do
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
{ matchStatus = 406 }
request methodGet "/images?select=*&name=eq.A.png"
(acceptHdrs "application/octet-stream")
""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
{ matchStatus = 406 }
request methodGet "/images?name=eq.A.png"
(acceptHdrs "application/octet-stream")
""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
{ matchStatus = 406 }
it "concatenates results if more than one row is returned" $
request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
describe "values with quotes in IN and NOT IN" $ do describe "values with quotes in IN and NOT IN" $ do
it "succeeds when only quoted values are present" $ do it "succeeds when only quoted values are present" $ do
get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith` get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`
@@ -1447,3 +1397,13 @@ spec actualPgVersion = do
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
context "searching for an empty string" $ do
it "works with an empty eq filter" $
get "/empty_string?string=eq.&select=id,string" `shouldRespondWith`
[json|
[{"id":1,"string":""}]
|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
+26 -5
View File
@@ -145,6 +145,14 @@ spec = do
it "returns whole range with status 200" $ it "returns whole range with status 200" $
get "/items" `shouldRespondWith` 200 get "/items" `shouldRespondWith` 200
context "count with an empty body" $ do
it "returns empty body with Content-Range */0" $
request methodGet "/items?id=eq.0"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json|[]|]
{ matchHeaders = ["Content-Range" <:> "*/0"] }
context "when I don't want the count" $ do context "when I don't want the count" $ do
it "returns range Content-Range with /*" $ it "returns range Content-Range with /*" $
request methodGet "/menagerie" request methodGet "/menagerie"
@@ -211,11 +219,24 @@ spec = do
, "Content-Range" <:> "2-4/*" ] , "Content-Range" <:> "2-4/*" ]
} }
it "succeeds if offset equals 0 as a no-op" $ context "succeeds if offset equals 0 as a no-op" $ do
get "/items?select=id&offset=0&order=id" it "no items" $ do
`shouldRespondWith` get "/items?offset=0&id=eq.0"
[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}]|] `shouldRespondWith`
{ matchHeaders = ["Content-Range" <:> "0-14/*"] } [json|[]|]
{ matchHeaders = ["Content-Range" <:> "*/*"] }
request methodGet "/items?offset=0&id=eq.0"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json|[]|]
{ matchHeaders = ["Content-Range" <:> "*/0"] }
it "one or more items" $
get "/items?select=id&offset=0&order=id"
`shouldRespondWith`
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
it "succeeds if offset is negative as a no-op" $ it "succeeds if offset is negative as a no-op" $
get "/items?select=id&offset=-4&order=id" get "/items?select=id&offset=-4&order=id"
@@ -2,6 +2,7 @@ module Feature.Query.RelatedQueriesSpec where
import Network.Wai (Application) import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
@@ -256,3 +257,124 @@ spec = describe "related queries" $ do
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
-- "?table=not.is.null" does a "table IS DISTINCT FROM NULL" instead of a "table IS NOT NULL"
-- https://github.com/PostgREST/postgrest/issues/2800#issuecomment-1720315818
it "embeds verifying that the entire target table row is not null" $ do
get "/table_b?select=name,table_a(name)&table_a=not.is.null" `shouldRespondWith`
[json|[
{"name":"Test 1","table_a":{"name":"Not null 1"}},
{"name":"Test 2","table_a":{"name":null}}
]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
get "/table_b?select=name,table_a()&table_a=is.null" `shouldRespondWith`
[json|[
{"name":"Test 3"}
]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
it "works with count=exact" $ do
request methodGet "/projects?select=name,clients(name)&clients=not.is.null"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json|[
{"name":"Windows 7", "clients":{"name":"Microsoft"}},
{"name":"Windows 10", "clients":{"name":"Microsoft"}},
{"name":"IOS", "clients":{"name":"Apple"}},
{"name":"OSX", "clients":{"name":"Apple"}}
]|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-3/4" ]
}
request methodGet "/projects?select=name,clients()&clients=is.null"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json|[{"name":"Orphan"}]|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
request methodGet "/client?select=*,clientinfo(),contact()&clientinfo.other=ilike.*main*&contact.name=ilike.*tabby*&or=(clientinfo.not.is.null,contact.not.is.null)"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json|[
{"id":1,"name":"Walmart"},
{"id":2,"name":"Target"}
]|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/2" ]
}
it "works with count=planned" $ do
request methodGet "/projects?select=name,clients(name)&clients=not.is.null"
[("Prefer", "count=planned")] ""
`shouldRespondWith`
[json|[
{"name":"Windows 7", "clients":{"name":"Microsoft"}},
{"name":"Windows 10", "clients":{"name":"Microsoft"}},
{"name":"IOS", "clients":{"name":"Apple"}},
{"name":"OSX", "clients":{"name":"Apple"}}
]|]
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-3/1200" ]
}
request methodGet "/projects?select=name,clients()&clients=is.null"
[("Prefer", "count=planned")] ""
`shouldRespondWith`
[json|[{"name":"Orphan"}]|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
request methodGet "/client?select=*,clientinfo(),contact()&clientinfo.other=ilike.*main*&contact.name=ilike.*tabby*&or=(clientinfo.not.is.null,contact.not.is.null)"
[("Prefer", "count=planned")] ""
`shouldRespondWith`
[json|[
{"id":1,"name":"Walmart"},
{"id":2,"name":"Target"}
]|]
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/952" ]
}
it "works with count=estimated" $ do
request methodGet "/projects?select=name,clients(name)&clients=not.is.null"
[("Prefer", "count=estimated")] ""
`shouldRespondWith`
[json|[
{"name":"Windows 7", "clients":{"name":"Microsoft"}},
{"name":"Windows 10", "clients":{"name":"Microsoft"}},
{"name":"IOS", "clients":{"name":"Apple"}},
{"name":"OSX", "clients":{"name":"Apple"}}
]|]
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-3/1200" ]
}
request methodGet "/projects?select=name,clients()&clients=is.null"
[("Prefer", "count=estimated")] ""
`shouldRespondWith`
[json|[{"name":"Orphan"}]|]
{ matchStatus = 200
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-0/1" ]
}
request methodGet "/client?select=*,clientinfo(),contact()&clientinfo.other=ilike.*main*&contact.name=ilike.*tabby*&or=(clientinfo.not.is.null,contact.not.is.null)"
[("Prefer", "count=estimated")] ""
`shouldRespondWith`
[json|[
{"id":1,"name":"Walmart"},
{"id":2,"name":"Target"}
]|]
{ matchStatus = 206
, matchHeaders = [ matchContentTypeJson
, "Content-Range" <:> "0-1/952" ]
}
+93 -117
View File
@@ -1,12 +1,11 @@
module Feature.Query.RpcSpec where module Feature.Query.RpcSpec where
import qualified Data.ByteString.Lazy as BL (empty, readFile) import qualified Data.ByteString.Lazy as BL (empty)
import Network.Wai (Application) import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus)) import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import Network.HTTP.Types import Network.HTTP.Types
import System.IO.Unsafe (unsafePerformIO)
import Test.Hspec hiding (pendingWith) import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
@@ -14,8 +13,7 @@ import Text.Heredoc
import PostgREST.Config.PgVersion (PgVersion, pgVersion100, import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
pgVersion109, pgVersion110, pgVersion109, pgVersion110,
pgVersion112, pgVersion114, pgVersion112, pgVersion114)
pgVersion140)
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
@@ -98,6 +96,27 @@ spec actualPgVersion =
, matchHeaders = ["Content-Range" <:> "0-1/2"] , matchHeaders = ["Content-Range" <:> "0-1/2"]
} }
it "includes exact count of 1 for functions that return a single scalar, domain or composite" $ do
request methodGet "/rpc/add_them?a=3&b=4"
[("Prefer", "count=exact")] ""
`shouldRespondWith` "7"
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/1"]
}
request methodGet "/rpc/ret_domain?val=8"
[("Prefer", "count=exact")] ""
`shouldRespondWith` "8"
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/1"]
}
request methodGet "/rpc/ret_point_2d"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json|{"x": 10, "y": 5}|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/1"]
}
it "returns proper json" $ do it "returns proper json" $ do
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith` post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
[json| [ {"id": 3}, {"id":4} ] |] [json| [ {"id": 3}, {"id":4} ] |]
@@ -954,12 +973,7 @@ spec actualPgVersion =
it "custom header is set" $ it "custom header is set" $
request methodPost "/rpc/get_guc_value" request methodPost "/rpc/get_guc_value"
[("Custom-Header", "test")] [("Custom-Header", "test")]
( [json| { "prefix": "request.headers", "name": "custom-header" } |]
if actualPgVersion >= pgVersion140 then
[json| { "prefix": "request.headers", "name": "custom-header" } |]
else
[json| { "name": "request.header.custom-header" } |]
)
`shouldRespondWith` `shouldRespondWith`
[json|"test"|] [json|"test"|]
{ matchStatus = 200 { matchStatus = 200
@@ -968,12 +982,7 @@ spec actualPgVersion =
it "standard header is set" $ it "standard header is set" $
request methodPost "/rpc/get_guc_value" request methodPost "/rpc/get_guc_value"
[("Origin", "http://example.com")] [("Origin", "http://example.com")]
( [json| { "prefix": "request.headers", "name": "origin" } |]
if actualPgVersion >= pgVersion140 then
[json| { "prefix": "request.headers", "name": "origin" } |]
else
[json| { "name": "request.header.origin" } |]
)
`shouldRespondWith` `shouldRespondWith`
[json|"http://example.com"|] [json|"http://example.com"|]
{ matchStatus = 200 { matchStatus = 200
@@ -981,12 +990,7 @@ spec actualPgVersion =
} }
it "current role is available as GUC claim" $ it "current role is available as GUC claim" $
request methodPost "/rpc/get_guc_value" [] request methodPost "/rpc/get_guc_value" []
( [json| { "prefix": "request.jwt.claims", "name": "role" } |]
if actualPgVersion >= pgVersion140 then
[json| { "prefix": "request.jwt.claims", "name": "role" } |]
else
[json| { "name": "request.jwt.claim.role" } |]
)
`shouldRespondWith` `shouldRespondWith`
[json|"postgrest_test_anonymous"|] [json|"postgrest_test_anonymous"|]
{ matchStatus = 200 { matchStatus = 200
@@ -994,25 +998,15 @@ spec actualPgVersion =
} }
it "single cookie ends up as claims" $ it "single cookie ends up as claims" $
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")] request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
(
if actualPgVersion >= pgVersion140 then
[json| {"prefix": "request.cookies", "name":"acookie"} |] [json| {"prefix": "request.cookies", "name":"acookie"} |]
else
[json| {"name":"request.cookie.acookie"} |]
)
`shouldRespondWith` `shouldRespondWith`
[json|"cookievalue"|] [json|"cookievalue"|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = [] , matchHeaders = []
} }
it "multiple cookies ends up as claims" $ it "multiple cookies end up as claims" $
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")] request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
(
if actualPgVersion >= pgVersion140 then
[json| {"prefix": "request.cookies", "name":"secondcookie"} |] [json| {"prefix": "request.cookies", "name":"secondcookie"} |]
else
[json| {"name":"request.cookie.secondcookie"} |]
)
`shouldRespondWith` `shouldRespondWith`
[json|"anothervalue"|] [json|"anothervalue"|]
{ matchStatus = 200 { matchStatus = 200
@@ -1028,12 +1022,7 @@ spec actualPgVersion =
} }
it "gets the Authorization value" $ it "gets the Authorization value" $
request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"] request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
(
if actualPgVersion >= pgVersion140 then
[json| {"prefix": "request.headers", "name":"authorization"} |] [json| {"prefix": "request.headers", "name":"authorization"} |]
else
[json| {"name":"request.header.authorization"} |]
)
`shouldRespondWith` `shouldRespondWith`
[json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|] [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
{ matchStatus = 200 { matchStatus = 200
@@ -1056,79 +1045,6 @@ spec actualPgVersion =
, matchHeaders = [] , matchHeaders = []
} }
context "binary output" $ do
context "Proc that returns scalar" $ do
it "can query without selecting column" $
request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
it "can get raw output with Accept: text/plain" $
request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
`shouldRespondWith` "Welcome to PostgREST"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $
request methodGet "/rpc/return_scalar_xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<my-xml-tag/>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $
request methodGet "/rpc/welcome.xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<html>\n <head>\n <title>PostgREST</title>\n </head>\n <body>\n <h1>Welcome to PostgREST</h1>\n </body>\n</html>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "should fail with function returning text and Accept: text/xml" $
request methodGet "/rpc/welcome" (acceptHdrs "text/xml") ""
`shouldRespondWith`
[json|
{
"hint":"No function matches the given name and argument types. You might need to add explicit type casts.",
"details":null,
"code":"42883",
"message":"function xmlagg(text) does not exist"
}
|]
{ matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
context "Proc that returns set of scalars" $
it "can query without selecting column" $
request methodGet "/rpc/welcome_twice"
(acceptHdrs "text/plain")
""
`shouldRespondWith`
"Welcome to PostgRESTWelcome to PostgREST"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
}
context "Proc that returns rows" $ do
it "can query if a single column is selected" $
request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
}
it "fails if a single column is not selected" $
request methodPost "/rpc/ret_rows_with_base64_bin"
(acceptHdrs "application/octet-stream") ""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
{ matchStatus = 406 }
context "only for GET rpc" $ do context "only for GET rpc" $ do
it "should fail on mutating procs" $ do it "should fail on mutating procs" $ do
get "/rpc/callcounter" `shouldRespondWith` 405 get "/rpc/callcounter" `shouldRespondWith` 405
@@ -1297,14 +1213,14 @@ spec actualPgVersion =
`shouldRespondWith` `shouldRespondWith`
[json|{"A": 1, "B": 2, "C": 3}|] [json|{"A": 1, "B": 2, "C": 3}|]
it "can insert text directly" $ it "can insert text directly" $ do
request methodPost "/rpc/unnamed_text_param" request methodPost "/rpc/unnamed_text_param"
[("Content-Type", "text/plain"), ("Accept", "text/plain")] [("Content-Type", "text/plain"), ("Accept", "text/plain")]
[str|unnamed text arg|] [str|unnamed text arg|]
`shouldRespondWith` `shouldRespondWith`
[str|unnamed text arg|] [str|unnamed text arg|]
it "can insert xml directly" $ it "can insert xml directly" $ do
request methodPost "/rpc/unnamed_xml_param" request methodPost "/rpc/unnamed_xml_param"
[("Content-Type", "text/xml"), ("Accept", "text/xml")] [("Content-Type", "text/xml"), ("Accept", "text/xml")]
[str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|] [str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|]
@@ -1312,7 +1228,7 @@ spec actualPgVersion =
[str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|] [str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|]
it "can insert bytea directly" $ do it "can insert bytea directly" $ do
let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png" let file = readFixtureFile "image.png"
r <- request methodPost "/rpc/unnamed_bytea_param" r <- request methodPost "/rpc/unnamed_bytea_param"
[("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")] [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
file file
@@ -1365,10 +1281,9 @@ spec actualPgVersion =
} }
it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $ it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $
let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png" in
request methodPost "/rpc/unnamed_int_param" request methodPost "/rpc/unnamed_int_param"
[("Content-Type", "application/octet-stream")] [("Content-Type", "application/octet-stream")]
file (readFixtureFile "image.png")
`shouldRespondWith` `shouldRespondWith`
[json|{ [json|{
"hint": null, "hint": null,
@@ -1407,7 +1322,7 @@ spec actualPgVersion =
[str|unnamed text arg|] [str|unnamed text arg|]
`shouldRespondWith` `shouldRespondWith`
[str|unnamed text arg|] [str|unnamed text arg|]
let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png" let file = readFixtureFile "image.png"
r <- request methodPost "/rpc/overloaded_unnamed_param" r <- request methodPost "/rpc/overloaded_unnamed_param"
[("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")] [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
file file
@@ -1490,3 +1405,64 @@ spec actualPgVersion =
`shouldRespondWith` `shouldRespondWith`
[json| {"code":"22026","details":null,"hint":null,"message":"bit string length 6 does not match type bit(5)"} |] [json| {"code":"22026","details":null,"hint":null,"message":"bit string length 6 does not match type bit(5)"} |]
{ matchStatus = 400 } { matchStatus = 400 }
context "get message and details from raise sqlstate" $ do
it "gets message and details from raise sqlstate PGRST" $ do
r <- request methodGet "/rpc/raise_sqlstate_test1"
[] ""
let resStatus = simpleStatus r
resHeaders = simpleHeaders r
resBody = simpleBody r
liftIO $ do
resStatus `shouldBe` Status { statusCode = 332, statusMessage = "My Custom Status" }
resHeaders `shouldSatisfy` elem ("X-Header", "str")
resBody `shouldBe` [json|{"code":"123","message":"ABC","details":"DEF","hint":"XYZ"}|]
get "/rpc/raise_sqlstate_test2" `shouldRespondWith`
[json|{"code":"123","message":"ABC","details":null,"hint":null}|]
{ matchStatus = 332
, matchHeaders = ["X-Header" <:> "str"] }
it "get message and details from PGRST raise and checks standard status message" $ do
r <- request methodGet "/rpc/raise_sqlstate_test3"
[] ""
let resStatus = simpleStatus r
resHeaders = simpleHeaders r
resBody = simpleBody r
liftIO $ do
resStatus `shouldBe` Status { statusCode = 404, statusMessage = "Not Found" }
resHeaders `shouldSatisfy` elem ("X-Header", "str")
resBody `shouldBe` [json|{"code":"123","message":"ABC","details":null,"hint":null}|]
it "get message and details from PGRST raise and checks custom status message" $ do
r <- request methodGet "/rpc/raise_sqlstate_test4"
[] ""
let resStatus = simpleStatus r
resHeaders = simpleHeaders r
resBody = simpleBody r
liftIO $ do
resStatus `shouldBe` Status { statusCode = 404, statusMessage = "My Not Found" }
resHeaders `shouldSatisfy` elem ("X-Header", "str")
resBody `shouldBe` [json|{"code":"123","message":"ABC","details":null,"hint":null}|]
it "returns error for invalid JSON in RAISE Message field" $
get "/rpc/raise_sqlstate_invalid_json_message" `shouldRespondWith`
[json|{"code":"PGRST121","message":"The message and detail field of RAISE 'PGRST' error expects JSON","details":null,"hint":null}|]
{ matchStatus = 500 }
it "returns error for invalid JSON in RAISE Details field" $
get "/rpc/raise_sqlstate_invalid_json_details" `shouldRespondWith`
[json|{"code":"PGRST121","message":"The message and detail field of RAISE 'PGRST' error expects JSON","details":null,"hint":null}|]
{ matchStatus = 500 }
it "returns error for missing Details field in RAISE" $
get "/rpc/raise_sqlstate_missing_details" `shouldRespondWith`
[json|{"code":"PGRST121","message":"The message and detail field of RAISE 'PGRST' error expects JSON","details":null,"hint":null}|]
{ matchStatus = 500 }
+111
View File
@@ -0,0 +1,111 @@
module Feature.Query.ServerTimingSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "Show Duration on Server-Timing header" $ do
context "responds with Server-Timing header" $ do
it "works with get request" $ do
request methodGet "/organizations?id=eq.6"
[]
""
`shouldRespondWith`
[json|[{"id":6,"name":"Oscorp","referee":3,"auditor":4,"manager_id":6}]|]
{ matchStatus = 200
, matchHeaders = matchContentTypeJson : map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
}
it "works with post request" $
request methodPost "/organizations?select=*"
[("Prefer","return=representation")]
[json|{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}|]
`shouldRespondWith`
[json|[{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}]|]
{ matchStatus = 201
, matchHeaders = matchContentTypeJson : map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
}
it "works with patch request" $
request methodPatch "/no_pk?b=eq.0" mempty
[json| { b: "1" } |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = matchHeaderAbsent hContentType : map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
}
it "works with put request" $
request methodPut "/tiobe_pls?name=eq.Python"
[("Prefer", "return=representation")]
[json| [ { "name": "Python", "rank": 19 } ]|]
`shouldRespondWith`
[json| [ { "name": "Python", "rank": 19 } ]|]
{ matchStatus = 200
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
}
it "works with delete request" $
request methodDelete "/items?id=eq.1"
[]
""
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = matchHeaderAbsent hContentType : map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
}
it "works with rpc call" $
request methodPost "/rpc/ret_point_overloaded"
[]
[json|{"x": 1, "y": 2}|]
`shouldRespondWith`
[json|{"x": 1, "y": 2}|]
{ matchStatus = 200
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
}
it "works with root spec" $
request methodHead "/"
[]
""
`shouldRespondWith`
""
{ matchStatus = 200
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
}
it "works with OPTIONS method" $ do
request methodOptions "/organizations"
[]
""
`shouldRespondWith`
""
{ matchStatus = 200
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "response"]
}
request methodOptions "/rpc/getallprojects"
[]
""
`shouldRespondWith`
""
{ matchStatus = 200
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "response"]
}
request methodOptions "/"
[]
""
`shouldRespondWith`
""
{ matchStatus = 200
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "response"]
}
+8 -16
View File
@@ -72,8 +72,7 @@ spec =
`shouldRespondWith` `shouldRespondWith`
[json|{"details":"The result contains 4 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|] [json|{"details":"The result contains 4 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
{ matchStatus = 406 { matchStatus = 406
, matchHeaders = [ matchContentTypeSingular , matchHeaders = [ matchContentTypeSingular ]
, "Preference-Applied" <:> "tx=commit" ]
} }
-- the rows should not be updated, either -- the rows should not be updated, either
@@ -88,8 +87,7 @@ spec =
`shouldRespondWith` `shouldRespondWith`
[json|{"details":"The result contains 4 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|] [json|{"details":"The result contains 4 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
{ matchStatus = 406 { matchStatus = 406
, matchHeaders = [ matchContentTypeSingular , matchHeaders = [ matchContentTypeSingular ]
, "Preference-Applied" <:> "tx=commit" ]
} }
-- the rows should not be updated, either -- the rows should not be updated, either
@@ -145,8 +143,7 @@ spec =
`shouldRespondWith` `shouldRespondWith`
[json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|] [json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
{ matchStatus = 406 { matchStatus = 406
, matchHeaders = [ matchContentTypeSingular , matchHeaders = [ matchContentTypeSingular ]
, "Preference-Applied" <:> "tx=commit" ]
} }
-- the rows should not exist, either -- the rows should not exist, either
@@ -161,8 +158,7 @@ spec =
`shouldRespondWith` `shouldRespondWith`
[json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|] [json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
{ matchStatus = 406 { matchStatus = 406
, matchHeaders = [ matchContentTypeSingular , matchHeaders = [ matchContentTypeSingular ]
, "Preference-Applied" <:> "tx=commit" ]
} }
-- the rows should not exist, either -- the rows should not exist, either
@@ -177,8 +173,7 @@ spec =
`shouldRespondWith` `shouldRespondWith`
[json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|] [json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
{ matchStatus = 406 { matchStatus = 406
, matchHeaders = [ matchContentTypeSingular , matchHeaders = [ matchContentTypeSingular ]
, "Preference-Applied" <:> "tx=commit" ]
} }
-- the rows should not exist, either -- the rows should not exist, either
@@ -226,8 +221,7 @@ spec =
`shouldRespondWith` `shouldRespondWith`
[json|{"details":"The result contains 5 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|] [json|{"details":"The result contains 5 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
{ matchStatus = 406 { matchStatus = 406
, matchHeaders = [ matchContentTypeSingular , matchHeaders = [ matchContentTypeSingular ]
, "Preference-Applied" <:> "tx=commit" ]
} }
-- the rows should still exist -- the rows should still exist
@@ -244,8 +238,7 @@ spec =
`shouldRespondWith` `shouldRespondWith`
[json|{"details":"The result contains 5 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|] [json|{"details":"The result contains 5 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
{ matchStatus = 406 { matchStatus = 406
, matchHeaders = [ matchContentTypeSingular , matchHeaders = [ matchContentTypeSingular ]
, "Preference-Applied" <:> "tx=commit" ]
} }
-- the rows should still exist -- the rows should still exist
@@ -318,8 +311,7 @@ spec =
`shouldRespondWith` `shouldRespondWith`
[json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|] [json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
{ matchStatus = 406 { matchStatus = 406
, matchHeaders = [ matchContentTypeSingular , matchHeaders = [ matchContentTypeSingular]
, "Preference-Applied" <:> "tx=commit" ]
} }
-- should rollback function -- should rollback function
+2 -2
View File
@@ -44,7 +44,7 @@ spec actualPgVersion = do
it "fails with 400 and error" $ it "fails with 400 and error" $
request methodPatch "/simple_pk" [] "}{ x = 2" request methodPatch "/simple_pk" [] "}{ x = 2"
`shouldRespondWith` `shouldRespondWith`
[json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'","code":"PGRST102","details":null,"hint":null}|] [json|{"message":"Empty or invalid json","code":"PGRST102","details":null,"hint":null}|]
{ matchStatus = 400, { matchStatus = 400,
matchHeaders = [matchContentTypeJson] matchHeaders = [matchContentTypeJson]
} }
@@ -53,7 +53,7 @@ spec actualPgVersion = do
it "fails with 400 and error" $ it "fails with 400 and error" $
request methodPatch "/items" [] "" request methodPatch "/items" [] ""
`shouldRespondWith` `shouldRespondWith`
[json|{"message":"Error in $: not enough input","code":"PGRST102","details":null,"hint":null}|] [json|{"message":"Empty or invalid json","code":"PGRST102","details":null,"hint":null}|]
{ matchStatus = 400, { matchStatus = 400,
matchHeaders = [matchContentTypeJson] matchHeaders = [matchContentTypeJson]
} }
+25 -2
View File
@@ -32,6 +32,21 @@ spec actualPgVersion =
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates, return=representation", matchContentTypeJson] , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates, return=representation", matchContentTypeJson]
} }
it "UPDATEs rows on pk conflict" $
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "name": "Python", "rank": 6 },
{ "name": "Java", "rank": 2 },
{ "name": "C", "rank": 1 }
]|] `shouldRespondWith` [json| [
{ "name": "Python", "rank": 6 },
{ "name": "Java", "rank": 2 },
{ "name": "C", "rank": 1 }
]|]
{ matchStatus = 200
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates, return=representation", matchContentTypeJson]
}
it "INSERTs and UPDATEs row on composite pk conflict" $ it "INSERTs and UPDATEs row on composite pk conflict" $
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")] request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [ [json| [
@@ -62,7 +77,7 @@ spec actualPgVersion =
it "succeeds when the payload has no elements" $ it "succeeds when the payload has no elements" $
request methodPost "/articles" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")] request methodPost "/articles" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json|[]|] `shouldRespondWith` [json|[]|] `shouldRespondWith`
[json|[]|] { matchStatus = 201 [json|[]|] { matchStatus = 200 -- nothing was inserted, so it should be 200
, matchHeaders = [matchContentTypeJson] } , matchHeaders = [matchContentTypeJson] }
it "INSERTs and UPDATEs rows on single unique key conflict" $ it "INSERTs and UPDATEs rows on single unique key conflict" $
@@ -282,6 +297,7 @@ spec actualPgVersion =
[json| [ { "name": "Go", "rank": 19 } ]|] [json| [ { "name": "Go", "rank": 19 } ]|]
`shouldRespondWith` `shouldRespondWith`
[json| [ { "name": "Go", "rank": 19 } ]|] [json| [ { "name": "Go", "rank": 19 } ]|]
{ matchStatus = 201 }
it "succeeds on table with composite pk" $ do it "succeeds on table with composite pk" $ do
-- assert that the next request will indeed be an insert -- assert that the next request will indeed be an insert
@@ -294,6 +310,7 @@ spec actualPgVersion =
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|] [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` `shouldRespondWith`
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|] [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
{ matchStatus = 201 }
when (actualPgVersion >= pgVersion110) $ when (actualPgVersion >= pgVersion110) $
it "succeeds on a partitioned table with composite pk" $ do it "succeeds on a partitioned table with composite pk" $ do
@@ -307,6 +324,7 @@ spec actualPgVersion =
[json| [ { "name": "Supra", "year": 2021 } ]|] [json| [ { "name": "Supra", "year": 2021 } ]|]
`shouldRespondWith` `shouldRespondWith`
[json| [ { "name": "Supra", "year": 2021, "car_brand_name": null } ]|] [json| [ { "name": "Supra", "year": 2021, "car_brand_name": null } ]|]
{ matchStatus = 201 }
it "succeeds if the table has only PK cols and no other cols" $ do it "succeeds if the table has only PK cols and no other cols" $ do
-- assert that the next request will indeed be an insert -- assert that the next request will indeed be an insert
@@ -319,6 +337,7 @@ spec actualPgVersion =
[json|[ { "id": 10 } ]|] [json|[ { "id": 10 } ]|]
`shouldRespondWith` `shouldRespondWith`
[json|[ { "id": 10 } ]|] [json|[ { "id": 10 } ]|]
{ matchStatus = 201 }
context "Updating row" $ do context "Updating row" $ do
it "succeeds on table with single pk col" $ do it "succeeds on table with single pk col" $ do
@@ -401,7 +420,11 @@ spec actualPgVersion =
request methodPut "/tiobe_pls?name=eq.Ruby" request methodPut "/tiobe_pls?name=eq.Ruby"
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")] [("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
[json| [ { "name": "Ruby", "rank": 11 } ]|] [json| [ { "name": "Ruby", "rank": 11 } ]|]
`shouldRespondWith` [json|{ "name": "Ruby", "rank": 11 }|] { matchHeaders = [matchContentTypeSingular] } `shouldRespondWith`
[json|{ "name": "Ruby", "rank": 11 }|]
{ matchStatus = 201
, matchHeaders = [matchContentTypeSingular] }
context "with a camel case pk column" $ do context "with a camel case pk column" $ do
it "works with POST and merge-duplicates" $ do it "works with POST and merge-duplicates" $ do

Some files were not shown because too many files have changed in this diff Show More