Compare commits

...
104 Commits
Author SHA1 Message Date
steve-chavez f56bed2a75 bump version to 10.1.2 2023-02-02 03:30:15 -05:00
Laurence IslaandSteve Chavez af8e436732 Add missing fixes to the changelog 2023-02-02 03:30:15 -05:00
steve-chavez 98a29bee04 fix: NOTIFY pgrst not reoading the catalog cache 2023-02-02 03:30:15 -05:00
Tuan LeandSteve Chavez 557285b659 fix: consider authentication failure as a fatal error 2023-02-02 03:30:15 -05:00
Laurence IslaandSteve Chavez 81501aefa0 fix: FK pointing to VIEW instead of TABLE in OpenAPI output 2023-02-02 03:30:15 -05:00
Laurence IslaandSteve Chavez 12c1d4a8e4 Add upsert headers for POST requests to the OpenAPI output 2023-02-02 03:30:15 -05:00
Laurence IslaandSteve Chavez 8aa7368786 fix: Add required OpenAPI items object when the paramater is an array 2023-02-02 03:30:15 -05:00
Laurence IslaandSteve Chavez 9d4ff812c9 Add suggestions with fuzzy text search when no relationship is found (#2583) 2023-02-02 03:30:15 -05:00
Laurence IslaandSteve Chavez 171dd313d9 fix: clarify error messages for functions
Move explanation on single unnamed parameters to the error details
2023-02-02 03:30:15 -05:00
Laurence IslaandSteve Chavez fd24a7374b feat: hint function names/parameters on error 2023-02-02 03:30:15 -05:00
steve-chavez a525790c4c fix: bad M2M embed on RPC 2023-02-02 03:30:15 -05:00
steve-chavez aaa4fbc370 bump version to 10.1.1 2022-11-08 12:51:16 -05:00
Robert VollmertandWolfgang Walther 5e65b2afaf fix: bump hasql-pool to 0.8.0.6
This update ensures that connections aren't lost if they throw
an exception. Compare #2558.
2022-11-05 15:48:52 +01:00
steve-chavez 3408998629 bump version to 10.1.0.20221104 2022-11-04 18:47:48 -05:00
Laurence IslaandGitHub b8c5d212ea fix: Fatal error when requesting limit=0 and db-max-rows is set (#2560) 2022-11-04 18:14:16 -05:00
c8e4f38984 feat: Build against libpq 15
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
Co-authored-by: Wolfgang Walther <walther@technowledgy.de>
2022-11-02 08:25:07 +01:00
Wolfgang WaltherandWolfgang Walther 44dd73adcc fix: Embedding views with partial FK references broken
This is a regression introduced in d2719420f4.

Fixes #2548

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-31 18:15:06 +01:00
steve-chavez b2935ef799 bump version to 10.1.0 2022-10-28 18:09:19 -05:00
steve-chavez 0b1f8358c6 nix: postgrest-release modifies CHANGELOG 2022-10-28 17:52:58 -05:00
Wolfgang WaltherandWolfgang Walther 2c31c64325 fix: Detect all relationships when multiple view columns reference the same table column
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-28 18:12:58 +02:00
Wolfgang Walther 425ab70ec7 fix: Make UPSERT work with PostgreSQL 15
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-28 16:18:42 +02:00
Wolfgang Walther 2cce82bdd5 chore: Upgrade nixpkgs to 2022-10-28
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-28 14:27:56 +02:00
Robert VollmertandWolfgang Walther 800873ed91 try applying same override to openssl_1_1 as openssl 2022-10-28 10:49:04 +02:00
Robert VollmertandWolfgang Walther b702c5fbe4 pytest_xdist -> pytest-xdist 2022-10-28 10:49:04 +02:00
Robert VollmertandWolfgang Walther cfd0ee35d3 bump haskell dependency versions 2022-10-28 10:49:04 +02:00
Robert VollmertandWolfgang Walther 31cd0bbff4 bash_5 -> bash 2022-10-28 10:49:04 +02:00
Robert VollmertandWolfgang Walther 78ec8a095a bump nixpkgs again, hoping this fixes things 2022-10-28 10:49:04 +02:00
Robert VollmertandWolfgang Walther 39959fa330 fix nixpkgs-ugprade to use correct /bin/date 2022-10-28 10:49:04 +02:00
Robert VollmertandWolfgang Walther fc3a01fabe further bump, simplify hackage overrides 2022-10-28 10:49:04 +02:00
Robert VollmertandWolfgang Walther 03b810ffa5 allow/use lens 5.2 2022-10-28 10:49:04 +02:00
Robert VollmertandWolfgang Walther 177ec4df85 downgrade postgis 2022-10-28 10:49:04 +02:00
Robert VollmertandWolfgang Walther dbf645b899 upgrade nix wip 2022-10-28 10:49:04 +02:00
Wolfgang WaltherandWolfgang Walther e274706088 fix: Ignore leading and trailing spaces in column names, fts languages and casts
Fixes #2285

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:44 +02:00
Wolfgang WaltherandWolfgang Walther da632ac7d3 refactor: pIdentifierChar -> pIdentifier
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:44 +02:00
Wolfgang WaltherandWolfgang Walther b78b096ae4 fix: Stricter query string parsing - no empty fts lang
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:44 +02:00
Wolfgang WaltherandWolfgang Walther bbbc308b6f fix: Stricter query string parsing - no empty casts
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:44 +02:00
Wolfgang WaltherandWolfgang Walther ecf54bef43 test: Add basic doctests for pFieldName
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:44 +02:00
Wolfgang WaltherandWolfgang Walther 8c0d187044 fix: Stricter query string parsing - no hints or join types in regular fields
Fixes #2362

Reference #2475

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:44 +02:00
Wolfgang WaltherandWolfgang Walther ae8625a7b6 fix: Stricter query string parsing - no jsonpath in embedding
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:44 +02:00
Wolfgang WaltherandWolfgang Walther 7c7a04a27e test: Add doctests for pFieldForest
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:44 +02:00
Wolfgang WaltherandWolfgang Walther 2289defe4b refactor: Use stronger typing for SelectItem parser
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:44 +02:00
Wolfgang WaltherandWolfgang Walther 772d3c4e01 chore: Update cachix actions
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 21:54:30 +02:00
steve-chavez 45e7aac218 fix: embedding computed with normal relationship 2022-10-27 13:49:53 -05:00
steve-chavez 5d126bf0a3 refactor: all aliasing in addRels 2022-10-27 13:49:53 -05:00
Wolfgang WaltherandWolfgang Walther f9f572a5d2 fix: Treat non-setof computed relationships as M2O/O2M
Fixes #2481

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 19:50:38 +02:00
Wolfgang WaltherandWolfgang Walther 88b5966a44 nix: Remove deprecation warning for dockerTools.buildImage { contents }
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 19:29:26 +02:00
Wolfgang WaltherandWolfgang Walther 3d2880d40a fix: Return status code 200 when PATCHing without changing rows
This partially reverts #1257 / #1272 / 553531711b where the 404 was introduced.

A 406 error is still returned when requesting a single object via accept header.

Returning an error when no rows are changed can be introduced through a different syntax again, see the discussion in #2164.

Fixes #2343

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 19:28:06 +02:00
Wolfgang WaltherandWolfgang Walther 958e052de5 chore: Clean up CHANGELOG
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 17:39:04 +02:00
Wolfgang WaltherandWolfgang Walther 793acd276a test: Non-existing config options should not break anything
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-27 17:39:04 +02:00
Wolfgang Walther 9be6747b66 fix: Prevent infinite recursion with self-referencing views
Fixes #2283

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-26 21:33:30 +02:00
Wolfgang Walther 3bee6b1e27 fix: Fix regression in openapi output with mode follow-privileges
This was introduced in d5b92a433a. Before
this change, the OpenApi output would have <pk/> annotations for views,
too. After this change, they got lost for mode follow-privileges, because
the pks are refined in haskell code, but the request only fetches all
the tables again, but not the view dependencies.

This fix changes follow-privileges to only fetch a list of accessible
tables, which is then used to filter the tables in the schema cache.

Fixes #2356

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-26 21:29:58 +02:00
Wolfgang WaltherandWolfgang Walther 6fe1617348 fix: Expose PKs from all tables in a view
Fixes a regression in d2719420f4.

Resolves #2458

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-24 16:49:32 +02:00
Wolfgang WaltherandWolfgang Walther 2158f3d039 fix: Embed views with different column ordering correctly
This fixes a regression introduced in d2719420f4.

The order of relColumns in the schema cache is now consistently matching the order of columns in foreign key definitions.

Resolves #2518

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-24 16:49:32 +02:00
Wolfgang WaltherandWolfgang Walther ca338ae401 test: Remove unused no_pk_view from fixtures
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-24 16:49:32 +02:00
Wolfgang WaltherandWolfgang Walther 5135fea797 test: grant privileges on all test tables by default
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-10-24 16:49:32 +02:00
dependabot[bot]andSteve Chavez df538bfd98 build(deps): bump LouisBrunner/checks-action from 1.3.1 to 1.5.0
Bumps [LouisBrunner/checks-action](https://github.com/LouisBrunner/checks-action) from 1.3.1 to 1.5.0.
- [Release notes](https://github.com/LouisBrunner/checks-action/releases)
- [Commits](https://github.com/LouisBrunner/checks-action/compare/v1.3.1...v1.5.0)

---
updated-dependencies:
- dependency-name: LouisBrunner/checks-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-24 09:37:56 -05:00
steve-chavez 7ace8ace49 fix: aliasing on computed rels 2022-10-19 19:49:43 -05:00
steve-chavez c8b39c6cde refactor: aliasing logic to addRels
* also shorten query builder alias logic
2022-10-19 19:49:43 -05:00
steve-chavez 5f5a0c7764 refactor: simplify join conditions logic 2022-10-19 19:49:43 -05:00
steve-chavez 922da4520f refactor: clarify ReadPlan fields 2022-10-19 19:49:43 -05:00
Felix YanandSteve Chavez 64e047e5d7 Allow aeson 2.1
Builds fine and all tests pass.
2022-10-14 13:44:39 -05:00
steve-chavez efecf007e8 bump version to 10.0.0.20221011 2022-10-11 11:42:40 -05:00
Felix YanandSteve Chavez b81e15d0c5 Allow vector 0.13
Builds fine and all tests pass.
2022-10-10 17:47:48 -05:00
Steve ChavezandGitHub 0fbb116dd2 add basic ARCHITECTURE.md (#2503)
* refactor: move ApiRequest a top-level module
* refactor: rename DbStructure to SchemaCache
* refactor: GucHeader inside Response
* refactor: admin app to Workers
2022-10-10 11:24:45 -05:00
steve-chavez e4b98d51be fix: resource embedding opens empty transaction
This required untangling logic from App.hs.
Building/rejecting a plan no longer consumes a pool connection.

* Added io test for failed resource embedding not consuming connection
* optionalrollback to Query.hs and Response.hs
* delete Middleware module
* remove plan logic from App.hs
* remove RequestContext
* remove pkCols logic from App.hs
* remove proc logic from App.hs
* remove config logic from handleRequest
2022-10-07 18:52:57 -05:00
steve-chavez d37e14c4db refactor: readTotal into ResultSet 2022-10-07 18:52:57 -05:00
Felix YanandSteve Chavez cfaeff8a5a Allow jose 0.10
Builds fine and all tests pass.
2022-10-05 18:22:32 -05:00
steve-chavez 6398dd2b32 refactor: add plan module
* rename/add plan dirs
* add read plan func/data
* add call plan func/data
* add mutate plan func/data
2022-10-04 16:13:34 -05:00
steve-chavez 11385bbd9f refactor: move binaryField to ApiRequest 2022-10-01 11:05:49 -05:00
steve-chavez 858e4405ec refactor: getSchema in ApiRequest 2022-10-01 11:05:49 -05:00
steve-chavez 46307ca64a refactor: getMediaType in ApiRequest 2022-10-01 11:05:49 -05:00
steve-chavez fd67c8480c refactor: OpenAPI as Response submodule
* body after dbstructure
* rename runPgLocals to setPgLocals
2022-10-01 11:05:49 -05:00
steve-chavez f54dc2e20e refactor: runPgLocals to Query.hs
* unmmiddleware runPgLocals
2022-09-26 17:28:08 -05:00
steve-chavez e356783cc9 refactor: add Query.hs module
* add readQuery
* add createQuery
* update query
* single upsert query
* delete query
* invoke query
* open api query
2022-09-26 17:28:08 -05:00
steve-chavez b5080fa2d7 refactor: untangle query logic from request logic 2022-09-26 17:28:08 -05:00
dependabot[bot]andSteve Chavez e564ed6e3e build(deps): bump codecov/codecov-action from 3.1.0 to 3.1.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v3.1.0...v3.1.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-26 12:12:12 -05:00
steve-chavez 8d369a2195 refactor: add Response.hs module
* add response updateResponse
* add singleUpsertResponse
* add delete/invoke response
* add open api response
* add info response
* remove ApiRequest from profileHeader
* contentTypeHeaders only needs ApiRequest
2022-09-25 13:53:11 -05:00
steve-chavez 92e28bd902 refactor: untangle query logic from response logic
* untangle failNotSingular
* untangle failsChangesOffLimits
* untangle readTotal
2022-09-25 13:53:11 -05:00
steve-chavez c27e7be028 refactor: move guc headers/status decoding to App 2022-09-25 13:53:11 -05:00
Wolfgang WaltherandSteve Chavez 9d3bbc736b nix: Add hint how to derive docker image
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-09-20 16:39:37 -05:00
Robert Vollmert 38ecaa44db encode json body in binary, using modified hasql dep
- switch from "unknown" parameter in text format to a "json" parameter in
  binary format (no dependency update required)
- use a lazy bytestring "json" encoder (via updated hasql)
2022-09-19 20:49:11 +02:00
Robert Vollmert efd65ff1f4 nix: add update-nix-fetchgit to shell 2022-09-19 20:49:11 +02:00
Laurence IslaandGitHub d5e662567b fix: add error body when Prefer: count=exact is used and offset is out of bounds
* Adds error body when Prefer: count=exact is used and offset is out of bounds

* Adds details to differentiate between negative limits, lower boundaries greater than upper boundaries and out of bound ranges
2022-09-15 18:39:37 -05:00
Laurence IslaandGitHub 334f500f7c fix: make computed relationships work when the schema name has special characters 2022-09-15 11:07:57 -05:00
dependabot[bot]andSteve Chavez bb52fb9b0e build(deps): bump LouisBrunner/checks-action from 1.2.0 to 1.3.1
Bumps [LouisBrunner/checks-action](https://github.com/LouisBrunner/checks-action) from 1.2.0 to 1.3.1.
- [Release notes](https://github.com/LouisBrunner/checks-action/releases)
- [Commits](https://github.com/LouisBrunner/checks-action/compare/v1.2.0...v1.3.1)

---
updated-dependencies:
- dependency-name: LouisBrunner/checks-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-07 11:02:48 -05:00
Robert Vollmert 74ee6cd674 fix naming of stack builds (they don't test) 2022-09-05 23:27:41 +02:00
Robert Vollmert 524c6a7c2c postgresql-libpq: use PostgREST fork with reduced copies
This pulls in the change from https://github.com/PostgREST/postgresql-libpq/pull/1.
2022-09-05 23:27:41 +02:00
Robert Vollmert c9f7504d09 ci: edit stack.yaml.lock trivially to invalidate macOS cache 2022-09-05 17:57:50 +02:00
Robert Vollmert ba1fcfd1e3 feat: introduce pool acquisition timeout (fixes #2348)
The configuration option db-pool-acquisition-timeout
specifies the time in seconds to wait for the pool to
free up a connection slot. Otherwise, a 504 error is
returned. By default, there is no timeout.
2022-08-31 19:54:34 +02:00
Robert Vollmert 554db21f49 tests: fix 'no_pool_connections_available' (fixes #2456) 2022-08-31 19:13:53 +02:00
Robert Vollmert ebc34561fa tests: show that 'no_pool_connections_available' is broken 2022-08-31 19:13:53 +02:00
Robert Vollmert 5ae9a2b1cf tests: split test_io.py 2022-08-31 19:13:53 +02:00
Robert Vollmert e2aa227597 upgrade to hasql-1.6, hasql-pool-0.8.0.2
- hasql-pool-0.8
  * 'release' now flushes the pool, and no longer destroys it;
    'PoolIsReleasedUsageError' is gone compared to our fork
  * now supports an acquisition timeout, which we don't use
    yet
  * lower bound on 0.8.0.2 to fix a Windows build issue
- hasql-1.6
  introduces a position parameter to ServerError, which we
  ignore
2022-08-30 20:59:26 +02:00
Robert Vollmert 81f42d4b8a changelog: repair unreleased section 2022-08-29 17:01:45 +02:00
Robert Vollmert 90eaaefe12 upgrade to fork of hasql-pool 0.7.2 (fixes #2401)
This version of hasql-pool is a simplified rewrite that doesn't use
the resource-pool package. The major API changes are that idle
connections are no longer timed out (and the corresponding setting
is gone), and that `release` makes the pool unusable, where it used
to remain usable and only flushed idle connections.

We depend on a PostgREST fork of 0.7.2 that gives us reliable
flushing, compare https://github.com/PostgREST/hasql-pool/pull/1

- hasql-pool 0.7 removes timing out of idle connections, so
  this change removes the db-pool-timeout option.
  Given that we were typically running with very high
  timeout settings, I don't anticipate the lack of timeout
  to introduce new issues, though we might want to consider
  introducing some retry-logic down the line when we
  encounter connection failures.
- See https://github.com/PostgREST/postgrest/issues/2422 for a
  discussion on depending on a forked dependency. Besides adding
  the dependency to the nix overlay, we're also adding it to
  stack.yaml and a new cabal.project to allow stack/cabal users
  to build the project.
2022-08-29 14:55:09 +02:00
Robert Vollmert cdce929159 refactor: more accurate naming and documentation for connectionStatus 2022-08-29 14:55:09 +02:00
Robert Vollmert 79b865ba79 refactor: rename releasePool to flushPool and document its intended behaviour
Also fix documentation of AppState pool field.
2022-08-29 14:55:09 +02:00
Robert Vollmert 8e96e3b3ae ci: install git for cirrus to allow git repo dependencies 2022-08-29 14:55:09 +02:00
Robert Vollmert 0595e564da tests: update app settings test
The original test no longer makes sense once we drop pool timeouts
with the hasql-pool upgrade.

To somehow test that new connections have the settings, convert it
to flush the pool instead.
2022-08-29 14:55:09 +02:00
Robert Vollmert 9bb0bc1750 tests: validate that requests survive SIGUSR1 2022-08-29 14:55:09 +02:00
Laurence IslaandGitHub 950070ce4e Deprectate bulk-call 2022-08-26 12:09:47 -05:00
Robert Vollmert 3b290d524c Add a CI check for building with Cabal
The idea is that this ensures
1. that it's easy for contributers to hack on PostgREST without
   getting nix set up
2. we see things break more easily outside the very controlled nix
   environment (e.g. it would be easy to add a GHC 9.4.2 build here)
2022-08-24 23:50:00 +02:00
Robert Vollmert 906fac2dd6 Consistently use the same checkout action 2022-08-24 23:50:00 +02:00
Felix YanandSteve Chavez 377944502d Correct typos in QueryParams.hs 2022-08-20 16:52:34 -05:00
97 changed files with 3920 additions and 2283 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ freebsd_instance:
build_task: build_task:
name: Build FreeBSD (Stack) name: Build FreeBSD (Stack)
install_script: pkg install -y postgresql13-client hs-stack install_script: pkg install -y postgresql13-client hs-stack git
stack_cache: stack_cache:
folders: /.stack folders: /.stack
+2 -2
View File
@@ -11,8 +11,8 @@ inputs:
runs: runs:
using: composite using: composite
steps: steps:
- uses: cachix/install-nix-action@v16 - uses: cachix/install-nix-action@v18
- uses: cachix/cachix-action@v10 - uses: cachix/cachix-action@v12
with: with:
name: postgrest name: postgrest
authToken: ${{ inputs.authToken }} authToken: ${{ inputs.authToken }}
+36 -6
View File
@@ -43,10 +43,10 @@ jobs:
with: with:
tools: tests tools: tests
- name: Run coverage (IO tests and Spec tests against PostgreSQL 14) - name: Run coverage (IO tests and Spec tests against PostgreSQL 15)
run: postgrest-coverage run: postgrest-coverage
- name: Upload coverage to codecov - name: Upload coverage to codecov
uses: codecov/codecov-action@v3.1.0 uses: codecov/codecov-action@v3.1.1
with: with:
files: ./coverage/codecov.json files: ./coverage/codecov.json
@@ -63,7 +63,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
pgVersion: [9.6, 10, 11, 12, 13, 14] pgVersion: [9.6, 10, 11, 12, 13, 14, 15]
name: Test PG ${{ matrix.pgVersion }} (Nix) name: Test PG ${{ matrix.pgVersion }} (Nix)
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
@@ -148,14 +148,14 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- name: Linux & test - name: Linux
runs-on: ubuntu-latest runs-on: ubuntu-latest
cache: | cache: |
~/.stack ~/.stack
.stack-work .stack-work
artifact: postgrest-ubuntu-x64 artifact: postgrest-ubuntu-x64
- name: MacOS & test - name: MacOS
runs-on: macos-latest runs-on: macos-latest
cache: | cache: |
~/.stack ~/.stack
@@ -211,6 +211,36 @@ jobs:
path: postgrest path: postgrest
if-no-files-found: error if-no-files-found: error
Build-Cabal:
strategy:
matrix:
ghc: ['8.10.7', '9.2.4']
fail-fast: false
name: Build Linux (Cabal, GHC ${{ matrix.ghc }})
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: ghcup
run: |
ghcup install ghc ${{ matrix.ghc }}
ghcup set ghc ${{ matrix.ghc }}
- name: Copy cabal.project
run: |
cp cabal.project.non-nix cabal.project
- name: Cache
uses: actions/cache@v3
with:
path: ~/.cabal
key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('**/*.cabal') }}-${{ hashFiles('**/cabal.project') }}
restore-keys: |
${{ runner.os }}-${{ matrix.ghc }}-
- name: Install dependencies
run: |
cabal update
cabal build --only-dependencies --enable-tests --enable-benchmarks
- name: Build
run: cabal build --enable-tests --enable-benchmarks all
Build-Cabal-Arm: Build-Cabal-Arm:
name: Build aarch64 (Cabal) name: Build aarch64 (Cabal)
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/rel-') }} if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/rel-') }}
@@ -220,7 +250,7 @@ jobs:
env: env:
GITHUB_COMMIT: ${{ github.sha }} GITHUB_COMMIT: ${{ github.sha }}
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- id: Remote-Dir - id: Remote-Dir
name: Unique directory name for the remote build name: Unique directory name for the remote build
run: echo "::set-output name=remotepath::postgrest-build-$(uuidgen)" run: echo "::set-output name=remotepath::postgrest-build-$(uuidgen)"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
name: loadtest.md name: loadtest.md
path: artifacts path: artifacts
- name: Upload to GitHub Checks - name: Upload to GitHub Checks
uses: LouisBrunner/checks-action@v1.2.0 uses: LouisBrunner/checks-action@v1.5.0
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
sha: ${{ github.event.workflow_run.head_sha }} sha: ${{ github.event.workflow_run.head_sha }}
+70
View File
@@ -0,0 +1,70 @@
# Architecture
This document describes the high-level architecture of PostgREST.
## Bird's Eye View
```haskell
postgrest :: Request -> Either Error SQLStatement -> Response
```
On the highest level, PostgREST processes an HTTP request, if it's accepted it builds a SQL statement for it, executes it, and produces a response.
## Code Map
This section talks briefly about various important modules.
The starting point of the program is `main/Main.hs`, which calls `src/PostgREST/CLI.hs` which then calls `src/PostgREST/App.hs`.
`App.hs` is then in charge of composing the different modules.
### ApiRequest.hs
PostgREST operates over two types of resources: database relations(tables or views) and database functions; providing different representations(depending on the media type)
for them.
This module is in charge of representing the operation over an `ApiRequest` type. It parses the URL querystring following PostgREST syntax, the request headers, and the request body
(if possible it avoids parsing the body and sends it directly to the db).
A request might be rejected at this level if it's invalid, e.g. providing an unknown media type to PostgREST or using an unknown HTTP method.
### Plan.hs
Using the Schema Cache, this module enables more complex functionality(like resource embedding) by enriching the ApiRequest. It generates Plan types(`ReadPlan`, `MutatePlan`)
that then will be used to generate a SQL statement.
A request might be rejected at this level if it's invalid, e.g. by doing resource embedding on a nonexistent resource.
An OPTIONS request doesn't require a plan to be generated.
### Query.hs
This module constructs single SQL statements that can be parametrized and prepared. Only at this stage a PostgreSQL connection from the pool is used.
A query might fail(and be rollbacked) at this level if it doesn't comply to certain conditions, e.g. by not returning a single row when a ``Accept: application/vnd.pgrst.object`` header is specified.
An OPTIONS request doesn't require a query to be executed.
### Response.hs
This module constructs the HTTP response body with the right headers.
It builds the OpenAPI response using the schema cache.
### Auth.hs
This module provides functions to deal with JWT authorization.
### Workers.hs
This spawns threads which are used to execute concurrent jobs.
Jobs include connection recovery, a listener for the PostgreSQL LISTEN command, and an admin server.
### SchemaCache.hs
This queries the PostgreSQL system catalogs and caches the metadata into a SchemaCache type,
### AppState.hs
The state of the App which is kept across requests.
+56 -1
View File
@@ -3,7 +3,61 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased ## [10.1.2] - 2023-02-01
### Fixed
- #2565, Fix bad M2M embedding on RPC - @steve-chavez
- #2575, Replace misleading error message when no function is found with a hint containing functions/parameters names suggestions - @laurenceisla
- #2582, Move explanation about "single parameters" from the `message` to the `details` in the error output - @laurenceisla
- #2569, Replace misleading error message when no relationship is found with a hint containing parent/child names suggestions - @laurenceisla
- #1405, Add the required OpenAPI items object when the parameter is an array - @laurenceisla
- #2592, Add upsert headers for POST requests to the OpenAPI output - @laurenceisla
- #2623, Fix FK pointing to VIEW instead of TABLE in OpenAPI output - @laurenceisla
- #2622, Consider any PostgreSQL authentication failure as fatal and exit immediately - @michivi
- #2620, Fix `NOTIFY pgrst` not reloading the db connections catalog cache - @steve-chavez
## [10.1.1] - 2022-11-08
### Fixed
- #2548, Fix regression when embedding views with partial references to multi column FKs - @wolfgangwalther
- #2558, Fix regression when requesting limit=0 and `db-max-row` is set - @laurenceisla
## [10.1.0] - 2022-10-28
### Added
- #2348, Add `db-pool-acquisition-timeout` configuration option, time in seconds to wait to acquire a connection. - @robx
### Fixed
- #2261, #2349, #2467, Reduce allocations communication with PostgreSQL, particularly for request bodies. - @robx
- #2401, #2444, Fix SIGUSR1 to fully flush connections pool. - @robx
- #2428, Fix opening an empty transaction on failed resource embedding - @steve-chavez
- #2455, Fix embedding the same table multiple times - @steve-chavez
- #2518, Fix a regression when embedding views where base tables have a different column order for FK columns - @wolfgangwalther
- #2458, Fix a regression with the location header when inserting into views with PKs from multiple tables - @wolfgangwalther
- #2356, Fix a regression in openapi output with mode follow-privileges - @wolfgangwalther
- #2283, Fix infinite recursion when loading schema cache with self-referencing view - @wolfgangwalther
- #2343, Return status code 200 for PATCH requests which don't affect any rows - @wolfgangwalther
- #2481, Treat computed relationships not marked SETOF as M2O/O2O relationship - @wolfgangwalther
- #2534, Fix embedding a computed relationship with a normal relationship - @steve-chavez
- #2362, Fix error message when [] is used inside select - @wolfgangwalther
- #2475, Disallow !inner on computed columns - @wolfgangwalther
- #2285, Ignore leading and trailing spaces in column names when parsing the query string - @wolfgangwalther
- #2545, Fix UPSERT with PostgreSQL 15 - @wolfgangwalther
- #2459, Fix embedding views with multiple references to the same base column - @wolfgangwalther
### Changed
- #2444, Removed `db-pool-timeout` option, because this was removed upstream in hasql-pool. - @robx
- #2343, PATCH requests that don't affect any rows no longer return 404 - @wolfgangwalther
- #2537, Stricter parsing of query string. Instead of silently ignoring, the parser now throws on invalid syntax like json paths for embeddings, hints for regular columns, empty casts or fts languages, etc. - @wolfgangwalther
### Deprecated
- #1385, Deprecate bulk-calls when including the `Prefer: params=multiple-objects` in the request. A function with a JSON array or object parameter should be used instead for a better performance.
## [10.0.0] - 2022-08-18 ## [10.0.0] - 2022-08-18
@@ -70,6 +124,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #2410, Fix loop crash error on startup in Postgres 15 beta 3. Log: "UNION types \"char\" and text cannot be matched". - @yevon - #2410, Fix loop crash error on startup in Postgres 15 beta 3. Log: "UNION types \"char\" and text cannot be matched". - @yevon
- #2397, Fix race conditions managing database connection helper - @robx - #2397, Fix race conditions managing database connection helper - @robx
- #2269, Allow `limit=0` in the request query to return an empty array - @gautam1168, @laurenceisla - #2269, Allow `limit=0` in the request query to return an empty array - @gautam1168, @laurenceisla
- #2401, Ensure database connections can't outlive SIGUSR1 - @robx
### Changed ### Changed
+20
View File
@@ -0,0 +1,20 @@
-- Settings to allow building with plain cabal. If this was
-- named just cabal.project, it would interfere with the default
-- nix build.
packages: .
-- Example of depending on a forked repository (the same dependency
-- would be mentioned in nix/overlays/haskell-packages.nix and
-- stack.yaml, and should refer to a main branch commit of the
-- repository.
--
-- source-repository-package
-- type: git
-- location: https://github.com/PostgREST/hasql-pool.git
-- tag: 4d462c4d47d762effefc7de6c85eaed55f144f1d
source-repository-package
type: git
location: https://github.com/PostgREST/postgresql-libpq.git
tag: 33ff97db570b5b432255f5f24a68db51453f6eb8
+3 -1
View File
@@ -36,7 +36,8 @@ let
allOverlays.build-toolbox allOverlays.build-toolbox
allOverlays.checked-shell-script allOverlays.checked-shell-script
allOverlays.gitignore allOverlays.gitignore
allOverlays.postgresql-default allOverlays.postgis
(allOverlays.postgresql-default { inherit patches; })
allOverlays.postgresql-legacy allOverlays.postgresql-legacy
allOverlays.postgresql-future allOverlays.postgresql-future
(allOverlays.haskell-packages { inherit compiler; }) (allOverlays.haskell-packages { inherit compiler; })
@@ -48,6 +49,7 @@ let
postgresqlVersions = postgresqlVersions =
[ [
{ 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 ]); }
{ name = "postgresql-12"; postgresql = pkgs.postgresql_12.withPackages (p: [ p.postgis p.pg_safeupdate ]); } { name = "postgresql-12"; postgresql = pkgs.postgresql_12.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
+3 -3
View File
@@ -1,6 +1,6 @@
# Pinned version of Nixpkgs, generated with postgrest-nixpkgs-upgrade. # Pinned version of Nixpkgs, generated with postgrest-nixpkgs-upgrade.
{ {
date = "2022-08-09"; date = "2022-10-28";
rev = "9f15d6c3a74d2778c6e1af67947c95f100dc6fd2"; rev = "f44ba1be526c8da9e79a5759feca2365204003f6";
tarballHash = "14axdmi3kb6rlib39ik42yq907bm66x6vzswm5w1rsnw9vzgm31a"; tarballHash = "0npbwsdjw88py5w2pjflwh94wgi4jmnmls0k1n7q8m6h94w1y1ps";
} }
@@ -2,7 +2,7 @@
# directly, or use the .bin attribute to get the script in a bin/ directory, # directly, or use the .bin attribute to get the script in a bin/ directory,
# to be used in a path for example. # to be used in a path for example.
{ argbash { argbash
, bash_5 , bash
, coreutils , coreutils
, git , git
, lib , lib
@@ -77,7 +77,7 @@ let
text = text =
'' ''
#!${bash_5}/bin/bash #!${bash}/bin/bash
source ${argsParser} source ${argsParser}
set -euo pipefail set -euo pipefail
'' ''
+1
View File
@@ -3,6 +3,7 @@
checked-shell-script = import ./checked-shell-script; checked-shell-script = import ./checked-shell-script;
gitignore = import ./gitignore.nix; gitignore = import ./gitignore.nix;
haskell-packages = import ./haskell-packages.nix; haskell-packages = import ./haskell-packages.nix;
postgis = import ./postgis.nix;
postgresql-default = import ./postgresql-default.nix; postgresql-default = import ./postgresql-default.nix;
postgresql-legacy = import ./postgresql-legacy.nix; postgresql-legacy = import ./postgresql-legacy.nix;
postgresql-future = import ./postgresql-future.nix; postgresql-future = import ./postgresql-future.nix;
+31 -6
View File
@@ -13,13 +13,10 @@ let
# { # {
# pkg = "protolude"; # pkg = "protolude";
# ver = "0.3.0"; # ver = "0.3.0";
# sha256 = "0iwh4wsjhb7pms88lw1afhdal9f86nrrkkvv65f9wxbd1b159n72"; # sha256 = "<sha256>";
# } # }
# { }; # { };
# #
# To get the sha256:
# nix-prefetch-url --unpack https://hackage.haskell.org/package/protolude-0.3.0/protolude-0.3.0.tar.gz
# To temporarily pin unreleased versions from GitHub: # To temporarily pin unreleased versions from GitHub:
# <name> = # <name> =
# prev.callCabal2nixWithOptions "<name>" (super.fetchFromGitHub { # prev.callCabal2nixWithOptions "<name>" (super.fetchFromGitHub {
@@ -29,8 +26,36 @@ let
# sha256 = "<sha256>"; # sha256 = "<sha256>";
# }) "--subpath=<subpath>" {}; # }) "--subpath=<subpath>" {};
# #
# To get the sha256: # To fill in the sha256:
# nix-prefetch-url --unpack https://github.com/<owner>/<repo>/archive/<commit>.tar.gz # update-nix-fetchgit nix/overlays/haskell-packages.nix
hashtables = lib.dontCheck prev.hashtables_1_3_1;
hasql = lib.dontCheck prev.hasql_1_6_1_4;
hasql-dynamic-statements = lib.dontCheck prev.hasql-dynamic-statements_0_3_1_2;
hasql-pool = lib.dontCheck
(prev.callHackageDirect
{
pkg = "hasql-pool";
ver = "0.8.0.6";
sha256 = "sha256-2u/cwPk8XfXffaDRzGeyzhL+9k2+2T4b8bGOZwz8AX0=";
}
{ });
hasql-transaction = lib.dontCheck prev.hasql-transaction_1_0_1_2;
isomorphism-class = lib.unmarkBroken prev.isomorphism-class;
lens = lib.dontCheck prev.lens_5_2;
postgresql-binary = lib.dontCheck prev.postgresql-binary_0_13_1;
text-builder = lib.dontCheck prev.text-builder_0_6_7;
text-builder-dev = lib.dontCheck prev.text-builder-dev_0_3_3;
postgresql-libpq = lib.dontCheck
(prev.callCabal2nix "postgresql-libpq"
(super.fetchFromGitHub {
owner = "PostgREST";
repo = "postgresql-libpq";
rev = "cef92cb4c07b56568dffdbf4b719258b82183119"; # master
sha256 = "0r59klrz47qcnd22s47h612mlz3jbg40wwalfj3f6djwg0cdyr85";
})
{ });
} // extraOverrides final prev; } // extraOverrides final prev;
in in
{ {
+27
View File
@@ -0,0 +1,27 @@
final: prev:
let
postgis_3_2_3 = rec {
version = "3.2.3";
src = final.fetchurl {
url = "https://download.osgeo.org/postgis/source/postgis-${version}.tar.gz";
sha256 = "sha256-G02LXHVuWrpZ77wYM7Iu/k1lYneO7KVvpJf+susTZow=";
};
};
in
{
postgresql_11 = prev.postgresql_11.override { this = final.postgresql_11; } // {
pkgs = prev.postgresql_11.pkgs // {
postgis = prev.postgresql_11.pkgs.postgis.overrideAttrs (_: postgis_3_2_3);
};
};
postgresql_10 = prev.postgresql_10.override { this = final.postgresql_11; } // {
pkgs = prev.postgresql_10.pkgs // {
postgis = prev.postgresql_10.pkgs.postgis.overrideAttrs (_: postgis_3_2_3);
};
};
postgresql_9_6 = prev.postgresql_9_6.override { this = final.postgresql_11; } // {
pkgs = prev.postgresql_9_6.pkgs // {
postgis = prev.postgresql_9_6.pkgs.postgis.overrideAttrs (_: postgis_3_2_3);
};
};
}
+5 -2
View File
@@ -1,5 +1,8 @@
self: super: { patches }: self: super:
# Overlay that sets the default version of PostgreSQL. # Overlay that sets the default version of PostgreSQL.
with patches;
{ {
postgresql = super.postgresql_14; postgresql = super.postgresql_15.overrideAttrs ({ patches ? [ ], ... }: {
patches = patches ++ [ postgresql-atexit ];
});
} }
+15
View File
@@ -16,4 +16,19 @@ self: super:
}; };
in in
(import pinnedPkgs { }).pkgs.postgresql_9_6; (import pinnedPkgs { }).pkgs.postgresql_9_6;
# PostgreSQL 10 was removed from Nixpkgs with
# https://github.com/NixOS/nixpkgs/commit/aa1483114bb329fee7e1266100b8d8921ed4723f
# We pin its parent commit to get the last version that was available.
postgresql_10 =
let
rev = "79661ba7e2fb96ebefbb537458a5bbae9dc5bd1a";
tarballHash = "0rn796pfn4sg90ai9fdnwmr10a2s835p1arazzgz46h6s5cxvq97";
pinnedPkgs =
builtins.fetchTarball {
url = "https://github.com/nixos/nixpkgs/archive/${rev}.tar.gz";
sha256 = tarballHash;
};
in
(import pinnedPkgs { }).pkgs.postgresql_10;
} }
+4
View File
@@ -22,4 +22,8 @@
./static-haskell-nix-ncurses.patch; ./static-haskell-nix-ncurses.patch;
static-haskell-nix-ghc-bignum = static-haskell-nix-ghc-bignum =
./static-haskell-nix-ghc-bignum.patch; ./static-haskell-nix-ghc-bignum.patch;
static-haskell-nix-openssl =
./static-haskell-nix-openssl.patch;
postgresql-atexit =
./postgresql-atexit.patch;
} }
+11
View File
@@ -0,0 +1,11 @@
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -118,7 +118,7 @@ backend_src = $(top_srcdir)/src/backend
libpq-refs-stamp: $(shlib)
ifneq ($(enable_coverage), yes)
ifeq (,$(filter aix solaris,$(PORTNAME)))
- @if nm -A -u $< 2>/dev/null | grep -v __cxa_atexit | grep exit; then \
+ @if nm -A -u $< 2>/dev/null | grep " exit"; then \
echo 'libpq must not be calling any function which invokes exit'; exit 1; \
fi
endif
@@ -0,0 +1,12 @@
diff --git a/survey/default.nix b/survey/default.nix
index cf1bd31..9d34753 100644
--- a/survey/default.nix
+++ b/survey/default.nix
@@ -736,6 +736,7 @@ let
openblas = previous.openblas.override { enableStatic = true; };
openssl = previous.openssl.override { static = true; };
+ openssl_1_1 = previous.openssl_1_1.override { static = true; };
libsass = previous.libsass.overrideAttrs (old: { dontDisableStatic = true; });
+2 -1
View File
@@ -19,6 +19,7 @@ let
[ [
patches.static-haskell-nix-ncurses patches.static-haskell-nix-ncurses
patches.static-haskell-nix-ghc-bignum patches.static-haskell-nix-ghc-bignum
patches.static-haskell-nix-openssl
]; ];
extraOverrides = extraOverrides =
@@ -34,7 +35,7 @@ let
overlays = overlays =
[ [
allOverlays.postgresql-future allOverlays.postgresql-future
allOverlays.postgresql-default (allOverlays.postgresql-default { inherit patches; })
(allOverlays.haskell-packages { inherit compiler extraOverrides; }) (allOverlays.haskell-packages { inherit compiler extraOverrides; })
# Disable failing tests for postgresql on musl that should have no impact # Disable failing tests for postgresql on musl that should have no impact
# on the libpq that we need (collate.icu.utf8 and foreign regression # on the libpq that we need (collate.icu.utf8 and foreign regression
+15
View File
@@ -93,3 +93,18 @@ Image efficiency score: 100 %
Count Total Space Path Count Total Space Path
``` ```
# Deriving from the optimized image
Since the docker image is minimal, it does not contain a shell or other utilities.
To derive a non-minimal image, you can do the following:
```Dockerfile
# derive from any base image you want
FROM alpine:latest
# copy PostgREST over
COPY --from=postgrest/postgrest /bin/postgrest /bin
# add your other stuff
```
+1 -1
View File
@@ -8,7 +8,7 @@ let
dockerTools.buildImage { dockerTools.buildImage {
name = "postgrest"; name = "postgrest";
tag = "latest"; tag = "latest";
contents = postgrest; copyToRoot = postgrest;
# Set the current time as the image creation date. This makes the build # Set the current time as the image creation date. This makes the build
# non-reproducible, but that should not be an issue for us. # non-reproducible, but that should not be an issue for us.
+2 -1
View File
@@ -1,5 +1,6 @@
{ buildToolbox { buildToolbox
, checkedShellScript , checkedShellScript
, coreutils
, curl , curl
, jq , jq
, nix , nix
@@ -33,7 +34,7 @@ let
commitHash="$(${curl}/bin/curl "${refUrl}" -H "${githubV3Header}" | ${jq}/bin/jq -r .object.sha)" commitHash="$(${curl}/bin/curl "${refUrl}" -H "${githubV3Header}" | ${jq}/bin/jq -r .object.sha)"
tarballUrl="${tarballUrlBase}$commitHash.tar.gz" tarballUrl="${tarballUrlBase}$commitHash.tar.gz"
tarballHash="$(${nix}/bin/nix-prefetch-url --unpack "$tarballUrl")" tarballHash="$(${nix}/bin/nix-prefetch-url --unpack "$tarballUrl")"
currentDate="$(date --iso)" currentDate="$(${coreutils}/bin/date --iso)"
cat > nix/nixpkgs-version.nix << EOF cat > nix/nixpkgs-version.nix << EOF
# Pinned version of Nixpkgs, generated with ${name}. # Pinned version of Nixpkgs, generated with ${name}.
+11 -2
View File
@@ -51,7 +51,7 @@ let
checkedShellScript checkedShellScript
{ {
name = "postgrest-release"; name = "postgrest-release";
docs = "Patch postgrest.cabal, tag and push all in one go."; docs = "Patch postgrest.cabal, CHANGELOG.md, tag and push all in one go.";
args = [ "ARG_POSITIONAL_SINGLE([version], [Version to release], [pre])" ]; args = [ "ARG_POSITIONAL_SINGLE([version], [Version to release], [pre])" ];
inRootDir = true; inRootDir = true;
} }
@@ -69,7 +69,9 @@ let
IFS=. read -r major minor patch pre <<< "$current_version" IFS=. read -r major minor patch pre <<< "$current_version"
echo "Current version is $current_version" echo "Current version is $current_version"
bump_pre="$major.$minor.$patch.$(date '+%Y%m%d')" today_date="$(date '+%Y%m%d')"
today_date_for_changelog="$(date '+%Y-%m-%d')"
bump_pre="$major.$minor.$patch.$today_date"
bump_patch="$major.$minor.$((patch+1))" bump_patch="$major.$minor.$((patch+1))"
bump_minor="$major.$((minor+1)).0" bump_minor="$major.$((minor+1)).0"
bump_major="$((major+1)).0.0" bump_major="$((major+1)).0.0"
@@ -92,6 +94,13 @@ let
echo "Committing ..." echo "Committing ..."
git add postgrest.cabal > /dev/null git add postgrest.cabal > /dev/null
if [[ "$new_version" != "$bump_pre" ]]; then
echo "Updating CHANGELOG.md ..."
sed -i -E "s/Unreleased/&\n\n## [$new_version] - $today_date_for_changelog/" CHANGELOG.md > /dev/null
git add CHANGELOG.md > /dev/null
fi
git commit -m "bump version to $new_version" > /dev/null git commit -m "bump version to $new_version" > /dev/null
echo "Tagging ..." echo "Tagging ..."
+2 -2
View File
@@ -80,7 +80,7 @@ let
python3.withPackages (ps: [ python3.withPackages (ps: [
ps.pyjwt ps.pyjwt
ps.pytest ps.pytest
ps.pytest_xdist ps.pytest-xdist
ps.pyyaml ps.pyyaml
ps.requests ps.requests
ps.requests-unixsocket ps.requests-unixsocket
@@ -105,7 +105,7 @@ let
checkedShellScript checkedShellScript
{ {
name = "postgrest-dump-schema"; name = "postgrest-dump-schema";
docs = "Dump the loaded schema's DbStructure as a yaml file."; docs = "Dump the loaded schema's SchemaCache as a yaml file.";
inRootDir = true; inRootDir = true;
withEnv = postgrest.env; withEnv = postgrest.env;
withPath = [ jq ]; withPath = [ jq ];
+5 -1
View File
@@ -54,6 +54,10 @@ let
export PGDATABASE export PGDATABASE
export PGRST_DB_SCHEMAS export PGRST_DB_SCHEMAS
HBA_FILE="$tmpdir/pg_hba.conf"
echo "local $PGDATABASE some_protected_user password" > "$HBA_FILE"
echo "local $PGDATABASE all trust" >> "$HBA_FILE"
log "Initializing database cluster..." log "Initializing database cluster..."
# 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.
@@ -62,7 +66,7 @@ let
log "Starting the database cluster..." log "Starting the database cluster..."
# Instead of listening on a local port, we will listen on a unix domain socket. # Instead of listening on a local port, we will listen on a unix domain socket.
pg_ctl -l "$tmpdir/db.log" -w start -o "-F -c listen_addresses=\"\" -k $PGHOST -c log_statement=\"all\"" \ pg_ctl -l "$tmpdir/db.log" -w start -o "-F -c listen_addresses=\"\" -c hba_file=$HBA_FILE -k $PGHOST -c log_statement=\"all\"" \
>> "$setuplog" >> "$setuplog"
stop () { stop () {
+32 -30
View File
@@ -1,5 +1,5 @@
name: postgrest name: postgrest
version: 10.0.0 version: 10.1.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
@@ -35,7 +35,6 @@ library
NoImplicitPrelude NoImplicitPrelude
hs-source-dirs: src hs-source-dirs: src
exposed-modules: PostgREST.App exposed-modules: PostgREST.App
PostgREST.Admin
PostgREST.AppState PostgREST.AppState
PostgREST.Auth PostgREST.Auth
PostgREST.CLI PostgREST.CLI
@@ -45,35 +44,37 @@ library
PostgREST.Config.PgVersion PostgREST.Config.PgVersion
PostgREST.Config.Proxy PostgREST.Config.Proxy
PostgREST.Cors PostgREST.Cors
PostgREST.DbStructure PostgREST.SchemaCache
PostgREST.DbStructure.Identifiers PostgREST.SchemaCache.Identifiers
PostgREST.DbStructure.Proc PostgREST.SchemaCache.Proc
PostgREST.DbStructure.Relationship PostgREST.SchemaCache.Relationship
PostgREST.DbStructure.Table PostgREST.SchemaCache.Table
PostgREST.Error PostgREST.Error
PostgREST.GucHeader
PostgREST.Logger PostgREST.Logger
PostgREST.Middleware
PostgREST.MediaType PostgREST.MediaType
PostgREST.OpenAPI PostgREST.Query
PostgREST.Query.QueryBuilder PostgREST.Query.QueryBuilder
PostgREST.Query.SqlFragment PostgREST.Query.SqlFragment
PostgREST.Query.Statements PostgREST.Query.Statements
PostgREST.Plan
PostgREST.Plan.CallPlan
PostgREST.Plan.MutatePlan
PostgREST.Plan.ReadPlan
PostgREST.RangeQuery PostgREST.RangeQuery
PostgREST.Request.ApiRequest PostgREST.ApiRequest
PostgREST.Request.DbRequestBuilder PostgREST.ApiRequest.Preferences
PostgREST.Request.MutateQuery PostgREST.ApiRequest.QueryParams
PostgREST.Request.Preferences PostgREST.ApiRequest.Types
PostgREST.Request.QueryParams PostgREST.Response
PostgREST.Request.ReadQuery PostgREST.Response.OpenAPI
PostgREST.Request.Types PostgREST.Response.GucHeader
PostgREST.Version PostgREST.Version
PostgREST.Workers PostgREST.Workers
other-modules: Paths_postgrest other-modules: Paths_postgrest
build-depends: base >= 4.9 && < 4.17 build-depends: base >= 4.9 && < 4.17
, HTTP >= 4000.3.7 && < 4000.4 , HTTP >= 4000.3.7 && < 4000.4
, Ranged-sets >= 0.3 && < 0.5 , Ranged-sets >= 0.3 && < 0.5
, aeson >= 2.0.3 && < 2.1 , aeson >= 2.0.3 && < 2.2
, 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
@@ -84,18 +85,19 @@ library
, 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
, either >= 4.4.1 && < 5.1 , either >= 4.4.1 && < 5.1
, fuzzyset >= 0.2.3
, gitrev >= 1.2 && < 1.4 , gitrev >= 1.2 && < 1.4
, hasql >= 1.4 && < 1.6 , 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.1 && < 0.3
, hasql-pool >= 0.5 && < 0.6 , hasql-pool >= 0.8.0.6 && < 0.9
, hasql-transaction >= 1.0.1 && < 1.1 , hasql-transaction >= 1.0.1 && < 1.1
, heredoc >= 0.2 && < 0.3 , heredoc >= 0.2 && < 0.3
, http-types >= 0.12.2 && < 0.13 , http-types >= 0.12.2 && < 0.13
, insert-ordered-containers >= 0.2.2 && < 0.3 , insert-ordered-containers >= 0.2.2 && < 0.3
, interpolatedstring-perl6 >= 1 && < 1.1 , interpolatedstring-perl6 >= 1 && < 1.1
, jose >= 0.8.5.1 && < 0.10 , jose >= 0.8.5.1 && < 0.11
, lens >= 4.14 && < 5.2 , lens >= 4.14 && < 5.3
, lens-aeson >= 1.0.1 && < 1.2 , lens-aeson >= 1.0.1 && < 1.2
, mtl >= 2.2.2 && < 2.3 , mtl >= 2.2.2 && < 2.3
, network >= 2.6 && < 3.2 , network >= 2.6 && < 3.2
@@ -111,7 +113,7 @@ library
, time >= 1.6 && < 1.12 , time >= 1.6 && < 1.12
, unordered-containers >= 0.2.8 && < 0.3 , unordered-containers >= 0.2.8 && < 0.3
, vault >= 0.3.1.5 && < 0.4 , vault >= 0.3.1.5 && < 0.4
, vector >= 0.11 && < 0.13 , vector >= 0.11 && < 0.14
, wai >= 3.2.1 && < 3.3 , wai >= 3.2.1 && < 3.3
, wai-cors >= 0.2.5 && < 0.3 , wai-cors >= 0.2.5 && < 0.3
, wai-extra >= 3.1.8 && < 3.2 , wai-extra >= 3.1.8 && < 3.2
@@ -218,7 +220,7 @@ test-suite spec
SpecHelper SpecHelper
TestTypes TestTypes
build-depends: base >= 4.9 && < 4.17 build-depends: base >= 4.9 && < 4.17
, aeson >= 2.0.3 && < 2.1 , aeson >= 2.0.3 && < 2.2
, aeson-qq >= 0.8.1 && < 0.9 , aeson-qq >= 0.8.1 && < 0.9
, async >= 2.1.1 && < 2.3 , async >= 2.1.1 && < 2.3
, auto-update >= 0.1.4 && < 0.2 , auto-update >= 0.1.4 && < 0.2
@@ -226,14 +228,14 @@ test-suite spec
, bytestring >= 0.10.8 && < 0.12 , bytestring >= 0.10.8 && < 0.12
, case-insensitive >= 1.2 && < 1.3 , case-insensitive >= 1.2 && < 1.3
, containers >= 0.5.7 && < 0.7 , containers >= 0.5.7 && < 0.7
, hasql-pool >= 0.5 && < 0.6 , hasql-pool >= 0.8.0.2 && < 0.9
, hasql-transaction >= 1.0.1 && < 1.1 , hasql-transaction >= 1.0.1 && < 1.1
, heredoc >= 0.2 && < 0.3 , heredoc >= 0.2 && < 0.3
, hspec >= 2.3 && < 2.9 , hspec >= 2.3 && < 2.9
, hspec-wai >= 0.10 && < 0.12 , hspec-wai >= 0.10 && < 0.12
, hspec-wai-json >= 0.10 && < 0.12 , hspec-wai-json >= 0.10 && < 0.12
, http-types >= 0.12.3 && < 0.13 , http-types >= 0.12.3 && < 0.13
, lens >= 4.14 && < 5.2 , lens >= 4.14 && < 5.3
, lens-aeson >= 1.0.1 && < 1.2 , lens-aeson >= 1.0.1 && < 1.2
, monad-control >= 1.0.1 && < 1.1 , monad-control >= 1.0.1 && < 1.1
, postgrest , postgrest
@@ -261,22 +263,22 @@ test-suite querycost
main-is: QueryCost.hs main-is: QueryCost.hs
other-modules: SpecHelper other-modules: SpecHelper
build-depends: base >= 4.9 && < 4.17 build-depends: base >= 4.9 && < 4.17
, aeson >= 2.0.3 && < 2.1 , aeson >= 2.0.3 && < 2.2
, base64-bytestring >= 1 && < 1.3 , base64-bytestring >= 1 && < 1.3
, bytestring >= 0.10.8 && < 0.12 , bytestring >= 0.10.8 && < 0.12
, case-insensitive >= 1.2 && < 1.3 , case-insensitive >= 1.2 && < 1.3
, containers >= 0.5.7 && < 0.7 , containers >= 0.5.7 && < 0.7
, contravariant >= 1.4 && < 1.6 , contravariant >= 1.4 && < 1.6
, hasql >= 1.4 && < 1.6 , hasql >= 1.6 && < 1.7
, hasql-dynamic-statements >= 0.3.1 && < 0.4 , hasql-dynamic-statements >= 0.3.1 && < 0.4
, hasql-pool >= 0.5 && < 0.6 , hasql-pool >= 0.8.0.2 && < 0.9
, hasql-transaction >= 1.0.1 && < 1.1 , hasql-transaction >= 1.0.1 && < 1.1
, heredoc >= 0.2 && < 0.3 , heredoc >= 0.2 && < 0.3
, hspec >= 2.3 && < 2.9 , hspec >= 2.3 && < 2.9
, hspec-wai >= 0.10 && < 0.12 , hspec-wai >= 0.10 && < 0.12
, hspec-wai-json >= 0.10 && < 0.12 , hspec-wai-json >= 0.10 && < 0.12
, http-types >= 0.12.3 && < 0.13 , http-types >= 0.12.3 && < 0.13
, lens >= 4.14 && < 5.2 , lens >= 4.14 && < 5.3
, lens-aeson >= 1.0.1 && < 1.2 , lens-aeson >= 1.0.1 && < 1.2
, postgrest , postgrest
, process >= 1.4.2 && < 1.7 , process >= 1.4.2 && < 1.7
+1
View File
@@ -40,6 +40,7 @@ lib.overrideDerivation postgrest.env (
pkgs.cabal2nix pkgs.cabal2nix
pkgs.git pkgs.git
pkgs.postgresql pkgs.postgresql
pkgs.update-nix-fetchgit
postgrest.hsie.bin postgrest.hsie.bin
] ]
++ toolboxes; ++ toolboxes;
-74
View File
@@ -1,74 +0,0 @@
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Admin
( postgrestAdmin
) where
import qualified Data.Text as T
import Network.Socket
import Network.Socket.ByteString
import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.Wai as Wai
import qualified Hasql.Session as SQL
import qualified PostgREST.AppState as AppState
import PostgREST.Config (AppConfig (..))
import Protolude
-- | PostgREST admin application
postgrestAdmin :: AppState.AppState -> AppConfig -> Wai.Application
postgrestAdmin appState appConfig req respond = do
isMainAppReachable <- any isRight <$> reachMainApp appConfig
isSchemaCacheLoaded <- isJust <$> AppState.getDbStructure appState
isConnectionUp <-
if configDbChannelEnabled appConfig
then AppState.getIsListenerOn appState
else isRight <$> AppState.usePool appState (SQL.sql "SELECT 1")
case Wai.pathInfo req of
["ready"] ->
respond $ Wai.responseLBS (if isMainAppReachable && isConnectionUp && isSchemaCacheLoaded then HTTP.status200 else HTTP.status503) [] mempty
["live"] ->
respond $ Wai.responseLBS (if isMainAppReachable then HTTP.status200 else HTTP.status503) [] mempty
_ ->
respond $ Wai.responseLBS HTTP.status404 [] mempty
-- 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
-- The code for resolving the "*4", "!4", "*6", "!6", "*" special values is taken from
-- https://hackage.haskell.org/package/streaming-commons-0.2.2.4/docs/src/Data.Streaming.Network.html#bindPortGenEx
reachMainApp :: AppConfig -> IO [Either IOException ()]
reachMainApp AppConfig{..} =
case configServerUnixSocket of
Just path -> do
sock <- socket AF_UNIX Stream 0
(:[]) <$> 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
sendEmpty sock = void $ send sock mempty
tryAddr :: AddrInfo -> IO (Either IOException ())
tryAddr addr = do
sock <- socket (addrFamily addr) (addrSocketType addr) (addrProtocol addr)
try $ do
connect sock $ addrAddress addr
withSocketsDo $ bracket (pure sock) close sendEmpty
@@ -6,7 +6,7 @@ Description : PostgREST functions to translate HTTP request to a domain type cal
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.Request.ApiRequest module PostgREST.ApiRequest
( ApiRequest(..) ( ApiRequest(..)
, InvokeMethod(..) , InvokeMethod(..)
, Mutation(..) , Mutation(..)
@@ -35,40 +35,44 @@ import qualified Data.Vector as V
import Control.Arrow ((***)) import Control.Arrow ((***))
import Data.Aeson.Types (emptyArray, emptyObject) import Data.Aeson.Types (emptyArray, emptyObject)
import Data.List (lookup, union) import Data.List (lookup, union)
import Data.Maybe (fromJust) import Data.Ranged.Ranges (emptyRange, rangeIntersection,
import Data.Ranged.Ranges (emptyRange, rangeIntersection) rangeIsEmpty)
import Network.HTTP.Types.Header (hCookie) import Data.Tree (Tree (..))
import Network.HTTP.Types.Header (RequestHeaders, hCookie)
import Network.HTTP.Types.URI (parseSimpleQuery) import Network.HTTP.Types.URI (parseSimpleQuery)
import Network.Wai (Request (..)) import Network.Wai (Request (..))
import Network.Wai.Parse (parseHttpAccept) import Network.Wai.Parse (parseHttpAccept)
import Web.Cookie (parseCookies) import Web.Cookie (parseCookies)
import PostgREST.Config (AppConfig (..), import PostgREST.ApiRequest.Preferences (PreferCount (..),
OpenAPIMode (..))
import PostgREST.DbStructure (DbStructure (..))
import PostgREST.DbStructure.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema)
import PostgREST.DbStructure.Proc (ProcDescription (..),
ProcParam (..), ProcsMap)
import PostgREST.MediaType (MTPlanAttrs (..),
MTPlanFormat (..),
MediaType (..))
import PostgREST.RangeQuery (NonnegRange, allRange,
hasLimitZero,
limitZeroRange,
rangeRequested)
import PostgREST.Request.Preferences (PreferCount (..),
PreferParameters (..), PreferParameters (..),
PreferRepresentation (..), PreferRepresentation (..),
PreferResolution (..), PreferResolution (..),
PreferTransaction (..)) PreferTransaction (..))
import PostgREST.Request.QueryParams (QueryParams (..)) import PostgREST.ApiRequest.QueryParams (QueryParams (..))
import PostgREST.Request.Types (ApiRequestError (..)) import PostgREST.ApiRequest.Types (ApiRequestError (..),
RangeError (..),
SelectItem (..))
import PostgREST.Config (AppConfig (..),
OpenAPIMode (..))
import PostgREST.MediaType (MTPlanAttrs (..),
MTPlanFormat (..),
MediaType (..))
import PostgREST.RangeQuery (NonnegRange, allRange,
convertToLimitZeroRange,
hasLimitZero,
rangeRequested)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema)
import PostgREST.SchemaCache.Proc (ProcDescription (..),
ProcParam (..), ProcsMap,
procReturnsScalar)
import qualified PostgREST.MediaType as MediaType import qualified PostgREST.ApiRequest.Preferences as Preferences
import qualified PostgREST.Request.Preferences as Preferences import qualified PostgREST.ApiRequest.QueryParams as QueryParams
import qualified PostgREST.Request.QueryParams as QueryParams import qualified PostgREST.MediaType as MediaType
import Protolude import Protolude
@@ -169,18 +173,21 @@ data ApiRequest = ApiRequest {
, iCookies :: [(ByteString, ByteString)] -- ^ Request Cookies , iCookies :: [(ByteString, ByteString)] -- ^ Request Cookies
, iPath :: ByteString -- ^ Raw request path , iPath :: ByteString -- ^ Raw request path
, iMethod :: ByteString -- ^ Raw request method , iMethod :: ByteString -- ^ Raw request method
, iProfile :: Maybe Schema -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/. , iSchema :: Schema -- ^ The request schema. Can vary depending on profile headers.
, iSchema :: Schema -- ^ The request schema. Can vary depending on iProfile. , iNegotiatedByProfile :: Bool -- ^ If schema was was chosen according to the profile spec https://www.w3.org/TR/dx-prof-conneg/
, iAcceptMediaType :: MediaType , iAcceptMediaType :: MediaType -- ^ The media type in the Accept header
, iBinaryField :: Maybe FieldName -- ^ field used for raw output
} }
-- | Examines HTTP request and translates it into user intent. -- | Examines HTTP request and translates it into user intent.
userApiRequest :: AppConfig -> DbStructure -> Request -> RequestBody -> Either ApiRequestError ApiRequest userApiRequest :: AppConfig -> SchemaCache -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest conf dbStructure req reqBody = do userApiRequest conf sCache req reqBody = do
qPrms <- first QueryParamError $ QueryParams.parse $ rawQueryString req qPrms <- first QueryParamError $ QueryParams.parse $ rawQueryString req
pInfo <- getPathInfo conf $ pathInfo req pInfo <- getPathInfo conf $ pathInfo req
act <- getAction pInfo $ requestMethod req act <- getAction pInfo $ requestMethod req
apiRequest conf dbStructure req reqBody qPrms pInfo act mediaTypes <- getMediaTypes conf (requestHeaders req) act pInfo
negotiatedSchema <- getSchema conf (requestHeaders req) (requestMethod req)
apiRequest conf sCache req reqBody qPrms pInfo act mediaTypes negotiatedSchema
getPathInfo :: AppConfig -> [Text] -> Either ApiRequestError PathInfo getPathInfo :: AppConfig -> [Text] -> Either ApiRequestError PathInfo
getPathInfo AppConfig{configOpenApiMode, configDbRootSpec} path = getPathInfo AppConfig{configOpenApiMode, configDbRootSpec} path =
@@ -214,17 +221,44 @@ getAction PathInfo{pathIsProc, pathIsDefSpec} method =
"OPTIONS" -> Right ActionInfo "OPTIONS" -> Right ActionInfo
_ -> Left $ UnsupportedMethod method _ -> Left $ UnsupportedMethod method
apiRequest :: AppConfig -> DbStructure -> Request -> RequestBody -> QueryParams.QueryParams -> PathInfo -> Action -> Either ApiRequestError ApiRequest getMediaTypes :: AppConfig -> RequestHeaders -> Action -> PathInfo -> Either ApiRequestError (MediaType, MediaType)
apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..} path@PathInfo{pathName, pathIsProc, pathIsRootSpec, pathIsDefSpec} action getMediaTypes conf hdrs action path = do
| isJust profile && fromJust profile `notElem` configDbSchemas = Left $ UnacceptableSchema $ toList configDbSchemas acceptMediaType <- findAcceptMediaType conf action path accepts
| isInvalidRange = Left InvalidRange 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{configDbSchemas} hdrs method = do
case profile of
Just p | p `notElem` configDbSchemas -> Left $ UnacceptableSchema $ toList configDbSchemas
| otherwise -> Right (p, True)
Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated
where
defaultSchema = NonEmptyList.head configDbSchemas
profile = case method of
-- POST/PATCH/PUT/DELETE don't use the same header as per the spec
"DELETE" -> contentProfile
"PATCH" -> contentProfile
"POST" -> contentProfile
"PUT" -> contentProfile
_ -> acceptProfile
contentProfile = T.decodeUtf8 <$> lookupHeader "Content-Profile"
acceptProfile = T.decodeUtf8 <$> lookupHeader "Accept-Profile"
lookupHeader = flip lookup hdrs
apiRequest :: AppConfig -> SchemaCache -> Request -> RequestBody -> QueryParams.QueryParams -> PathInfo -> Action -> (MediaType, MediaType) -> (Schema, Bool) -> Either ApiRequestError ApiRequest
apiRequest conf sCache req reqBody queryparams@QueryParams{..} PathInfo{pathName, pathIsProc, pathIsRootSpec, pathIsDefSpec} action (acceptMediaType, contentMediaType) (schema, negotiatedByProfile)
| isInvalidRange = Left $ InvalidRange (if rangeIsEmpty headerRange then LowerGTUpper else NegativeLimit)
| shouldParsePayload && isLeft payload = either (Left . InvalidBody) witness payload | shouldParsePayload && isLeft payload = either (Left . InvalidBody) witness payload
| not expectParams && not (L.null qsParams) = Left $ ParseRequestError "Unexpected param or filter missing operator" ("Failed to parse " <> show qsParams) | not expectParams && not (L.null qsParams) = Left $ ParseRequestError "Unexpected param or filter missing operator" ("Failed to parse " <> show qsParams)
| method `elem` ["PATCH", "DELETE"] && not (null qsRanges) && null qsOrder = Left LimitNoOrderError | method `elem` ["PATCH", "DELETE"] && not (null qsRanges) && null qsOrder = Left LimitNoOrderError
| method == "PUT" && topLevelRange /= allRange = Left PutRangeNotAllowedError | method == "PUT" && topLevelRange /= allRange = Left PutRangeNotAllowedError
| otherwise = do | otherwise = do
acceptMediaType <- findAcceptMediaType conf action path accepts
checkedTarget <- target checkedTarget <- target
bField <- binaryField conf acceptMediaType checkedTarget queryparams
return ApiRequest { return ApiRequest {
iAction = action iAction = action
, iTarget = checkedTarget , iTarget = checkedTarget
@@ -242,17 +276,14 @@ apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..
, iCookies = maybe [] parseCookies $ lookupHeader "Cookie" , iCookies = maybe [] parseCookies $ lookupHeader "Cookie"
, iPath = rawPathInfo req , iPath = rawPathInfo req
, iMethod = method , iMethod = method
, iProfile = profile
, iSchema = schema , iSchema = schema
, iNegotiatedByProfile = negotiatedByProfile
, iAcceptMediaType = acceptMediaType , iAcceptMediaType = acceptMediaType
, iBinaryField = bField
} }
where where
accepts = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"
expectParams = pathIsProc && method /= "POST" expectParams = pathIsProc && method /= "POST"
contentMediaType = maybe MTApplicationJSON MediaType.decodeMediaType $ lookupHeader "content-type"
columns = case action of columns = case action of
ActionMutate MutationCreate -> qsColumns ActionMutate MutationCreate -> qsColumns
ActionMutate MutationUpdate -> qsColumns ActionMutate MutationUpdate -> qsColumns
@@ -289,30 +320,13 @@ apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..
(ct, _) -> Left $ "Content-Type not acceptable: " <> MediaType.toMime ct (ct, _) -> Left $ "Content-Type not acceptable: " <> MediaType.toMime ct
topLevelRange = fromMaybe allRange $ HM.lookup "limit" ranges -- if no limit is specified, get all the request rows topLevelRange = fromMaybe allRange $ HM.lookup "limit" ranges -- if no limit is specified, get all the request rows
defaultSchema = NonEmptyList.head configDbSchemas
profile
| length configDbSchemas <= 1 -- only enable content negotiation by profile when there are multiple schemas specified in the config
= Nothing
| otherwise = case method of
-- POST/PATCH/PUT/DELETE don't use the same header as per the spec
"DELETE" -> contentProfile
"PATCH" -> contentProfile
"POST" -> contentProfile
"PUT" -> contentProfile
_ -> acceptProfile
where
contentProfile = Just $ maybe defaultSchema T.decodeUtf8 $ lookupHeader "Content-Profile"
acceptProfile = Just $ maybe defaultSchema T.decodeUtf8 $ lookupHeader "Accept-Profile"
schema = fromMaybe defaultSchema profile
target target
| pathIsProc = (`TargetProc` pathIsRootSpec) <$> callFindProc schema pathName | pathIsProc = (`TargetProc` pathIsRootSpec) <$> callFindProc schema pathName
| pathIsDefSpec = Right $ TargetDefaultSpec schema | pathIsDefSpec = Right $ TargetDefaultSpec schema
| otherwise = Right $ TargetIdent $ QualifiedIdentifier schema pathName | otherwise = Right $ TargetIdent $ QualifiedIdentifier schema pathName
where where
callFindProc procSch procNam = findProc callFindProc procSch procNam = findProc
(QualifiedIdentifier procSch procNam) payloadColumns (preferParameters == Just SingleObject) (dbProcs dbStructure) (QualifiedIdentifier procSch procNam) payloadColumns (preferParameters == Just SingleObject) (dbProcs sCache)
contentMediaType (action == ActionInvoke InvPost) contentMediaType (action == ActionInvoke InvPost)
shouldParsePayload = case (action, contentMediaType) of shouldParsePayload = case (action, contentMediaType) of
@@ -340,7 +354,7 @@ apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..
-- Bypass all the ranges and send only the limit zero range (0 <= x <= -1) if -- Bypass all the ranges and send only the limit zero range (0 <= x <= -1) if
-- limit=0 is present in the query params (not allowed for the Range header) -- limit=0 is present in the query params (not allowed for the Range header)
ranges = HM.insert "limit" (if hasLimitZero limitRange then limitZeroRange else headerAndLimitRange) qsRanges ranges = HM.insert "limit" (convertToLimitZeroRange limitRange headerAndLimitRange) qsRanges
-- The only emptyRange allowed is the limit zero range -- The only emptyRange allowed is the limit zero range
isInvalidRange = topLevelRange == emptyRange && not (hasLimitZero limitRange) isInvalidRange = topLevelRange == emptyRange && not (hasLimitZero limitRange)
@@ -439,7 +453,7 @@ requestMediaTypes conf action path =
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> MediaType -> Bool -> Either ApiRequestError ProcDescription findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> MediaType -> Bool -> Either ApiRequestError ProcDescription
findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPost = findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPost =
case matchProc of case matchProc of
([], []) -> Left $ NoRpc (qiSchema qi) (qiName qi) (S.toList argumentsKeys) paramsAsSingleObject contentMediaType isInvPost ([], []) -> Left $ NoRpc (qiSchema qi) (qiName qi) (S.toList argumentsKeys) paramsAsSingleObject contentMediaType isInvPost (HM.keys allProcs) lookupProcName
-- If there are no functions with named arguments, fallback to the single unnamed argument function -- If there are no functions with named arguments, fallback to the single unnamed argument function
([], [proc]) -> Right proc ([], [proc]) -> Right proc
([], procs) -> Left $ AmbiguousRpc (toList procs) ([], procs) -> Left $ AmbiguousRpc (toList procs)
@@ -447,7 +461,9 @@ findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPo
([proc], _) -> Right proc ([proc], _) -> Right proc
(procs, _) -> Left $ AmbiguousRpc (toList procs) (procs, _) -> Left $ AmbiguousRpc (toList procs)
where where
matchProc = overloadedProcPartition $ HM.lookupDefault mempty qi allProcs -- first find the proc by name matchProc = overloadedProcPartition lookupProcName
-- First find the proc by name
lookupProcName = HM.lookupDefault mempty qi allProcs
-- The partition obtained has the form (overloadedProcs,fallbackProcs) -- The partition obtained has the form (overloadedProcs,fallbackProcs)
-- where fallbackProcs are functions with a single unnamed parameter -- where fallbackProcs are functions with a single unnamed parameter
overloadedProcPartition = foldr select ([],[]) overloadedProcPartition = foldr select ([],[])
@@ -486,3 +502,35 @@ findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPo
-- If the function has required and optional parameters, the arguments keys have to match the required parameters -- If the function has required and optional parameters, the arguments keys have to match the required parameters
-- 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)
-- | If raw(binary) output is requested, check that MediaType is one of the
-- admitted rawMediaTypes and that`?select=...` contains only one field other
-- than `*`
binaryField :: AppConfig -> MediaType -> Target -> QueryParams -> Either ApiRequestError (Maybe FieldName)
binaryField AppConfig{configRawMediaTypes} acceptMediaType target QueryParams{qsSelect}
| returnsScalar target && isRawMediaType =
Right $ Just "pgrst_scalar"
| isRawMediaType =
let
fieldName = fstFieldName qsSelect
in
case fieldName of
Just fld -> Right $ Just fld
Nothing -> Left $ BinaryFieldError acceptMediaType
| otherwise =
Right Nothing
where
isRawMediaType = acceptMediaType `elem` configRawMediaTypes `union` [MTOctetStream, MTTextPlain, MTTextXML] || isRawPlan acceptMediaType
isRawPlan mt = case mt of
MTPlan (MTPlanAttrs (Just MTOctetStream) _ _) -> True
MTPlan (MTPlanAttrs (Just MTTextPlain) _ _) -> True
MTPlan (MTPlanAttrs (Just MTTextXML) _ _) -> True
_ -> False
returnsScalar :: Target -> Bool
returnsScalar (TargetProc proc _) = procReturnsScalar proc
returnsScalar _ = False
fstFieldName :: [Tree SelectItem] -> Maybe FieldName
fstFieldName [Node SelectField{selField=("*", _)} []] = Nothing
fstFieldName [Node SelectField{selField=(fld, _)} []] = Just fld
fstFieldName _ = Nothing
@@ -1,12 +1,12 @@
-- | -- |
-- Module: PostgREST.Request.Preferences -- Module: PostgREST.ApiRequest.Preferences
-- Description: Track client preferences to be employed when processing requests -- Description: Track client preferences to be employed when processing requests
-- --
-- Track client prefences set in HTTP 'Prefer' headers according to RFC7240[1]. -- Track client prefences set in HTTP 'Prefer' headers according to RFC7240[1].
-- --
-- [1] https://datatracker.ietf.org/doc/html/rfc7240 -- [1] https://datatracker.ietf.org/doc/html/rfc7240
-- --
module PostgREST.Request.Preferences module PostgREST.ApiRequest.Preferences
( Preferences(..) ( Preferences(..)
, PreferCount(..) , PreferCount(..)
, PreferParameters(..) , PreferParameters(..)
@@ -15,6 +15,7 @@ module PostgREST.Request.Preferences
, PreferTransaction(..) , PreferTransaction(..)
, fromHeaders , fromHeaders
, ToAppliedHeader(..) , ToAppliedHeader(..)
, shouldCount
) where ) where
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
@@ -171,6 +172,7 @@ data PreferParameters
| MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure. | MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure.
deriving Eq deriving Eq
-- TODO: Deprecate params=multiple-objects in next major version
instance ToHeaderValue PreferParameters where instance ToHeaderValue PreferParameters where
toHeaderValue SingleObject = "params=single-object" toHeaderValue SingleObject = "params=single-object"
toHeaderValue MultipleObjects = "params=multiple-objects" toHeaderValue MultipleObjects = "params=multiple-objects"
@@ -187,6 +189,10 @@ instance ToHeaderValue PreferCount where
toHeaderValue PlannedCount = "count=planned" toHeaderValue PlannedCount = "count=planned"
toHeaderValue EstimatedCount = "count=estimated" toHeaderValue EstimatedCount = "count=estimated"
shouldCount :: Maybe PreferCount -> Bool
shouldCount prefCount =
prefCount == Just ExactCount || prefCount == Just EstimatedCount
-- | Whether to commit or roll back transactions. -- | Whether to commit or roll back transactions.
data PreferTransaction data PreferTransaction
= Commit -- ^ Commit transaction - the default. = Commit -- ^ Commit transaction - the default.
@@ -1,12 +1,12 @@
-- | -- |
-- Module : PostgREST.Request.QueryParams -- Module : PostgREST.ApiRequest.QueryParams
-- Description : Parser for PostgREST Query paramters -- Description : Parser for PostgREST Query parameters
-- --
-- This module is in charge of parsing all the querystring values in an url, e.g. -- This module is in charge of parsing all the querystring values in an url, e.g.
-- the select, id, order in `/projects?select=id,name&id=eq.1&order=id,name.desc`. -- the select, id, order in `/projects?select=id,name&id=eq.1&order=id,name.desc`.
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE TupleSections #-} {-# LANGUAGE TupleSections #-}
module PostgREST.Request.QueryParams module PostgREST.ApiRequest.QueryParams
( parse ( parse
, QueryParams(..) , QueryParams(..)
, pRequestRange , pRequestRange
@@ -39,23 +39,23 @@ import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser,
optionMaybe, sepBy1, string, optionMaybe, sepBy1, string,
try, (<?>)) try, (<?>))
import PostgREST.DbStructure.Identifiers (FieldName)
import PostgREST.RangeQuery (NonnegRange, allRange, import PostgREST.RangeQuery (NonnegRange, allRange,
rangeGeq, rangeLimit, rangeGeq, rangeLimit,
rangeOffset, restrictRange) rangeOffset, restrictRange)
import PostgREST.SchemaCache.Identifiers (FieldName)
import PostgREST.Request.ReadQuery (SelectItem) import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field,
import PostgREST.Request.Types (EmbedParam (..), EmbedPath, Field, Filter (..), FtsOperator (..),
Filter (..), FtsOperator (..), JoinType (..), JsonOperand (..),
JoinType (..), JsonOperand (..), JsonOperation (..), JsonPath,
JsonOperation (..), JsonPath, ListVal, LogicOperator (..),
ListVal, LogicOperator (..), LogicTree (..), OpExpr (..),
LogicTree (..), OpExpr (..), Operation (..),
Operation (..), OrderDirection (..),
OrderDirection (..), OrderNulls (..), OrderTerm (..),
OrderNulls (..), OrderTerm (..), QPError (..), SelectItem (..),
QPError (..), SimpleOperator (..), SimpleOperator (..), SingleVal,
SingleVal, TrileanVal (..)) TrileanVal (..))
import Protolude hiding (try) import Protolude hiding (try)
@@ -73,6 +73,7 @@ import Protolude hiding (try)
-- >>> deriving instance Show JsonOperation -- >>> deriving instance Show JsonOperation
-- >>> deriving instance Show Filter -- >>> deriving instance Show Filter
-- >>> deriving instance Show JoinType -- >>> deriving instance Show JoinType
-- >>> deriving instance Show SelectItem
data QueryParams = data QueryParams =
QueryParams QueryParams
@@ -105,7 +106,7 @@ data QueryParams =
-- | -- |
-- Parse query parameters from a query string like "id=eq.1&select=name". -- Parse query parameters from a query string like "id=eq.1&select=name".
-- --
-- The canonical representation of the query string has paramters sorted alphabetically: -- The canonical representation of the query string has parameters sorted alphabetically:
-- --
-- >>> qsCanonical <$> parse "a=1&c=3&b=2&d" -- >>> qsCanonical <$> parse "a=1&c=3&b=2&d"
-- Right "a=1&b=2&c=3&d=" -- Right "a=1&b=2&c=3&d="
@@ -113,7 +114,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 "select=name,location" -- >>> qsSelect <$> parse "select=name,location"
-- Right [Node {rootLabel = (("name",[]),Nothing,Nothing,Nothing,Nothing), subForest = []},Node {rootLabel = (("location",[]),Nothing,Nothing,Nothing,Nothing), subForest = []}] -- Right [Node {rootLabel = SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectField {selField = ("location",[]), 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:
-- --
@@ -301,6 +302,22 @@ pTreePath = do
jp <- P.option [] pJsonPath jp <- P.option [] pJsonPath
return (init p, (last p, jp)) return (init p, (last p, jp))
-- |
-- Parse select= into a Forest of SelectItems
--
-- >>> P.parse pFieldForest "" "id"
-- Right [Node {rootLabel = SelectField {selField = ("id",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]
--
-- >>> 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 = []}]}]
--
-- >>> 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 = []}]}]}]
--
-- >>> P.parse pFieldForest "" "id,clients(name[])"
-- Left (line 1, column 16):
-- unexpected '['
-- expecting letter, digit, "-", "!", "(", "->>", "->", "::", ")", "," or end of input
pFieldForest :: Parser [Tree SelectItem] pFieldForest :: Parser [Tree SelectItem]
pFieldForest = pFieldTree `sepBy1` lexeme (char ',') pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
where where
@@ -311,11 +328,45 @@ pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
pStar :: Parser Text pStar :: Parser Text
pStar = string "*" $> "*" pStar = string "*" $> "*"
-- |
-- Parse field names
--
-- >>> P.parse pFieldName "" "identifier"
-- Right "identifier"
--
-- >>> P.parse pFieldName "" "identifier with spaces"
-- Right "identifier with spaces"
--
-- >>> P.parse pFieldName "" "identifier-with-dashes"
-- Right "identifier-with-dashes"
--
-- >>> P.parse pFieldName "" "123"
-- Right "123"
--
-- >>> P.parse pFieldName "" "_"
-- Right "_"
--
-- >>> P.parse pFieldName "" "$"
-- Right "$"
--
-- >>> P.parse pFieldName "" ":"
-- Left (line 1, column 1):
-- unexpected ":"
-- expecting field name (* or [a..z0..9_$])
--
-- >>> P.parse pFieldName "" "\":\""
-- Right ":"
--
-- >>> P.parse pFieldName "" " no leading or trailing spaces "
-- Right "no leading or trailing spaces"
--
-- >>> P.parse pFieldName "" "\" leading and trailing spaces \""
-- Right " leading and trailing spaces "
pFieldName :: Parser Text pFieldName :: Parser Text
pFieldName = pFieldName =
pQuotedValue <|> pQuotedValue <|>
T.intercalate "-" . map toS <$> (many1 pIdentifierChar `sepBy1` dash) <?> T.intercalate "-" . map toS <$> (pIdentifier `sepBy1` dash) <?>
"field name (* or [a..z0..9_])" "field name (* or [a..z0..9_$])"
where where
isDash :: GenParser Char st () isDash :: GenParser Char st ()
isDash = try ( char '-' >> notFollowedBy (char '>') ) isDash = try ( char '-' >> notFollowedBy (char '>') )
@@ -368,13 +419,42 @@ pField = lexeme $ (,) <$> pFieldName <*> P.option [] pJsonPath
aliasSeparator :: Parser () aliasSeparator :: Parser ()
aliasSeparator = char ':' >> notFollowedBy (char ':') aliasSeparator = char ':' >> notFollowedBy (char ':')
-- |
-- Parse regular fields in select
--
-- >>> P.parse pRelationSelect "" "rel(*)"
-- Right (SelectRelation {selRelation = "rel", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing})
--
-- >>> P.parse pRelationSelect "" "alias:rel(*)"
-- Right (SelectRelation {selRelation = "rel", selAlias = Just "alias", selHint = Nothing, selJoinType = Nothing})
--
-- >>> P.parse pRelationSelect "" "rel!hint(*)"
-- Right (SelectRelation {selRelation = "rel", selAlias = Nothing, selHint = Just "hint", selJoinType = Nothing})
--
-- >>> P.parse pRelationSelect "" "rel!inner(*)"
-- Right (SelectRelation {selRelation = "rel", selAlias = Nothing, selHint = Nothing, selJoinType = Just JTInner})
--
-- >>> P.parse pRelationSelect "" "rel!hint!inner(*)"
-- Right (SelectRelation {selRelation = "rel", selAlias = Nothing, selHint = Just "hint", selJoinType = Just JTInner})
--
-- >>> P.parse pRelationSelect "" "alias:rel!inner!hint(*)"
-- Right (SelectRelation {selRelation = "rel", selAlias = Just "alias", selHint = Just "hint", selJoinType = Just JTInner})
--
-- >>> P.parse pRelationSelect "" "rel->jsonpath(*)"
-- Left (line 1, column 6):
-- unexpected '>'
--
-- >>> P.parse pRelationSelect "" "rel->jsonpath!hint(*)"
-- Left (line 1, column 6):
-- unexpected '>'
pRelationSelect :: Parser SelectItem pRelationSelect :: Parser SelectItem
pRelationSelect = lexeme $ try ( do pRelationSelect = lexeme $ try ( do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField name <- pFieldName
prm1 <- optionMaybe pEmbedParam prm1 <- optionMaybe pEmbedParam
prm2 <- optionMaybe pEmbedParam prm2 <- optionMaybe pEmbedParam
return (fld, Nothing, alias, embedParamHint prm1 <|> embedParamHint prm2, embedParamJoin prm1 <|> embedParamJoin prm2) try (void $ lookAhead (string "("))
return $ SelectRelation name alias (embedParamHint prm1 <|> embedParamHint prm2) (embedParamJoin prm1 <|> embedParamJoin prm2)
) )
where where
pEmbedParam :: Parser EmbedParam pEmbedParam :: Parser EmbedParam
@@ -390,19 +470,66 @@ pRelationSelect = lexeme $ try ( do
Just (EPJoinType jt) -> Just jt Just (EPJoinType jt) -> Just jt
_ -> Nothing _ -> Nothing
-- |
-- Parse regular fields in select
--
-- >>> P.parse pFieldSelect "" "name"
-- Right (SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Nothing})
--
-- >>> P.parse pFieldSelect "" "name->jsonpath"
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selCast = Nothing, selAlias = Nothing})
--
-- >>> P.parse pFieldSelect "" "name::cast"
-- Right (SelectField {selField = ("name",[]), selCast = Just "cast", selAlias = Nothing})
--
-- >>> P.parse pFieldSelect "" "alias:name"
-- Right (SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Just "alias"})
--
-- >>> P.parse pFieldSelect "" "alias:name->jsonpath::cast"
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selCast = Just "cast", selAlias = Just "alias"})
--
-- >>> P.parse pFieldSelect "" "*"
-- Right (SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing})
--
-- >>> P.parse pFieldSelect "" "name!hint"
-- Left (line 1, column 5):
-- unexpected '!'
-- expecting letter, digit, "-", "->>", "->", "::", ")", "," or end of input
--
-- >>> P.parse pFieldSelect "" "*!hint"
-- Left (line 1, column 2):
-- unexpected '!'
-- expecting ")", "," or end of input
--
-- >>> P.parse pFieldSelect "" "name::"
-- Left (line 1, column 7):
-- unexpected end of input
-- expecting letter or digit
pFieldSelect :: Parser SelectItem pFieldSelect :: Parser SelectItem
pFieldSelect = lexeme $ pFieldSelect = lexeme $
try ( try (
do do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField fld <- pField
cast' <- optionMaybe (string "::" *> many pIdentifierChar) cast' <- optionMaybe (string "::" *> pIdentifier)
return (fld, toS <$> cast', alias, Nothing, Nothing) pEnd
return $ SelectField fld (toS <$> cast') alias
) )
<|> do <|> do
s <- pStar s <- pStar
return ((s, []), Nothing, Nothing, Nothing, Nothing) pEnd
return $ SelectField (s, []) Nothing Nothing
where
pEnd = try (void $ lookAhead (string ")")) <|>
try (void $ lookAhead (string ",")) <|>
try eof
-- |
-- Parse operator expression used in horizontal filtering
--
-- >>> P.parse (pOpExpr pSingleVal) "" "fts().value"
-- Left (line 1, column 7):
-- unknown single value operator fts()
pOpExpr :: Parser SingleVal -> Parser OpExpr pOpExpr :: Parser SingleVal -> Parser OpExpr
pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
where where
@@ -426,7 +553,7 @@ pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)
pFts = do pFts = do
opStr <- try (P.many (noneOf ".(")) opStr <- try (P.many (noneOf ".("))
op <- parseMaybe ("unknown fts operator " <> opStr) . ftsOperator $ toS opStr op <- parseMaybe ("unknown fts operator " <> opStr) . ftsOperator $ toS opStr
lang <- optionMaybe $ try (between (char '(') (char ')') $ many pIdentifierChar) lang <- optionMaybe $ try (between (char '(') (char ')') pIdentifier)
pDelimiter >> Fts op (toS <$> lang) <$> pSVal pDelimiter >> Fts op (toS <$> lang) <$> pSVal
parseMaybe :: [Char] -> Maybe a -> Parser a parseMaybe :: [Char] -> Maybe a -> Parser a
@@ -509,6 +636,9 @@ pLogicPath = do
pColumns :: Parser [FieldName] pColumns :: Parser [FieldName]
pColumns = pFieldName `sepBy1` lexeme (char ',') pColumns = pFieldName `sepBy1` lexeme (char ',')
pIdentifier :: Parser Text
pIdentifier = T.strip . toS <$> many1 pIdentifierChar
pIdentifierChar :: Parser Char pIdentifierChar :: Parser Char
pIdentifierChar = letter <|> digit <|> oneOf "_ $" pIdentifierChar = letter <|> digit <|> oneOf "_ $"
@@ -1,5 +1,5 @@
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.Request.Types module PostgREST.ApiRequest.Types
( Alias ( Alias
, Cast , Cast
, Depth , Depth
@@ -9,10 +9,6 @@ module PostgREST.Request.Types
, Field , Field
, Filter(..) , Filter(..)
, Hint , Hint
, CallQuery(..)
, CallParams(..)
, CallRequest
, JoinCondition(..)
, JoinType(..) , JoinType(..)
, JsonOperand(..) , JsonOperand(..)
, JsonOperation(..) , JsonOperation(..)
@@ -27,37 +23,51 @@ module PostgREST.Request.Types
, OrderNulls(..) , OrderNulls(..)
, OrderTerm(..) , OrderTerm(..)
, QPError(..) , QPError(..)
, RangeError(..)
, SingleVal , SingleVal
, TrileanVal(..) , TrileanVal(..)
, SimpleOperator(..) , SimpleOperator(..)
, FtsOperator(..) , FtsOperator(..)
, SelectItem(..)
) where ) where
import qualified Data.ByteString.Lazy as LBS
import PostgREST.DbStructure.Identifiers (FieldName,
QualifiedIdentifier)
import PostgREST.DbStructure.Proc (ProcDescription (..),
ProcParam (..))
import PostgREST.DbStructure.Relationship (Relationship)
import PostgREST.MediaType (MediaType (..)) import PostgREST.MediaType (MediaType (..))
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier)
import PostgREST.SchemaCache.Proc (ProcDescription (..))
import PostgREST.SchemaCache.Relationship (Relationship,
RelationshipsMap)
import Protolude import Protolude
-- | The select value in `/tbl?select=alias:field::cast`
data SelectItem
= SelectField
{ selField :: Field
, selCast :: Maybe Cast
, selAlias :: Maybe Alias
}
| SelectRelation
{ selRelation :: FieldName
, selAlias :: Maybe Alias
, selHint :: Maybe Hint
, selJoinType :: Maybe JoinType
}
deriving (Eq)
data ApiRequestError data ApiRequestError
= AmbiguousRelBetween Text Text [Relationship] = AmbiguousRelBetween Text Text [Relationship]
| AmbiguousRpc [ProcDescription] | AmbiguousRpc [ProcDescription]
| BinaryFieldError MediaType
| MediaTypeError [ByteString] | MediaTypeError [ByteString]
| InvalidBody ByteString | InvalidBody ByteString
| InvalidFilters | InvalidFilters
| InvalidRange | InvalidRange RangeError
| InvalidRpcMethod ByteString | InvalidRpcMethod ByteString
| LimitNoOrderError | LimitNoOrderError
| NotFound | NotFound
| NoRelBetween Text Text Text | NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
| NoRpc Text Text [Text] Bool MediaType Bool | NoRpc Text Text [Text] Bool MediaType Bool [QualifiedIdentifier] [ProcDescription]
| NotEmbedded Text | NotEmbedded Text
| ParseRequestError Text Text | ParseRequestError Text Text
| PutRangeNotAllowedError | PutRangeNotAllowedError
@@ -66,18 +76,14 @@ data ApiRequestError
| UnsupportedMethod ByteString | UnsupportedMethod ByteString
data QPError = QPError Text Text data QPError = QPError Text Text
data RangeError
type CallRequest = CallQuery = NegativeLimit
| LowerGTUpper
| OutOfBounds Text Text
type NodeName = Text type NodeName = Text
type Depth = Integer type Depth = Integer
data JoinCondition =
JoinCondition
(QualifiedIdentifier, FieldName)
(QualifiedIdentifier, FieldName)
deriving (Eq)
data OrderTerm = OrderTerm data OrderTerm = OrderTerm
{ otTerm :: Field { otTerm :: Field
, otDirection :: Maybe OrderDirection , otDirection :: Maybe OrderDirection
@@ -95,19 +101,6 @@ data OrderNulls
| OrderNullsLast | OrderNullsLast
deriving (Eq) deriving (Eq)
data CallQuery = FunctionCall
{ funCQi :: QualifiedIdentifier
, funCParams :: CallParams
, funCArgs :: Maybe LBS.ByteString
, funCScalar :: Bool
, funCMultipleCall :: Bool
, funCReturning :: [FieldName]
}
data CallParams
= KeyParams [ProcParam] -- ^ Call with key params: func(a := val1, b:= val2)
| OnePosParam ProcParam -- ^ Call with positional params(only one supported): func(val)
type Field = (FieldName, JsonPath) type Field = (FieldName, JsonPath)
type Cast = Text type Cast = Text
type Alias = Text type Alias = Text
+89 -535
View File
@@ -17,112 +17,61 @@ module PostgREST.App
, run , run
) where ) where
import Control.Monad.Except (liftEither) import Control.Monad.Except (liftEither)
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft)
import Data.List (union)
import Data.Maybe (fromJust) 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 System.Posix.Types (FileMode)
import qualified Data.ByteString.Char8 as BS import qualified Hasql.Transaction.Sessions as SQL
import qualified Data.ByteString.Lazy as LBS import qualified Network.Wai as Wai
import qualified Data.HashMap.Strict as HM import qualified Network.Wai.Handler.Warp as Warp
import qualified Data.Set as S
import qualified Hasql.DynamicStatements.Snippet as SQL (Snippet)
import qualified Hasql.Transaction as SQL
import qualified Hasql.Transaction.Sessions as SQL
import qualified Network.HTTP.Types.Header as HTTP
import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.HTTP.Types.URI as HTTP
import qualified Network.Wai as Wai
import qualified Network.Wai.Handler.Warp as Warp
import qualified PostgREST.Admin as Admin import qualified PostgREST.ApiRequest as ApiRequest
import qualified PostgREST.AppState as AppState import qualified PostgREST.ApiRequest.Types as ApiRequestTypes
import qualified PostgREST.Auth as Auth import qualified PostgREST.AppState as AppState
import qualified PostgREST.Cors as Cors import qualified PostgREST.Auth as Auth
import qualified PostgREST.DbStructure as DbStructure import qualified PostgREST.Cors as Cors
import qualified PostgREST.Error as Error import qualified PostgREST.Error as Error
import qualified PostgREST.Logger as Logger import qualified PostgREST.Logger as Logger
import qualified PostgREST.Middleware as Middleware import qualified PostgREST.Plan as Plan
import qualified PostgREST.OpenAPI as OpenAPI import qualified PostgREST.Query as Query
import qualified PostgREST.Query.QueryBuilder as QueryBuilder import qualified PostgREST.Response as Response
import qualified PostgREST.Query.Statements as Statements import qualified PostgREST.Workers as Workers
import qualified PostgREST.RangeQuery as RangeQuery
import qualified PostgREST.Request.ApiRequest as ApiRequest
import qualified PostgREST.Request.DbRequestBuilder as ReqBuilder
import qualified PostgREST.Request.Types as ApiRequestTypes
import PostgREST.AppState (AppState) import PostgREST.ApiRequest (Action (..), ApiRequest (..),
import PostgREST.Auth (AuthResult (..)) Mutation (..), Target (..))
import PostgREST.Config (AppConfig (..), import PostgREST.AppState (AppState)
LogLevel (..), import PostgREST.Auth (AuthResult (..))
OpenAPIMode (..)) import PostgREST.Config (AppConfig (..), LogLevel (..))
import PostgREST.Config.PgVersion (PgVersion (..)) import PostgREST.Config.PgVersion (PgVersion (..))
import PostgREST.DbStructure (DbStructure (..)) import PostgREST.Error (Error)
import PostgREST.DbStructure.Identifiers (FieldName, import PostgREST.Query (DbHandler)
QualifiedIdentifier (..), import PostgREST.SchemaCache (SchemaCache (..))
Schema) import PostgREST.Version (prettyVersion)
import PostgREST.DbStructure.Proc (ProcDescription (..),
ProcVolatility (..))
import PostgREST.DbStructure.Table (Table (..))
import PostgREST.Error (Error)
import PostgREST.GucHeader (GucHeader,
addHeadersIfNotIncluded,
unwrapGucHeader)
import PostgREST.MediaType (MTPlanAttrs (..),
MediaType (..))
import PostgREST.Query.Statements (ResultSet (..))
import PostgREST.Request.ApiRequest (Action (..),
ApiRequest (..),
InvokeMethod (..),
Mutation (..), Target (..))
import PostgREST.Request.Preferences (PreferCount (..),
PreferParameters (..),
PreferRepresentation (..),
toAppliedHeader)
import PostgREST.Request.QueryParams (QueryParams (..))
import PostgREST.Request.ReadQuery (ReadRequest, fstFieldNames)
import PostgREST.Version (prettyVersion)
import PostgREST.Workers (connectionWorker, listener)
import qualified PostgREST.DbStructure.Proc as Proc
import qualified PostgREST.MediaType as MediaType
import Protolude hiding (Handler) import Protolude hiding (Handler)
data RequestContext = RequestContext
{ ctxConfig :: AppConfig
, ctxDbStructure :: DbStructure
, ctxApiRequest :: ApiRequest
, ctxPgVersion :: PgVersion
}
type Handler = ExceptT Error type Handler = ExceptT Error
type DbHandler = Handler SQL.Transaction
type SignalHandlerInstaller = AppState -> IO() type SignalHandlerInstaller = AppState -> IO()
type SocketRunner = Warp.Settings -> Wai.Application -> FileMode -> FilePath -> IO() type SocketRunner = Warp.Settings -> Wai.Application -> FileMode -> FilePath -> IO()
run :: SignalHandlerInstaller -> Maybe SocketRunner -> AppState -> IO () run :: SignalHandlerInstaller -> Maybe SocketRunner -> AppState -> IO ()
run installHandlers maybeRunWithSocket appState = do run installHandlers maybeRunWithSocket appState = do
conf@AppConfig{..} <- AppState.getConfig appState conf@AppConfig{..} <- AppState.getConfig appState
connectionWorker appState -- Loads the initial DbStructure Workers.connectionWorker appState -- Loads the initial SchemaCache
installHandlers appState installHandlers appState
-- reload schema cache + config on NOTIFY -- reload schema cache + config on NOTIFY
when configDbChannelEnabled $ listener appState Workers.runListener conf appState
let app = postgrest configLogLevel appState (connectionWorker appState) Workers.runAdmin conf appState $ serverSettings conf
adminApp = Admin.postgrestAdmin appState conf
whenJust configAdminServerPort $ \adminPort -> do let app = postgrest configLogLevel appState (Workers.connectionWorker appState)
AppState.logWithZTime appState $ "Admin server listening on port " <> show adminPort
void . forkIO $ Warp.runSettings (serverSettings conf & setPort adminPort) adminApp
case configServerUnixSocket of case configServerUnixSocket of
Just socket -> Just socket ->
@@ -137,9 +86,6 @@ run installHandlers maybeRunWithSocket appState = do
do do
AppState.logWithZTime appState $ "Listening on port " <> show configServerPort AppState.logWithZTime appState $ "Listening on port " <> show configServerPort
Warp.runSettings (serverSettings conf) app Warp.runSettings (serverSettings conf) app
where
whenJust :: Applicative m => Maybe a -> (a -> m ()) -> m ()
whenJust mg f = maybe (pure ()) f mg
serverSettings :: AppConfig -> Warp.Settings serverSettings :: AppConfig -> Warp.Settings
serverSettings AppConfig{..} = serverSettings AppConfig{..} =
@@ -160,61 +106,50 @@ postgrest logLevel appState connWorker =
Left err -> respond $ Error.errorResponseFor err Left err -> respond $ Error.errorResponseFor err
Right authResult -> do Right authResult -> do
conf <- AppState.getConfig appState conf <- AppState.getConfig appState
maybeDbStructure <- AppState.getDbStructure appState maybeSchemaCache <- AppState.getSchemaCache appState
pgVer <- AppState.getPgVersion appState pgVer <- AppState.getPgVersion appState
jsonDbS <- AppState.getJsonDbS appState jsonDbS <- AppState.getJsonDbS appState
let let
eitherResponse :: IO (Either Error Wai.Response) eitherResponse :: IO (Either Error Wai.Response)
eitherResponse = eitherResponse =
runExceptT $ postgrestResponse appState conf maybeDbStructure jsonDbS pgVer authResult req runExceptT $ postgrestResponse appState conf maybeSchemaCache jsonDbS pgVer authResult req
response <- either Error.errorResponseFor identity <$> eitherResponse response <- either Error.errorResponseFor identity <$> eitherResponse
-- 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.
let isPGAway = Wai.responseStatus response == HTTP.status503 when (Response.isServiceUnavailable response) connWorker
when isPGAway connWorker resp <- do
resp <- addRetryHint isPGAway appState response delay <- AppState.getRetryNextIn appState
return $ Response.addRetryHint delay response
respond resp respond resp
addRetryHint :: Bool -> AppState -> Wai.Response -> IO Wai.Response
addRetryHint shouldAdd appState response = do
delay <- AppState.getRetryNextIn appState
let h = ("Retry-After", BS.pack $ show delay)
return $ Wai.mapResponseHeaders (\hs -> if shouldAdd then h:hs else hs) response
postgrestResponse postgrestResponse
:: AppState.AppState :: AppState.AppState
-> AppConfig -> AppConfig
-> Maybe DbStructure -> Maybe SchemaCache
-> ByteString -> ByteString
-> PgVersion -> PgVersion
-> AuthResult -> AuthResult
-> Wai.Request -> Wai.Request
-> Handler IO Wai.Response -> Handler IO Wai.Response
postgrestResponse appState conf@AppConfig{..} maybeDbStructure jsonDbS pgVer AuthResult{..} req = do postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jsonDbS pgVer authResult@AuthResult{..} req = do
body <- lift $ Wai.strictRequestBody req sCache <-
case maybeSchemaCache of
dbStructure <- Just sCache ->
case maybeDbStructure of return sCache
Just dbStructure ->
return dbStructure
Nothing -> Nothing ->
throwError Error.NoSchemaCacheError throwError Error.NoSchemaCacheError
body <- lift $ Wai.strictRequestBody req
apiRequest <- apiRequest <-
liftEither . mapLeft Error.ApiRequestError $ liftEither . mapLeft Error.ApiRequestError $
ApiRequest.userApiRequest conf dbStructure req body ApiRequest.userApiRequest conf sCache req body
let ctx apiReq = RequestContext conf dbStructure apiReq pgVer Response.optionalRollback conf apiRequest $
handleRequest authResult conf appState (Query.txMode apiRequest) (Just authRole /= configDbAnonRole) configDbPreparedStatements jsonDbS pgVer apiRequest sCache
if iAction apiRequest == ActionInfo then
handleInfo (iTarget apiRequest) (ctx apiRequest)
else
runDbHandler appState (txMode apiRequest) (Just authRole /= configDbAnonRole) configDbPreparedStatements .
Middleware.optionalRollback conf apiRequest $
Middleware.runPgLocals conf authClaims authRole (handleRequest . ctx) apiRequest jsonDbS pgVer
runDbHandler :: AppState.AppState -> SQL.Mode -> Bool -> Bool -> DbHandler b -> Handler IO b runDbHandler :: AppState.AppState -> SQL.Mode -> Bool -> Bool -> DbHandler b -> Handler IO b
runDbHandler appState mode authenticated prepared handler = do runDbHandler appState mode authenticated prepared handler = do
@@ -228,433 +163,52 @@ runDbHandler appState mode authenticated prepared handler = do
liftEither resp liftEither resp
handleRequest :: RequestContext -> DbHandler Wai.Response handleRequest :: AuthResult -> AppConfig -> AppState.AppState -> SQL.Mode -> Bool -> Bool -> ByteString -> PgVersion -> ApiRequest -> SchemaCache -> Handler IO Wai.Response
handleRequest context@(RequestContext _ _ ApiRequest{..} _) = handleRequest AuthResult{..} conf appState mode authenticated prepared jsonDbS pgVer apiReq@ApiRequest{..} sCache =
case (iAction, iTarget) of case (iAction, iTarget) of
(ActionRead headersOnly, TargetIdent identifier) -> (ActionRead headersOnly, TargetIdent identifier) -> do
handleRead headersOnly identifier context rPlan <- liftEither $ Plan.readPlan identifier conf sCache apiReq
(ActionMutate MutationCreate, TargetIdent identifier) -> resultSet <- runQuery $ Query.readQuery rPlan conf apiReq
handleCreate identifier context return $ Response.readResponse headersOnly identifier apiReq resultSet
(ActionMutate MutationUpdate, TargetIdent identifier) ->
handleUpdate identifier context (ActionMutate MutationCreate, TargetIdent identifier) -> do
(ActionMutate MutationSingleUpsert, TargetIdent identifier) -> mrPlan <- liftEither $ Plan.mutateReadPlan MutationCreate apiReq identifier conf sCache
handleSingleUpsert identifier context resultSet <- runQuery $ Query.createQuery mrPlan apiReq conf
(ActionMutate MutationDelete, TargetIdent identifier) -> return $ Response.createResponse identifier mrPlan apiReq resultSet
handleDelete identifier context
(ActionInvoke invMethod, TargetProc proc _) -> (ActionMutate MutationUpdate, TargetIdent identifier) -> do
handleInvoke invMethod proc context mrPlan <- liftEither $ Plan.mutateReadPlan MutationUpdate apiReq identifier conf sCache
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> resultSet <- runQuery $ Query.updateQuery mrPlan apiReq conf
handleOpenApi headersOnly tSchema context return $ Response.updateResponse apiReq resultSet
(ActionMutate MutationSingleUpsert, TargetIdent identifier) -> do
mrPlan <- liftEither $ Plan.mutateReadPlan MutationSingleUpsert apiReq identifier conf sCache
resultSet <- runQuery $ Query.singleUpsertQuery mrPlan apiReq conf
return $ Response.singleUpsertResponse apiReq resultSet
(ActionMutate MutationDelete, TargetIdent identifier) -> do
mrPlan <- liftEither $ Plan.mutateReadPlan MutationDelete apiReq identifier conf sCache
resultSet <- runQuery $ Query.deleteQuery mrPlan apiReq conf
return $ Response.deleteResponse apiReq resultSet
(ActionInvoke invMethod, TargetProc proc _) -> do
cPlan <- liftEither $ Plan.callReadPlan proc conf sCache apiReq
resultSet <- runQuery $ Query.invokeQuery proc cPlan apiReq conf
return $ Response.invokeResponse invMethod proc apiReq resultSet
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do
oaiResult <- runQuery $ Query.openApiQuery sCache pgVer conf tSchema
return $ Response.openApiResponse headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile
(ActionInfo, _) ->
return $ Response.infoResponse iTarget sCache
_ -> _ ->
-- This is unreachable as the ApiRequest.hs rejects it before -- This is unreachable as the ApiRequest.hs rejects it before
-- TODO Refactor the Action/Target types to remove this line -- TODO Refactor the Action/Target types to remove this line
throwError $ Error.ApiRequestError ApiRequestTypes.NotFound throwError $ Error.ApiRequestError ApiRequestTypes.NotFound
handleRead :: Bool -> QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleRead headersOnly identifier context@RequestContext{..} = do
req <- readRequest identifier context
bField <- binaryField context req
let
ApiRequest{..} = ctxApiRequest
AppConfig{..} = ctxConfig
countQuery = QueryBuilder.readRequestToCountQuery req
resultSet <-
lift . SQL.statement mempty $
Statements.prepareRead
(QueryBuilder.readRequestToQuery req)
(if iPreferCount == Just EstimatedCount then
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
QueryBuilder.limitedQuery countQuery ((+ 1) <$> configDbMaxRows)
else
countQuery
)
(shouldCount iPreferCount)
iAcceptMediaType
bField
configDbPreparedStatements
case resultSet of
RSStandard{..} -> do
total <- readTotal ctxConfig ctxApiRequest rsTableTotal countQuery
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
let
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal total
headers =
[ contentRange
, ( "Content-Location"
, "/"
<> toUtf8 (qiName identifier)
<> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams
)
]
++ contentTypeHeaders context
failNotSingular iAcceptMediaType rsQueryTotal . response status headers $
if headersOnly then mempty else LBS.fromStrict rsBody
RSPlan plan ->
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
readTotal :: AppConfig -> ApiRequest -> Maybe Int64 -> SQL.Snippet -> DbHandler (Maybe Int64)
readTotal AppConfig{..} ApiRequest{..} tableTotal countQuery =
case iPreferCount of
Just PlannedCount ->
explain
Just EstimatedCount ->
if tableTotal > (fromIntegral <$> configDbMaxRows) then
max tableTotal <$> explain
else
return tableTotal
_ ->
return tableTotal
where where
explain = runQuery query =
lift . SQL.statement mempty . Statements.preparePlanRows countQuery $ runDbHandler appState mode authenticated prepared $ do
configDbPreparedStatements Query.setPgLocals conf authClaims authRole apiReq jsonDbS pgVer
query
handleCreate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleCreate identifier@QualifiedIdentifier{..} context@RequestContext{..} = do
let
ApiRequest{..} = ctxApiRequest
pkCols = if iPreferRepresentation /= None || isJust iPreferResolution
then maybe mempty tablePKCols $ HM.lookup identifier $ dbTables ctxDbStructure
else mempty
resultSet <- writeQuery MutationCreate identifier True pkCols context
case resultSet of
RSStandard{..} -> do
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
let
headers =
catMaybes
[ if null rsLocation then
Nothing
else
Just
( HTTP.hLocation
, "/"
<> toUtf8 qiName
<> HTTP.renderSimpleQuery True rsLocation
)
, Just . RangeQuery.contentRangeH 1 0 $
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
, if null pkCols && isNothing (qsOnConflict iQueryParams) then
Nothing
else
toAppliedHeader <$> iPreferResolution
]
failNotSingular iAcceptMediaType rsQueryTotal $
if iPreferRepresentation == Full then
response HTTP.status201 (headers ++ contentTypeHeaders context) (LBS.fromStrict rsBody)
else
response HTTP.status201 headers mempty
RSPlan plan ->
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
handleUpdate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleUpdate identifier context@(RequestContext _ _ ApiRequest{..} _) = do
resultSet <- writeQuery MutationUpdate identifier False mempty context
case resultSet of
RSStandard{..} -> do
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
let
fullRepr = iPreferRepresentation == Full
updateIsNoOp = S.null iColumns
status
| rsQueryTotal == 0 && not updateIsNoOp = HTTP.status404
| fullRepr = HTTP.status200
| otherwise = HTTP.status204
contentRangeHeader =
RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
failChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) rsQueryTotal =<<
failNotSingular iAcceptMediaType rsQueryTotal (
if fullRepr then
response status (contentTypeHeaders context ++ [contentRangeHeader]) (LBS.fromStrict rsBody)
else
response status [contentRangeHeader] mempty)
RSPlan plan ->
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
handleSingleUpsert :: QualifiedIdentifier -> RequestContext-> DbHandler Wai.Response
handleSingleUpsert identifier context@(RequestContext _ ctxDbStructure ApiRequest{..} _) = do
let pkCols = maybe mempty tablePKCols $ HM.lookup identifier $ dbTables ctxDbStructure
resultSet <- writeQuery MutationSingleUpsert identifier False pkCols context
case resultSet of
RSStandard {..} -> do
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted,
-- PUT /items?id=eq.14 { "id" : 2, .. } is rejected.
-- If this condition is not satisfied then nothing is inserted,
-- check the WHERE for INSERT in QueryBuilder.hs to see how it's done
when (rsQueryTotal /= 1) $ do
lift SQL.condemn
throwError Error.PutMatchingPkError
return $
if iPreferRepresentation == Full then
response HTTP.status200 (contentTypeHeaders context) (LBS.fromStrict rsBody)
else
response HTTP.status204 [] mempty
RSPlan plan ->
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
handleDelete :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleDelete identifier context@(RequestContext _ _ ApiRequest{..} _) = do
resultSet <- writeQuery MutationDelete identifier False mempty context
case resultSet of
RSStandard {..} -> do
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
let
contentRangeHeader =
RangeQuery.contentRangeH 1 0 $
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
failChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) rsQueryTotal =<<
failNotSingular iAcceptMediaType rsQueryTotal (
if iPreferRepresentation == Full then
response HTTP.status200
(contentTypeHeaders context ++ [contentRangeHeader])
(LBS.fromStrict rsBody)
else
response HTTP.status204 [contentRangeHeader] mempty)
RSPlan plan ->
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
handleInfo :: Monad m => Target -> RequestContext -> Handler m Wai.Response
handleInfo target RequestContext{..} =
case target of
TargetIdent identifier ->
case HM.lookup identifier (dbTables ctxDbStructure) of
Just tbl -> infoResponse $ allowH tbl
Nothing -> throwError $ Error.ApiRequestError ApiRequestTypes.NotFound
TargetProc pd _
| pdVolatility pd == Volatile -> infoResponse "OPTIONS,POST"
| otherwise -> infoResponse "OPTIONS,GET,HEAD,POST"
TargetDefaultSpec _ -> infoResponse "OPTIONS,GET,HEAD"
where
infoResponse allowHeader = return $ Wai.responseLBS HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty
allOrigins = ("Access-Control-Allow-Origin", "*")
allowH table =
let hasPK = not . null $ tablePKCols table in
BS.intercalate "," $
["OPTIONS,GET,HEAD"] ++
["POST" | tableInsertable table] ++
["PUT" | tableInsertable table && tableUpdatable table && hasPK] ++
["PATCH" | tableUpdatable table] ++
["DELETE" | tableDeletable table]
handleInvoke :: InvokeMethod -> ProcDescription -> RequestContext -> DbHandler Wai.Response
handleInvoke invMethod proc context@RequestContext{..} = do
let
ApiRequest{..} = ctxApiRequest
identifier =
QualifiedIdentifier
(pdSchema proc)
(fromMaybe (pdName proc) $ Proc.procTableName proc)
req <- readRequest identifier context
bField <- binaryField context req
let callReq = ReqBuilder.callRequest proc ctxApiRequest req
resultSet <-
lift . SQL.statement mempty $
Statements.prepareCall
(Proc.procReturnsScalar proc)
(Proc.procReturnsSingle proc)
(QueryBuilder.requestToCallProcQuery callReq)
(QueryBuilder.readRequestToQuery req)
(QueryBuilder.readRequestToCountQuery req)
(shouldCount iPreferCount)
iAcceptMediaType
(iPreferParameters == Just MultipleObjects)
bField
(configDbPreparedStatements ctxConfig)
case resultSet of
RSStandard {..} -> do
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
let
(status, contentRange) =
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
failNotSingular iAcceptMediaType rsQueryTotal $
if Proc.procReturnsVoid proc then
response HTTP.status204 [contentRange] mempty
else
response status
(contentTypeHeaders context ++ [contentRange])
(if invMethod == InvHead then mempty else LBS.fromStrict rsBody)
RSPlan plan ->
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
handleOpenApi :: Bool -> Schema -> RequestContext -> DbHandler Wai.Response
handleOpenApi headersOnly tSchema (RequestContext conf@AppConfig{..} dbStructure apiRequest ctxPgVersion) = do
body <-
lift $ case configOpenApiMode of
OAFollowPriv ->
OpenAPI.encode conf dbStructure
<$> SQL.statement [tSchema] (DbStructure.accessibleTables ctxPgVersion configDbPreparedStatements)
<*> SQL.statement tSchema (DbStructure.accessibleProcs ctxPgVersion configDbPreparedStatements)
<*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)
OAIgnorePriv ->
OpenAPI.encode conf dbStructure
(HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ DbStructure.dbTables dbStructure)
(HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ DbStructure.dbProcs dbStructure)
<$> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)
OADisabled ->
pure mempty
return $
Wai.responseLBS HTTP.status200
(MediaType.toContentType MTOpenAPI : maybeToList (profileHeader apiRequest))
(if headersOnly then mempty else body)
txMode :: ApiRequest -> SQL.Mode
txMode ApiRequest{..} =
case (iAction, iTarget) of
(ActionRead _, _) ->
SQL.Read
(ActionInfo, _) ->
SQL.Read
(ActionInspect _, _) ->
SQL.Read
(ActionInvoke InvGet, _) ->
SQL.Read
(ActionInvoke InvHead, _) ->
SQL.Read
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Stable} _) ->
SQL.Read
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Immutable} _) ->
SQL.Read
_ ->
SQL.Write
writeQuery :: Mutation -> QualifiedIdentifier -> Bool -> [Text] -> RequestContext -> DbHandler ResultSet
writeQuery mutation identifier@QualifiedIdentifier{..} isInsert pkCols context@RequestContext{..} = do
readReq <- readRequest identifier context
mutateReq <-
liftEither $
ReqBuilder.mutateRequest mutation qiSchema qiName ctxApiRequest
pkCols
readReq
lift . SQL.statement mempty $
Statements.prepareWrite
(QueryBuilder.readRequestToQuery readReq)
(QueryBuilder.mutateRequestToQuery mutateReq)
isInsert
(iAcceptMediaType ctxApiRequest)
(iPreferRepresentation ctxApiRequest)
pkCols
(configDbPreparedStatements ctxConfig)
-- | Response with headers and status overridden from GUCs.
gucResponse
:: Maybe HTTP.Status
-> [GucHeader]
-> HTTP.Status
-> [HTTP.Header]
-> LBS.ByteString
-> Wai.Response
gucResponse gucStatus gucHeaders status headers =
Wai.responseLBS (fromMaybe status gucStatus) $
addHeadersIfNotIncluded headers (map unwrapGucHeader gucHeaders)
-- |
-- Fail a response if a single JSON object was requested and not exactly one
-- was found.
failNotSingular :: MediaType -> Int64 -> Wai.Response -> DbHandler Wai.Response
failNotSingular mediaType queryTotal response =
if mediaType == MTSingularJSON && queryTotal /= 1 then
do
lift SQL.condemn
throwError $ Error.singularityError queryTotal
else
return response
failChangesOffLimits :: Maybe Integer -> Int64 -> Wai.Response -> DbHandler Wai.Response
failChangesOffLimits (Just maxChanges) queryTotal response =
if queryTotal > fromIntegral maxChanges
then do
lift SQL.condemn
throwError $ Error.OffLimitsChangesError queryTotal maxChanges
else
return response
failChangesOffLimits _ _ response = return response
shouldCount :: Maybe PreferCount -> Bool
shouldCount preferCount =
preferCount == Just ExactCount || preferCount == Just EstimatedCount
returnsScalar :: ApiRequest.Target -> Bool
returnsScalar (TargetProc proc _) = Proc.procReturnsScalar proc
returnsScalar _ = False
readRequest :: Monad m => QualifiedIdentifier -> RequestContext -> Handler m ReadRequest
readRequest QualifiedIdentifier{..} (RequestContext AppConfig{..} dbStructure apiRequest _) =
liftEither $
ReqBuilder.readRequest qiSchema qiName configDbMaxRows
(dbRelationships dbStructure)
apiRequest
contentTypeHeaders :: RequestContext -> [HTTP.Header]
contentTypeHeaders RequestContext{..} =
MediaType.toContentType (iAcceptMediaType ctxApiRequest) : maybeToList (profileHeader ctxApiRequest)
-- | If raw(binary) output is requested, check that MediaType is one of the
-- admitted rawMediaTypes and that`?select=...` contains only one field other
-- than `*`
binaryField :: Monad m => RequestContext -> ReadRequest -> Handler m (Maybe FieldName)
binaryField RequestContext{..} readReq
| returnsScalar (iTarget ctxApiRequest) && isRawMediaType =
return $ Just "pgrst_scalar"
| isRawMediaType =
let
fldNames = fstFieldNames readReq
fieldName = headMay fldNames
in
if length fldNames == 1 && fieldName /= Just "*" then
return fieldName
else
throwError $ Error.BinaryFieldError mediaType
| otherwise =
return Nothing
where
mediaType = iAcceptMediaType ctxApiRequest
isRawMediaType = mediaType `elem` configRawMediaTypes ctxConfig `union` [MTOctetStream, MTTextPlain, MTTextXML] || isRawPlan mediaType
isRawPlan mt = case mt of
MTPlan (MTPlanAttrs (Just MTOctetStream) _ _) -> True
MTPlan (MTPlanAttrs (Just MTTextPlain) _ _) -> True
MTPlan (MTPlanAttrs (Just MTTextXML) _ _) -> True
_ -> False
profileHeader :: ApiRequest -> Maybe HTTP.Header
profileHeader ApiRequest{..} =
(,) "Content-Profile" <$> (toUtf8 <$> iProfile)
+31 -19
View File
@@ -3,8 +3,9 @@
module PostgREST.AppState module PostgREST.AppState
( AppState ( AppState
, destroy , destroy
, flushPool
, getConfig , getConfig
, getDbStructure , getSchemaCache
, getIsListenerOn , getIsListenerOn
, getJsonDbS , getJsonDbS
, getMainThreadId , getMainThreadId
@@ -16,12 +17,11 @@ module PostgREST.AppState
, initWithPool , initWithPool
, logWithZTime , logWithZTime
, putConfig , putConfig
, putDbStructure , putSchemaCache
, putIsListenerOn , putIsListenerOn
, putJsonDbS , putJsonDbS
, putPgVersion , putPgVersion
, putRetryNextIn , putRetryNextIn
, releasePool
, signalListener , signalListener
, usePool , usePool
, waitListener , waitListener
@@ -40,17 +40,19 @@ import Data.Time.Clock (UTCTime, getCurrentTime)
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion) import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion)
import PostgREST.DbStructure (DbStructure) import PostgREST.SchemaCache (SchemaCache)
import Protolude import Protolude
data AppState = AppState data AppState = AppState
{ statePool :: SQL.Pool -- | Connection pool, either a 'Connection' or a 'ConnectionError' -- | Database connection pool
{ statePool :: SQL.Pool
-- | Database server version, will be updated by the connectionWorker
, statePgVersion :: IORef PgVersion , statePgVersion :: IORef PgVersion
-- | No schema cache at the start. Will be filled in by the connectionWorker -- | No schema cache at the start. Will be filled in by the connectionWorker
, stateDbStructure :: IORef (Maybe DbStructure) , stateSchemaCache :: IORef (Maybe SchemaCache)
-- | Cached DbStructure in json -- | Cached SchemaCache in json
, stateJsonDbS :: IORef ByteString , stateJsonDbS :: IORef ByteString
-- | Binary semaphore to make sure just one connectionWorker can run at a time -- | Binary semaphore to make sure just one connectionWorker can run at a time
, stateWorkerSem :: MVar () , stateWorkerSem :: MVar ()
@@ -72,12 +74,12 @@ data AppState = AppState
init :: AppConfig -> IO AppState init :: AppConfig -> IO AppState
init conf = do init conf = do
newPool <- initPool conf pool <- initPool conf
initWithPool newPool conf initWithPool pool conf
initWithPool :: SQL.Pool -> AppConfig -> IO AppState initWithPool :: SQL.Pool -> AppConfig -> IO AppState
initWithPool newPool conf = initWithPool pool conf =
AppState newPool 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
<*> newIORef mempty <*> newIORef mempty
@@ -91,17 +93,27 @@ initWithPool newPool conf =
<*> newIORef 0 <*> newIORef 0
destroy :: AppState -> IO () destroy :: AppState -> IO ()
destroy = releasePool destroy = destroyPool
initPool :: AppConfig -> IO SQL.Pool initPool :: AppConfig -> IO SQL.Pool
initPool AppConfig{..} = initPool AppConfig{..} =
SQL.acquire (configDbPoolSize, configDbPoolTimeout, toUtf8 configDbUri) SQL.acquire configDbPoolSize timeoutMilliseconds $ toUtf8 configDbUri
where
timeoutMilliseconds = (* oneSecond) <$> configDbPoolAcquisitionTimeout
oneSecond = 1000000
-- | Run an action with a database connection.
usePool :: AppState -> SQL.Session a -> IO (Either SQL.UsageError a) usePool :: AppState -> SQL.Session a -> IO (Either SQL.UsageError a)
usePool AppState{..} = SQL.use statePool usePool AppState{..} = SQL.use statePool
releasePool :: AppState -> IO () -- | Flush the connection pool so that any future use of the pool will
releasePool AppState{..} = SQL.release statePool -- use connections freshly established after this call.
flushPool :: AppState -> IO ()
flushPool AppState{..} = SQL.release statePool
-- | Destroy the pool on shutdown.
destroyPool :: AppState -> IO ()
destroyPool AppState{..} = SQL.release statePool
getPgVersion :: AppState -> IO PgVersion getPgVersion :: AppState -> IO PgVersion
getPgVersion = readIORef . statePgVersion getPgVersion = readIORef . statePgVersion
@@ -109,11 +121,11 @@ getPgVersion = readIORef . statePgVersion
putPgVersion :: AppState -> PgVersion -> IO () putPgVersion :: AppState -> PgVersion -> IO ()
putPgVersion = atomicWriteIORef . statePgVersion putPgVersion = atomicWriteIORef . statePgVersion
getDbStructure :: AppState -> IO (Maybe DbStructure) getSchemaCache :: AppState -> IO (Maybe SchemaCache)
getDbStructure = readIORef . stateDbStructure getSchemaCache = readIORef . stateSchemaCache
putDbStructure :: AppState -> Maybe DbStructure -> IO () putSchemaCache :: AppState -> Maybe SchemaCache -> IO ()
putDbStructure appState = atomicWriteIORef (stateDbStructure appState) putSchemaCache appState = atomicWriteIORef (stateSchemaCache appState)
getJsonDbS :: AppState -> IO ByteString getJsonDbS :: AppState -> IO ByteString
getJsonDbS = readIORef . stateJsonDbS getJsonDbS = readIORef . stateJsonDbS
+6 -6
View File
@@ -19,7 +19,7 @@ import Text.Heredoc (str)
import PostgREST.AppState (AppState) import PostgREST.AppState (AppState)
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.DbStructure (queryDbStructure) import PostgREST.SchemaCache (querySchemaCache)
import PostgREST.Version (prettyVersion) import PostgREST.Version (prettyVersion)
import PostgREST.Workers (reReadConfig) import PostgREST.Workers (reReadConfig)
@@ -48,7 +48,7 @@ main installSignalHandlers runAppWithSocket CLI{cliCommand, cliPath} = do
CmdDumpSchema -> putStrLn =<< dumpSchema appState CmdDumpSchema -> putStrLn =<< dumpSchema appState
CmdRun -> App.run installSignalHandlers runAppWithSocket appState) CmdRun -> App.run installSignalHandlers runAppWithSocket appState)
-- | Dump DbStructure schema to JSON -- | Dump SchemaCache schema to JSON
dumpSchema :: AppState -> IO LBS.ByteString dumpSchema :: AppState -> IO LBS.ByteString
dumpSchema appState = do dumpSchema appState = do
AppConfig{..} <- AppState.getConfig appState AppConfig{..} <- AppState.getConfig appState
@@ -56,7 +56,7 @@ dumpSchema appState = do
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 $
transaction SQL.ReadCommitted SQL.Read $ transaction SQL.ReadCommitted SQL.Read $
queryDbStructure querySchemaCache
(toList configDbSchemas) (toList configDbSchemas)
configDbExtraSearchPath configDbExtraSearchPath
configDbPreparedStatements configDbPreparedStatements
@@ -64,7 +64,7 @@ dumpSchema appState = do
Left e -> do Left e -> do
hPutStrLn stderr $ "An error ocurred when loading the schema cache:\n" <> show e hPutStrLn stderr $ "An error ocurred when loading the schema cache:\n" <> show e
exitFailure exitFailure
Right dbStructure -> return $ JSON.encode dbStructure Right sCache -> return $ JSON.encode sCache
-- | Command line interface options -- | Command line interface options
data CLI = CLI data CLI = CLI
@@ -148,8 +148,8 @@ exampleConfigFile =
|## Number of open connections in the pool |## Number of open connections in the pool
|db-pool = 10 |db-pool = 10
| |
|## Time to live, in seconds, for an idle database pool connection |## Time in seconds to wait to acquire a slot from the connection pool
|db-pool-timeout = 3600 |# db-pool-acquisition-timeout = 10
| |
|## 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"
+39 -40
View File
@@ -46,7 +46,6 @@ import Data.List (lookup)
import Data.List.NonEmpty (fromList, toList) import Data.List.NonEmpty (fromList, toList)
import Data.Maybe (fromJust) import Data.Maybe (fromJust)
import Data.Scientific (floatingOrInteger) import Data.Scientific (floatingOrInteger)
import Data.Time.Clock (NominalDiffTime)
import Numeric (readOct, showOct) import Numeric (readOct, showOct)
import System.Environment (getEnvironment) import System.Environment (getEnvironment)
import System.Posix.Types (FileMode) import System.Posix.Types (FileMode)
@@ -55,48 +54,48 @@ 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.DbStructure.Identifiers (QualifiedIdentifier, dumpQi,
toQi)
import PostgREST.MediaType (MediaType (..), toMime) import PostgREST.MediaType (MediaType (..), toMime)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi,
toQi)
import Protolude hiding (Proxy, toList) import Protolude hiding (Proxy, toList)
data AppConfig = AppConfig data AppConfig = AppConfig
{ configAppSettings :: [(Text, Text)] { configAppSettings :: [(Text, Text)]
, configDbAnonRole :: Maybe Text , configDbAnonRole :: Maybe Text
, configDbChannel :: Text , configDbChannel :: Text
, configDbChannelEnabled :: Bool , configDbChannelEnabled :: Bool
, configDbExtraSearchPath :: [Text] , configDbExtraSearchPath :: [Text]
, configDbMaxRows :: Maybe Integer , configDbMaxRows :: Maybe Integer
, configDbPlanEnabled :: Bool , configDbPlanEnabled :: Bool
, configDbPoolSize :: Int , configDbPoolSize :: Int
, configDbPoolTimeout :: NominalDiffTime , configDbPoolAcquisitionTimeout :: Maybe Int
, configDbPreRequest :: Maybe QualifiedIdentifier , configDbPreRequest :: Maybe QualifiedIdentifier
, configDbPreparedStatements :: Bool , configDbPreparedStatements :: Bool
, configDbRootSpec :: Maybe QualifiedIdentifier , configDbRootSpec :: Maybe QualifiedIdentifier
, configDbSchemas :: NonEmpty Text , configDbSchemas :: NonEmpty Text
, configDbConfig :: Bool , configDbConfig :: Bool
, configDbTxAllowOverride :: Bool , configDbTxAllowOverride :: Bool
, configDbTxRollbackAll :: Bool , configDbTxRollbackAll :: Bool
, configDbUri :: Text , configDbUri :: Text
, configDbUseLegacyGucs :: Bool , 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
, configLogLevel :: LogLevel , configLogLevel :: LogLevel
, configOpenApiMode :: OpenAPIMode , configOpenApiMode :: OpenAPIMode
, configOpenApiSecurityActive :: Bool , configOpenApiSecurityActive :: Bool
, configOpenApiServerProxyUri :: Maybe Text , configOpenApiServerProxyUri :: Maybe Text
, configRawMediaTypes :: [MediaType] , configRawMediaTypes :: [MediaType]
, configServerHost :: Text , configServerHost :: Text
, configServerPort :: Int , configServerPort :: Int
, configServerUnixSocket :: Maybe FilePath , configServerUnixSocket :: Maybe FilePath
, configServerUnixSocketMode :: FileMode , configServerUnixSocketMode :: FileMode
, configAdminServerPort :: Maybe Int , configAdminServerPort :: Maybe Int
} }
data LogLevel = LogCrit | LogError | LogWarn | LogInfo data LogLevel = LogCrit | LogError | LogWarn | LogInfo
@@ -131,7 +130,7 @@ toText conf =
,("db-max-rows", maybe "\"\"" show . configDbMaxRows) ,("db-max-rows", maybe "\"\"" show . configDbMaxRows)
,("db-plan-enabled", T.toLower . show . configDbPlanEnabled) ,("db-plan-enabled", T.toLower . show . configDbPlanEnabled)
,("db-pool", show . configDbPoolSize) ,("db-pool", show . configDbPoolSize)
,("db-pool-timeout", show . floor . configDbPoolTimeout) ,("db-pool-acquisition-timeout", maybe "\"\"" show . configDbPoolAcquisitionTimeout)
,("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)
@@ -220,7 +219,7 @@ parser optPath env dbSettings =
(optInt "max-rows") (optInt "max-rows")
<*> (fromMaybe False <$> optBool "db-plan-enabled") <*> (fromMaybe False <$> optBool "db-plan-enabled")
<*> (fromMaybe 10 <$> optInt "db-pool") <*> (fromMaybe 10 <$> optInt "db-pool")
<*> (fromIntegral . fromMaybe 3600 <$> optInt "db-pool-timeout") <*> optInt "db-pool-acquisition-timeout"
<*> (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")
@@ -356,7 +355,7 @@ parser optPath env dbSettings =
let dbSettingName = T.pack $ dashToUnderscore <$> toS key in let dbSettingName = T.pack $ dashToUnderscore <$> toS key in
if dbSettingName `notElem` [ if dbSettingName `notElem` [
"server_host", "server_port", "server_unix_socket", "server_unix_socket_mode", "admin_server_port", "log_level", "server_host", "server_port", "server_unix_socket", "server_unix_socket_mode", "admin_server_port", "log_level",
"db_uri", "db_channel_enabled", "db_channel", "db_pool", "db_pool_timeout", "db_config"] "db_uri", "db_channel_enabled", "db_channel", "db_pool", "db_pool_acquisition_timeout", "db_config"]
then lookup dbSettingName dbSettings then lookup dbSettingName dbSettings
else Nothing else Nothing
+174 -52
View File
@@ -17,6 +17,8 @@ module PostgREST.Error
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.FuzzySet as Fuzzy
import qualified Data.HashMap.Strict as HM
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
@@ -29,17 +31,20 @@ import Network.Wai (Response, responseLBS)
import Network.HTTP.Types.Header (Header) import Network.HTTP.Types.Header (Header)
import PostgREST.MediaType (MediaType (..)) import PostgREST.ApiRequest.Types (ApiRequestError (..),
import qualified PostgREST.MediaType as MediaType QPError (..),
import PostgREST.Request.Types (ApiRequestError (..), RangeError (..))
QPError (..)) import PostgREST.MediaType (MediaType (..))
import qualified PostgREST.MediaType as MediaType
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
import PostgREST.DbStructure.Proc (ProcDescription (..), Schema)
import PostgREST.SchemaCache.Proc (ProcDescription (..),
ProcParam (..)) ProcParam (..))
import PostgREST.DbStructure.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
Relationship (..)) Relationship (..),
RelationshipsMap)
import Protolude import Protolude
@@ -56,11 +61,12 @@ class (JSON.ToJSON a) => PgrstError a where
instance PgrstError ApiRequestError where instance PgrstError ApiRequestError where
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 InvalidRpcMethod{} = HTTP.status405 status InvalidRpcMethod{} = HTTP.status405
status InvalidRange = HTTP.status416 status InvalidRange{} = HTTP.status416
status NotFound = HTTP.status404 status NotFound = HTTP.status404
status NoRelBetween{} = HTTP.status400 status NoRelBetween{} = HTTP.status400
status NoRpc{} = HTTP.status404 status NoRpc{} = HTTP.status404
@@ -90,10 +96,13 @@ instance JSON.ToJSON ApiRequestError where
"message" .= T.decodeUtf8 errorMessage, "message" .= T.decodeUtf8 errorMessage,
"details" .= JSON.Null, "details" .= JSON.Null,
"hint" .= JSON.Null] "hint" .= JSON.Null]
toJSON InvalidRange = JSON.object [ toJSON (InvalidRange rangeError) = JSON.object [
"code" .= ApiRequestErrorCode03, "code" .= ApiRequestErrorCode03,
"message" .= ("HTTP Range error" :: Text), "message" .= ("Requested range not satisfiable" :: Text),
"details" .= JSON.Null, "details" .= (case rangeError of
NegativeLimit -> "Limit should be greater than or equal to zero."
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."),
"hint" .= JSON.Null] "hint" .= JSON.Null]
toJSON (ParseRequestError message details) = JSON.object [ toJSON (ParseRequestError message details) = JSON.object [
"code" .= ApiRequestErrorCode04, "code" .= ApiRequestErrorCode04,
@@ -128,6 +137,12 @@ instance JSON.ToJSON ApiRequestError where
"details" .= JSON.Null, "details" .= JSON.Null,
"hint" .= ("Apply an 'order' using unique column(s)" :: Text)] "hint" .= ("Apply an 'order' using unique column(s)" :: Text)]
toJSON (BinaryFieldError ct) = JSON.object [
"code" .= ApiRequestErrorCode13,
"message" .= ((T.decodeUtf8 (MediaType.toMime ct) <> " requested but more than one column was selected") :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON PutRangeNotAllowedError = JSON.object [ toJSON PutRangeNotAllowedError = JSON.object [
"code" .= ApiRequestErrorCode14, "code" .= ApiRequestErrorCode14,
"message" .= ("Range header and limit/offset querystring parameters are not allowed for PUT" :: Text), "message" .= ("Range header and limit/offset querystring parameters are not allowed for PUT" :: Text),
@@ -140,36 +155,143 @@ instance JSON.ToJSON ApiRequestError where
"details" .= JSON.Null, "details" .= JSON.Null,
"hint" .= JSON.Null] "hint" .= JSON.Null]
toJSON (NoRelBetween parent child schema) = JSON.object [ toJSON (NoRelBetween parent child embedHint schema allRels) = JSON.object [
"code" .= SchemaCacheErrorCode00, "code" .= SchemaCacheErrorCode00,
"message" .= ("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache" :: Text), "message" .= ("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache" :: Text),
"details" .= JSON.Null, "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."),
"hint" .= ("Verify that '" <> parent <> "' and '" <> child <> "' exist in the schema '" <> schema <> "' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache." :: Text)] "hint" .= noRelBetweenHint parent child schema allRels]
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [ toJSON (AmbiguousRelBetween parent child rels) = JSON.object [
"code" .= SchemaCacheErrorCode01, "code" .= SchemaCacheErrorCode01,
"message" .= ("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'" :: Text), "message" .= ("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'" :: Text),
"details" .= (compressedRel <$> rels), "details" .= (compressedRel <$> rels),
"hint" .= ("Try changing '" <> child <> "' to one of the following: " <> relHint rels <> ". Find the desired relationship in the 'details' key." :: Text)] "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) = toJSON (NoRpc schema procName argumentKeys hasPreferSingleObject contentType isInvPost allProcs overloadedProcs) =
let prms = "(" <> T.intercalate ", " argumentKeys <> ")" in JSON.object [ let func = schema <> "." <> procName
prms = T.intercalate ", " argumentKeys
prmsMsg = "(" <> prms <> ")"
prmsDet = " with parameter" <> (if length argumentKeys > 1 then "s " else " ") <> prms
fmtPrms p = if null argumentKeys then " without parameters" else p
onlySingleParams = hasPreferSingleObject || (isInvPost && contentType `elem` [MTTextPlain, MTTextXML, MTOctetStream])
in JSON.object [
"code" .= SchemaCacheErrorCode02, "code" .= SchemaCacheErrorCode02,
"message" .= ("Could not find the " <> schema <> "." <> procName <> "message" .= ("Could not find the function " <> func <> (if onlySingleParams then "" else fmtPrms prmsMsg) <> " in the schema cache"),
"details" .= ("Searched for the function " <> func <>
(case (hasPreferSingleObject, isInvPost, contentType) of (case (hasPreferSingleObject, isInvPost, contentType) of
(True, _, _) -> " function with a single json or jsonb parameter" (True, _, _) -> " with a single json/jsonb parameter"
(_, True, MTTextPlain) -> " function with a single unnamed text parameter" (_, True, MTTextPlain) -> " with a single unnamed text parameter"
(_, True, MTTextXML) -> " function with a single unnamed xml parameter" (_, True, MTTextXML) -> " with a single unnamed xml parameter"
(_, True, MTOctetStream) -> " function with a single unnamed bytea parameter" (_, True, MTOctetStream) -> " with a single unnamed bytea parameter"
(_, True, MTApplicationJSON) -> prms <> " function or the " <> schema <> "." <> procName <>" function with a single unnamed json or jsonb parameter" (_, True, MTApplicationJSON) -> fmtPrms prmsDet <> " or with a single unnamed json/jsonb parameter"
_ -> prms <> " function") <> _ -> fmtPrms prmsDet) <>
" in the schema cache"), ", but no matches were found in the schema cache."),
"details" .= JSON.Null, -- The hint will be null in the case of single unnamed parameter functions
"hint" .= ("If a new function was created in the database with this name and parameters, try reloading the schema cache." :: Text)] "hint" .= if onlySingleParams
then Nothing
else noRpcHint schema procName argumentKeys allProcs overloadedProcs ]
toJSON (AmbiguousRpc procs) = JSON.object [ toJSON (AmbiguousRpc procs) = JSON.object [
"code" .= SchemaCacheErrorCode03, "code" .= SchemaCacheErrorCode03,
"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]), "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]),
"details" .= JSON.Null, "details" .= JSON.Null,
"hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)] "hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)]
-- |
-- If no relationship is found then:
--
-- Looks for parent suggestions if parent not found
-- Looks for child suggestions if parent is found but child is not
-- Gives no suggestions if both are found (it means that there is a problem with the embed hint)
--
-- >>> :set -Wno-missing-fields
-- >>> let qi t = QualifiedIdentifier "api" t
-- >>> let rel ft = Relationship{relForeignTable = qi ft}
-- >>> let rels = HM.fromList [((qi "films", "api"), [rel "directors", rel "roles", rel "actors"])]
--
-- >>> noRelBetweenHint "film" "directors" "api" rels
-- Just "Perhaps you meant 'films' instead of 'film'."
--
-- >>> noRelBetweenHint "films" "role" "api" rels
-- Just "Perhaps you meant 'roles' instead of 'role'."
--
-- >>> noRelBetweenHint "films" "role" "api" rels
-- Just "Perhaps you meant 'roles' instead of 'role'."
--
-- >>> noRelBetweenHint "films" "actors" "api" rels
-- Nothing
--
-- >>> noRelBetweenHint "noclosealternative" "roles" "api" rels
-- Nothing
--
-- >>> noRelBetweenHint "films" "noclosealternative" "api" rels
-- Nothing
--
-- >>> noRelBetweenHint "films" "noclosealternative" "noclosealternative" rels
-- Nothing
--
noRelBetweenHint :: Text -> Text -> Schema -> RelationshipsMap -> Maybe Text
noRelBetweenHint parent child schema allRels = ("Perhaps you meant '" <>) <$>
if isJust findParent
then (<> "' instead of '" <> child <> "'.") <$> suggestChild
else (<> "' instead of '" <> parent <> "'.") <$> suggestParent
where
findParent = HM.lookup (QualifiedIdentifier schema parent, schema) allRels
fuzzySetOfParents = Fuzzy.fromList [qiName (fst p) | p <- HM.keys allRels, snd p == schema]
fuzzySetOfChildren = Fuzzy.fromList [qiName (relForeignTable c) | c <- fromMaybe [] findParent]
suggestParent = Fuzzy.getOne fuzzySetOfParents parent
-- Do not give suggestion if the child is found in the relations (weight = 1.0)
suggestChild = headMay [snd k | k <- Fuzzy.get fuzzySetOfChildren child, fst k < 1.0]
-- |
-- If no function is found with the given name, it does a fuzzy search to all the functions
-- in the same schema and shows the best match as hint.
--
-- >>> :set -Wno-missing-fields
-- >>> let procs = [(QualifiedIdentifier "api" "test"), (QualifiedIdentifier "api" "another"), (QualifiedIdentifier "private" "other")]
--
-- >>> noRpcHint "api" "testt" ["val", "param", "name"] procs []
-- Just "Perhaps you meant to call the function api.test"
--
-- >>> noRpcHint "api" "other" [] procs []
-- Just "Perhaps you meant to call the function api.another"
--
-- >>> noRpcHint "api" "noclosealternative" [] procs []
-- Nothing
--
-- If a function is found with the given name, but no params match, then it does a fuzzy search
-- to all the overloaded functions' params using the form "param1, param2, param3, ..."
-- and shows the best match as hint.
--
-- >>> let procsDesc = [ProcDescription {pdParams = [ProcParam {ppName="val"}, ProcParam {ppName="param"}, ProcParam {ppName="name"}]}, ProcDescription {pdParams = [ProcParam {ppName="id"}, ProcParam {ppName="attr"}]}]
--
-- >>> noRpcHint "api" "test" ["vall", "pqaram", "nam"] procs procsDesc
-- Just "Perhaps you meant to call the function api.test(name, param, val)"
--
-- >>> noRpcHint "api" "test" ["val", "param"] procs procsDesc
-- Just "Perhaps you meant to call the function api.test(name, param, val)"
--
-- >>> noRpcHint "api" "test" ["id", "attrs"] procs procsDesc
-- Just "Perhaps you meant to call the function api.test(attr, id)"
--
-- >>> noRpcHint "api" "test" ["id"] procs procsDesc
-- Just "Perhaps you meant to call the function api.test(attr, id)"
--
-- >>> noRpcHint "api" "test" ["noclosealternative"] procs procsDesc
-- Nothing
--
noRpcHint :: Text -> Text -> [Text] -> [QualifiedIdentifier] -> [ProcDescription] -> Maybe Text
noRpcHint schema procName params allProcs overloadedProcs =
fmap (("Perhaps you meant to call the function " <> schema <> ".") <>) possibleProcs
where
fuzzySetOfProcs = Fuzzy.fromList [qiName k | k <- allProcs, qiSchema k == schema]
fuzzySetOfParams = Fuzzy.fromList $ listToText <$> [[ppName prm | prm <- pdParams ov] | ov <- overloadedProcs]
-- Cannot do a fuzzy search like: Fuzzy.getOne [[Text]] [Text], where [[Text]] is the list of params for each
-- overloaded function and [Text] the given params. This converts those lists to text to make fuzzy search possible.
-- E.g. ["val", "param", "name"] into "(name, param, val)"
listToText = ("(" <>) . (<> ")") . T.intercalate ", " . sort
possibleProcs
| null overloadedProcs = Fuzzy.getOne fuzzySetOfProcs procName
| otherwise = (procName <>) <$> Fuzzy.getOne fuzzySetOfParams (listToText params)
compressedRel :: Relationship -> JSON.Value compressedRel :: Relationship -> JSON.Value
-- An ambiguousness error cannot happen for computed relationships TODO refactor so this mempty is not needed -- An ambiguousness error cannot happen for computed relationships TODO refactor so this mempty is not needed
compressedRel ComputedRelationship{} = JSON.object mempty compressedRel ComputedRelationship{} = JSON.object mempty
@@ -182,7 +304,7 @@ compressedRel Relationship{..} =
: case relCardinality of : case relCardinality of
M2M Junction{..} -> [ M2M Junction{..} -> [
"cardinality" .= ("many-to-many" :: Text) "cardinality" .= ("many-to-many" :: Text)
, "relationship" .= (qiName junTable <> " using " <> junConstraint1 <> fmtEls (snd <$> junColumns1) <> " and " <> junConstraint2 <> fmtEls (snd <$> junColumns2)) , "relationship" .= (qiName junTable <> " using " <> junConstraint1 <> fmtEls (snd <$> junColsSource) <> " and " <> junConstraint2 <> fmtEls (snd <$> junColsTarget))
] ]
M2O cons relColumns -> [ M2O cons relColumns -> [
"cardinality" .= ("many-to-one" :: Text) "cardinality" .= ("many-to-one" :: Text)
@@ -225,22 +347,27 @@ 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.ConnectionError e) = JSON.object [ toJSON (SQL.ConnectionUsageError e) = JSON.object [
"code" .= ConnectionErrorCode00, "code" .= ConnectionErrorCode00,
"message" .= ("Database connection error. Retrying the connection." :: Text), "message" .= ("Database connection error. Retrying the connection." :: Text),
"details" .= (T.decodeUtf8With T.lenientDecode $ fromMaybe "" e :: Text), "details" .= (T.decodeUtf8With T.lenientDecode $ fromMaybe "" e :: Text),
"hint" .= JSON.Null] "hint" .= JSON.Null]
toJSON (SQL.SessionError e) = JSON.toJSON e -- SQL.Error toJSON (SQL.SessionUsageError e) = JSON.toJSON e -- SQL.Error
toJSON SQL.AcquisitionTimeoutUsageError = JSON.object [
"code" .= ConnectionErrorCode00,
"message" .= ("Timed out acquiring connection from connection pool." :: Text),
"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
toJSON (SQL.ResultError (SQL.ServerError c m d h)) = 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) = JSON.object [
"code" .= InternalErrorCode00, "code" .= InternalErrorCode00,
@@ -254,12 +381,14 @@ instance JSON.ToJSON SQL.CommandError where
"details" .= (fmap T.decodeUtf8 d :: Maybe Text), "details" .= (fmap T.decodeUtf8 d :: Maybe Text),
"hint" .= JSON.Null] "hint" .= JSON.Null]
pgErrorStatus :: Bool -> SQL.UsageError -> HTTP.Status pgErrorStatus :: Bool -> SQL.UsageError -> HTTP.Status
pgErrorStatus _ (SQL.ConnectionError _) = HTTP.status503 pgErrorStatus _ (SQL.ConnectionUsageError _) = HTTP.status503
pgErrorStatus _ (SQL.SessionError (SQL.QueryError _ _ (SQL.ClientError _))) = HTTP.status503 pgErrorStatus _ SQL.AcquisitionTimeoutUsageError = HTTP.status504
pgErrorStatus authed (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError rError))) = pgErrorStatus _ (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ClientError _))) = HTTP.status503
pgErrorStatus authed (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError rError))) =
case rError of case rError of
(SQL.ServerError c m _ _) -> (SQL.ServerError c m _ _ _) ->
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
@@ -296,25 +425,25 @@ pgErrorStatus authed (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError rErr
_ -> HTTP.status500 _ -> HTTP.status500
checkIsFatal :: PgError -> Maybe Text checkIsFatal :: PgError -> Maybe Text
checkIsFatal (PgError _ (SQL.ConnectionError e)) checkIsFatal (PgError _ (SQL.ConnectionUsageError e))
| isAuthFailureMessage = Just $ toS failureMessage | isAuthFailureMessage = Just $ toS failureMessage
| otherwise = Nothing | otherwise = Nothing
where isAuthFailureMessage = "FATAL: password authentication failed" `isPrefixOf` failureMessage where isAuthFailureMessage = "FATAL: password authentication failed" `isInfixOf` failureMessage
failureMessage = BS.unpack $ fromMaybe mempty e failureMessage = BS.unpack $ fromMaybe mempty e
checkIsFatal (PgError _ (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError serverError)))) checkIsFatal (PgError _ (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError serverError))))
= case serverError of = case serverError of
-- Check for a syntax error (42601 is the pg code). This would mean the error is on our part somehow, so we treat it as fatal. -- Check for a syntax error (42601 is the pg code). This would mean the error is on our part somehow, so we treat it as fatal.
SQL.ServerError "42601" _ _ _ SQL.ServerError "42601" _ _ _ _
-> Just "Hint: This is probably a bug in PostgREST, please report it at https://github.com/PostgREST/postgrest/issues" -> Just "Hint: This is probably a bug in PostgREST, please report it at https://github.com/PostgREST/postgrest/issues"
-- Check for a "prepared statement <name> already exists" error (Code 42P05: duplicate_prepared_statement). -- Check for a "prepared statement <name> already exists" error (Code 42P05: duplicate_prepared_statement).
-- This would mean that a connection pooler in transaction mode is being used -- This would mean that a connection pooler in transaction mode is being used
-- while prepared statements are enabled in the PostgREST configuration, -- while prepared statements are enabled in the PostgREST configuration,
-- both of which are incompatible with each other. -- both of which are incompatible with each other.
SQL.ServerError "42P05" _ _ _ SQL.ServerError "42P05" _ _ _ _
-> Just "Hint: If you are using connection poolers in transaction mode, try setting db-prepared-statements to false." -> Just "Hint: If you are using connection poolers in transaction mode, try setting db-prepared-statements to false."
-- Check for a "transaction blocks not allowed in statement pooling mode" error (Code 08P01: protocol_violation). -- Check for a "transaction blocks not allowed in statement pooling mode" error (Code 08P01: protocol_violation).
-- This would mean that a connection pooler in statement mode is being used which is not supported in PostgREST. -- This would mean that a connection pooler in statement mode is being used which is not supported in PostgREST.
SQL.ServerError "08P01" "transaction blocks not allowed in statement pooling mode" _ _ SQL.ServerError "08P01" "transaction blocks not allowed in statement pooling mode" _ _ _
-> Just "Hint: Connection poolers in statement mode are not supported." -> Just "Hint: Connection poolers in statement mode are not supported."
_ -> Nothing _ -> Nothing
checkIsFatal _ = Nothing checkIsFatal _ = Nothing
@@ -322,7 +451,6 @@ checkIsFatal _ = Nothing
data Error data Error
= ApiRequestError ApiRequestError = ApiRequestError ApiRequestError
| BinaryFieldError MediaType
| GucHeadersError | GucHeadersError
| GucStatusError | GucStatusError
| JwtTokenInvalid Text | JwtTokenInvalid Text
@@ -336,7 +464,6 @@ data Error
instance PgrstError Error where instance PgrstError Error where
status (ApiRequestError err) = status err status (ApiRequestError err) = status err
status BinaryFieldError{} = HTTP.status406
status GucHeadersError = HTTP.status500 status GucHeadersError = HTTP.status500
status GucStatusError = HTTP.status500 status GucStatusError = HTTP.status500
status JwtTokenInvalid{} = HTTP.unauthorized401 status JwtTokenInvalid{} = HTTP.unauthorized401
@@ -394,11 +521,6 @@ instance JSON.ToJSON Error where
"message" .= ("response.status guc must be a valid status code" :: Text), "message" .= ("response.status guc must be a valid status code" :: Text),
"details" .= JSON.Null, "details" .= JSON.Null,
"hint" .= JSON.Null] "hint" .= JSON.Null]
toJSON (BinaryFieldError ct) = JSON.object [
"code" .= ApiRequestErrorCode13,
"message" .= ((T.decodeUtf8 (MediaType.toMime ct) <> " requested but more than one column was selected") :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON PutMatchingPkError = JSON.object [ toJSON PutMatchingPkError = JSON.object [
"code" .= ApiRequestErrorCode15, "code" .= ApiRequestErrorCode15,
-122
View File
@@ -1,122 +0,0 @@
{-|
Module : PostgREST.Middleware
Description : Sets CORS policy. Also the PostgreSQL GUCs, role, search_path and pre-request function.
-}
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Middleware
( runPgLocals
, optionalRollback
) where
import qualified Data.Aeson as JSON
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.HashMap.Strict as HM
import qualified Data.Text.Encoding as T
import qualified Hasql.Decoders as HD
import qualified Hasql.DynamicStatements.Snippet as SQL hiding (sql)
import qualified Hasql.DynamicStatements.Statement as SQL
import qualified Hasql.Transaction as SQL
import qualified Network.Wai as Wai
import Control.Arrow ((***))
import Data.Scientific (FPFormat (..), formatScientific, isInteger)
import PostgREST.Config (AppConfig (..))
import PostgREST.Config.PgVersion (PgVersion (..), pgVersion140)
import PostgREST.Error (Error, errorResponseFor)
import PostgREST.GucHeader (addHeadersIfNotIncluded)
import PostgREST.Query.SqlFragment (fromQi, intercalateSnippet,
pgFmtIdentList, unknownEncoder)
import PostgREST.Request.ApiRequest (ApiRequest (..), Target (..))
import PostgREST.Request.Preferences
import Protolude
-- | Runs local(transaction scoped) GUCs for every request, plus the pre-request function
runPgLocals :: AppConfig -> KM.KeyMap JSON.Value -> Text ->
(ApiRequest -> ExceptT Error SQL.Transaction Wai.Response) ->
ApiRequest -> ByteString -> PgVersion -> ExceptT Error SQL.Transaction Wai.Response
runPgLocals conf claims role app req jsonDbS actualPgVersion = do
lift $ SQL.statement mempty $ SQL.dynamicallyParameterized
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql ++ specSql))
HD.noResult (configDbPreparedStatements conf)
lift $ traverse_ SQL.sql preReqSql
app req
where
methodSql = setConfigLocal mempty ("request.method", iMethod req)
pathSql = setConfigLocal mempty ("request.path", iPath req)
headersSql = if usesLegacyGucs
then setConfigLocal "request.header." <$> iHeaders req
else setConfigLocalJson "request.headers" (iHeaders req)
cookiesSql = if usesLegacyGucs
then setConfigLocal "request.cookie." <$> iCookies req
else setConfigLocalJson "request.cookies" (iCookies req)
claimsSql = if usesLegacyGucs
then setConfigLocal "request.jwt.claim." <$> [(toUtf8 $ K.toText c, toUtf8 $ unquoted v) | (c,v) <- KM.toList claims]
else [setConfigLocal mempty ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
roleSql = [setConfigLocal mempty ("role", toUtf8 role)]
appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings conf)
searchPathSql =
let schemas = pgFmtIdentList (iSchema req : configDbExtraSearchPath conf) in
setConfigLocal mempty ("search_path", schemas)
preReqSql = (\f -> "select " <> fromQi f <> "();") <$> configDbPreRequest conf
specSql = case iTarget req of
TargetProc{tpIsRootSpec=True} -> [setConfigLocal mempty ("request.spec", jsonDbS)]
_ -> mempty
usesLegacyGucs = configDbUseLegacyGucs conf && 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
-- | Set a transaction to eventually roll back if requested and set respective
-- headers on the response.
optionalRollback
:: AppConfig
-> ApiRequest
-> ExceptT Error SQL.Transaction Wai.Response
-> ExceptT Error SQL.Transaction Wai.Response
optionalRollback AppConfig{..} ApiRequest{..} transaction = do
resp <- catchError transaction $ return . errorResponseFor
when (shouldRollback || (configDbTxRollbackAll && not shouldCommit)) $ lift do
SQL.sql "SET CONSTRAINTS ALL IMMEDIATE"
SQL.condemn
return $ Wai.mapResponseHeaders preferenceApplied resp
where
shouldCommit =
configDbTxAllowOverride && iPreferTransaction == Just Commit
shouldRollback =
configDbTxAllowOverride && iPreferTransaction == Just Rollback
preferenceApplied
| shouldCommit =
addHeadersIfNotIncluded
[toAppliedHeader Commit]
| shouldRollback =
addHeadersIfNotIncluded
[toAppliedHeader Rollback]
| otherwise =
identity
-- | Do a pg set_config(setting, value, true) call. This is equivalent to a SET LOCAL.
setConfigLocal :: ByteString -> (ByteString, ByteString) -> SQL.Snippet
setConfigLocal prefix (k, v) =
"set_config(" <> unknownEncoder (prefix <> k) <> ", " <> unknownEncoder v <> ", true)"
-- | 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
setConfigLocalJson :: ByteString -> [(ByteString, ByteString)] -> [SQL.Snippet]
setConfigLocalJson prefix keyVals = [setConfigLocal mempty (prefix, gucJsonVal keyVals)]
where
gucJsonVal :: [(ByteString, ByteString)] -> ByteString
gucJsonVal = LBS.toStrict . JSON.encode . HM.fromList . arrayByteStringToText
arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)]
arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal
@@ -1,9 +1,9 @@
{-| {-|
Module : PostgREST.Request.DbRequestBuilder Module : PostgREST.Plan
Description : PostgREST database request builder Description : PostgREST Request Planner
This module is in charge of building an intermediate This module is in charge of building an intermediate
representation(ReadRequest, MutateRequest) between the HTTP request and the representation between the HTTP request and the
final resulting SQL query. final resulting SQL query.
A query tree is built in case of resource embedding. By inferring the A query tree is built in case of resource embedding. By inferring the
@@ -15,142 +15,172 @@ resource.
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.Request.DbRequestBuilder module PostgREST.Plan
( readRequest ( readPlan
, mutateRequest , mutateReadPlan
, callRequest , callReadPlan
, MutateReadPlan(..)
, CallReadPlan(..)
) where ) where
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 PostgREST.SchemaCache.Proc as Proc
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft)
import Data.List (delete) import Data.List (delete)
import Data.Tree (Tree (..)) import Data.Tree (Tree (..))
import PostgREST.DbStructure.Identifiers (FieldName, import PostgREST.ApiRequest (Action (..),
QualifiedIdentifier (..),
Schema, TableName)
import PostgREST.DbStructure.Proc (ProcDescription (..),
ProcParam (..),
procReturnsScalar)
import PostgREST.DbStructure.Relationship (Cardinality (..),
Junction (..),
Relationship (..),
RelationshipsMap)
import PostgREST.Error (Error (..))
import PostgREST.Query.SqlFragment (sourceCTEName)
import PostgREST.RangeQuery (NonnegRange, allRange,
restrictRange)
import PostgREST.Request.ApiRequest (Action (..),
ApiRequest (..), ApiRequest (..),
InvokeMethod (..), InvokeMethod (..),
Mutation (..), Mutation (..),
Payload (..)) Payload (..))
import PostgREST.Config (AppConfig (..))
import PostgREST.Error (Error (..))
import PostgREST.Query.SqlFragment (sourceCTEName)
import PostgREST.RangeQuery (NonnegRange, allRange,
convertToLimitZeroRange,
restrictRange)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema)
import PostgREST.SchemaCache.Proc (ProcDescription (..),
ProcParam (..),
procReturnsScalar)
import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..),
Relationship (..),
RelationshipsMap)
import PostgREST.SchemaCache.Table (tablePKCols)
import PostgREST.Request.MutateQuery import PostgREST.Plan.CallPlan
import PostgREST.Request.Preferences import PostgREST.Plan.MutatePlan
import PostgREST.Request.ReadQuery as ReadQuery import PostgREST.Plan.ReadPlan as ReadPlan
import PostgREST.Request.Types
import qualified PostgREST.Request.QueryParams as QueryParams import PostgREST.ApiRequest.Preferences
import PostgREST.ApiRequest.Types
import qualified PostgREST.ApiRequest.QueryParams as QueryParams
import Protolude hiding (from) import Protolude hiding (from)
-- | Builds the ReadRequest tree on a number of stages. data MutateReadPlan = MutateReadPlan {
mrReadPlan :: ReadPlanTree
, mrMutatePlan :: MutatePlan
}
data CallReadPlan = CallReadPlan {
crReadPlan :: ReadPlanTree
, crCallPlan :: CallPlan
}
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error MutateReadPlan
mutateReadPlan mutation apiRequest identifier conf sCache = do
rPlan <- readPlan identifier conf sCache apiRequest
mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan
return $ MutateReadPlan rPlan mPlan
callReadPlan :: ProcDescription -> AppConfig -> SchemaCache -> ApiRequest -> Either Error CallReadPlan
callReadPlan proc conf sCache apiRequest = do
let identifier = QualifiedIdentifier (pdSchema proc) (fromMaybe (pdName proc) $ Proc.procTableName proc)
rPlan <- readPlan identifier conf sCache apiRequest
let cPlan = callPlan proc apiRequest rPlan
return $ CallReadPlan rPlan cPlan
-- | Builds the ReadPlan tree on a number of stages.
-- | 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.
readRequest :: Schema -> TableName -> Maybe Integer -> RelationshipsMap -> ApiRequest -> Either Error ReadRequest readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
readRequest schema rootTableName maxRows allRels apiRequest = readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows} SchemaCache{dbRelationships} apiRequest =
mapLeft ApiRequestError $ mapLeft ApiRequestError $
treeRestrictRange maxRows (iAction apiRequest) =<< treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
augmentRequestWithJoin schema allRels =<< addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<<
addLogicTrees apiRequest =<< addLogicTrees apiRequest =<<
addRanges apiRequest =<< addRanges apiRequest =<<
addOrders apiRequest =<< addOrders apiRequest =<<
addFilters apiRequest (initReadRequest rootName rootAlias qsSelect) addFilters apiRequest (initReadRequest qi $ QueryParams.qsSelect $ iQueryParams apiRequest)
where
QueryParams.QueryParams{..} = iQueryParams apiRequest
(rootName, rootAlias) = case iAction apiRequest of
ActionRead _ -> (QualifiedIdentifier schema rootTableName, Nothing)
-- the CTE we use for non-read cases has a sourceCTEName(see Statements.hs) as the WITH name so we use the table name as an alias so findRel can find the right relationship
_ -> (QualifiedIdentifier mempty $ decodeUtf8 sourceCTEName, Just rootTableName)
-- Build the initial tree with a Depth attribute so when a self join occurs we -- Build the initial read plan tree
-- can differentiate the parent and child tables by having an alias like initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadPlanTree
-- "table_depth", this is related to initReadRequest qi@QualifiedIdentifier{..} =
-- http://github.com/PostgREST/postgrest/issues/987. foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi, relName=qiName, depth=rootDepth} []
initReadRequest :: QualifiedIdentifier -> Maybe Alias -> [Tree SelectItem] -> ReadRequest
initReadRequest rootQi rootAlias =
foldr (treeEntry rootDepth) initial
where where
rootDepth = 0 rootDepth = 0
rootSchema = qiSchema rootQi defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing rootDepth
rootName = qiName rootQi treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree
initial = Node (Select [] rootQi rootAlias [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, Nothing, rootDepth)) [] treeEntry depth (Node SelectRelation{..} fldForest) (Node q rForest) =
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
treeEntry depth (Node fld@((fn, _),_,alias, hint, joinType) fldForest) (Node (q, i) rForest) =
let nxtDepth = succ depth in let nxtDepth = succ depth in
case fldForest of Node q $
[] -> Node (q {select=fld:select q}, i) rForest foldr (treeEntry nxtDepth)
_ -> Node (q, i) $ (Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relAlias=selAlias, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth} [])
foldr (treeEntry nxtDepth) fldForest:rForest
(Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] allRange, treeEntry _ (Node SelectField{..} _) (Node q rForest) = Node q{select=(selField, selCast, selAlias):select q} rForest
(fn, Nothing, alias, hint, joinType, nxtDepth)) [])
fldForest:rForest
-- | Enforces the `max-rows` config on the result -- | Enforces the `max-rows` config on the result
treeRestrictRange :: Maybe Integer -> Action -> ReadRequest -> Either ApiRequestError ReadRequest treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
treeRestrictRange _ (ActionMutate _) request = Right request treeRestrictRange _ (ActionMutate _) request = Right request
treeRestrictRange maxRows _ request = pure $ nodeRestrictRange maxRows <$> request treeRestrictRange maxRows _ request = pure $ nodeRestrictRange maxRows <$> request
where where
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode nodeRestrictRange :: Maybe Integer -> ReadPlan -> ReadPlan
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i) nodeRestrictRange m q@ReadPlan{range_=r} = q{range_= convertToLimitZeroRange r (restrictRange m r) }
augmentRequestWithJoin :: Schema -> RelationshipsMap -> ReadRequest -> Either ApiRequestError ReadRequest -- add relationships to the nodes of the tree by traversing the forest while keeping track of the parentNode(https://stackoverflow.com/questions/22721064/get-the-parent-of-a-node-in-data-tree-haskell#comment34627048_22721064)
augmentRequestWithJoin schema allRels request = -- also adds aliasing
addJoinConditions Nothing <$> addRels schema allRels Nothing request addRels :: Schema -> Action -> RelationshipsMap -> Maybe ReadPlanTree -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,relAlias,depth} forest) =
addRels :: Schema -> RelationshipsMap -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, joinType, depth)) forest) =
case parentNode of case parentNode of
Just (Node (Select{from=parentNodeQi, fromAlias=aliasQi}, _) _) -> Just (Node ReadPlan{from=parentNodeQi, fromAlias=parentAlias} _) ->
let newFrom r = if qiName tbl == nodeName then relForeignTable r else tbl let
newReadNode = (\r -> newReadPlan = (\r ->
if not $ relIsSelf r -- add alias if self rel TODO consolidate aliasing in another function let newAlias = Just (qiName (relForeignTable r) <> "_" <> show depth)
then (query{from=newFrom r}, (nodeName, Just r, alias, hint, joinType, depth)) aggAlias = qiName (relTable r) <> "_" <> fromMaybe relName relAlias <> "_" <> show depth in
else (query{from=newFrom r, fromAlias=Just (qiName (newFrom r) <> "_" <> show depth)}, (nodeName, Just r, alias, hint, joinType, depth)) case r of
) <$> rel Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing
origin = if depth == 1 -- Only on depth 1 we check if the root(depth 0) has an alias so the sourceCTEName alias can be found as a relationship rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r}
then fromMaybe (qiName parentNodeQi) aliasQi ComputedRelationship{} ->
else qiName parentNodeQi rPlan{from=relForeignTable r, relToParent=Just r{relTable=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias}
rel = findRel schema allRels origin nodeName hint _ ->
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, fromAlias=newAlias, relJoinConds=getJoinConditions newAlias parentAlias r}
) <$> rel
origin = if depth == 1 -- Only on depth 1 we check if the root(depth 0) has an alias so the sourceCTEName alias can be found as a relationship
then fromMaybe (qiName parentNodeQi) parentAlias
else qiName parentNodeQi
rel = findRel schema allRels origin relName relHint
in in
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest) Node <$> newReadPlan <*> (updateForest . hush $ Node <$> newReadPlan <*> pure forest)
_ -> Nothing -> -- root case
let rn = (query, (nodeName, Nothing, alias, Nothing, joinType, depth)) in let
Node rn <$> updateForest (Just $ Node rn forest) newFrom = QualifiedIdentifier mempty $ decodeUtf8 sourceCTEName
newAlias = Just (qiName $ from rPlan)
newReadPlan = case action of
-- the CTE for mutations/rpc is used as WITH sourceCTEName .. SELECT .. FROM sourceCTEName as alias,
-- we use the table name as an alias so findRel can find the right relationship.
ActionMutate _ -> rPlan{from=newFrom, fromAlias=newAlias}
ActionInvoke _ -> rPlan{from=newFrom, fromAlias=newAlias}
_ -> rPlan
in
Node newReadPlan <$> updateForest (Just $ Node newReadPlan forest)
where where
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest] updateForest :: Maybe ReadPlanTree -> Either ApiRequestError [ReadPlanTree]
updateForest rq = addRels schema allRels rq `traverse` forest updateForest rq = addRels schema action allRels rq `traverse` forest
-- applies aliasing to join conditions TODO refactor, this should go into the querybuilder module getJoinConditions :: Maybe Alias -> Maybe Alias -> Relationship -> [JoinCondition]
addJoinConditions :: Maybe Alias -> ReadRequest -> ReadRequest getJoinConditions _ _ ComputedRelationship{} = []
addJoinConditions _ (Node node@(Select{fromAlias=tblAlias}, (_, Nothing, _, _, _, _)) forest) = Node node (addJoinConditions tblAlias <$> forest) getJoinConditions tblAlias parentAlias Relationship{relTable=qi,relForeignTable=fQi,relCardinality=card} =
addJoinConditions _ (Node node@(Select{fromAlias=tblAlias}, (_, Just ComputedRelationship{}, _, _, _, _)) forest) = Node node (addJoinConditions tblAlias <$> forest) case card of
addJoinConditions previousAlias (Node (query@Select{fromAlias=tblAlias}, nodeProps@(_, Just (Relationship QualifiedIdentifier{qiSchema=tSchema, qiName=tN} QualifiedIdentifier{qiName=ftN} _ card _ _), _, _, _, _)) forest) = M2M (Junction QualifiedIdentifier{qiName=jtn} _ _ jcols1 jcols2) ->
Node (query{joinConditions=joinConds}, nodeProps) (addJoinConditions tblAlias <$> forest) (toJoinCondition Nothing Nothing ftN jtn <$> jcols2) ++ (toJoinCondition parentAlias tblAlias tN jtn <$> jcols1)
O2M _ cols ->
toJoinCondition parentAlias tblAlias tN ftN <$> cols
M2O _ cols ->
toJoinCondition parentAlias tblAlias tN ftN <$> cols
O2O _ cols ->
toJoinCondition parentAlias tblAlias tN ftN <$> cols
where where
joinConds = QualifiedIdentifier{qiSchema=tSchema, qiName=tN} = qi
case card of QualifiedIdentifier{qiName=ftN} = fQi
M2M (Junction QualifiedIdentifier{qiName=jtn} _ _ jcols1 jcols2) ->
(toJoinCondition Nothing Nothing ftN jtn <$> jcols2) ++ (toJoinCondition previousAlias tblAlias tN jtn <$> jcols1)
O2M _ cols ->
toJoinCondition previousAlias tblAlias tN ftN <$> cols
M2O _ cols ->
toJoinCondition previousAlias tblAlias tN ftN <$> cols
O2O _ cols ->
toJoinCondition previousAlias tblAlias tN ftN <$> cols
toJoinCondition :: Maybe Alias -> Maybe Alias -> Text -> Text -> (FieldName, FieldName) -> JoinCondition toJoinCondition :: Maybe Alias -> Maybe Alias -> Text -> Text -> (FieldName, FieldName) -> JoinCondition
toJoinCondition prAl newAl tb ftb (c, fc) = toJoinCondition prAl newAl tb ftb (c, fc) =
let qi1 = QualifiedIdentifier tSchema ftb let qi1 = QualifiedIdentifier tSchema ftb
@@ -166,7 +196,7 @@ addJoinConditions previousAlias (Node (query@Select{fromAlias=tblAlias}, nodePro
findRel :: Schema -> RelationshipsMap -> NodeName -> NodeName -> Maybe Hint -> Either ApiRequestError Relationship findRel :: Schema -> RelationshipsMap -> NodeName -> NodeName -> Maybe Hint -> Either ApiRequestError Relationship
findRel schema allRels origin target hint = findRel schema allRels origin target hint =
case rels of case rels of
[] -> Left $ NoRelBetween origin target schema [] -> Left $ NoRelBetween origin target hint schema allRels
[r] -> Right r [r] -> Right r
rs -> Left $ AmbiguousRelBetween origin target rs rs -> Left $ AmbiguousRelBetween origin target rs
where where
@@ -241,7 +271,7 @@ findRel schema allRels origin target hint =
) )
) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier schema origin, schema) allRels ) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier schema origin, schema) allRels
addFilters :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest addFilters :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addFilters ApiRequest{..} rReq = addFilters ApiRequest{..} rReq =
foldr addFilterToNode (Right rReq) flts foldr addFilterToNode (Right rReq) flts
where where
@@ -254,11 +284,11 @@ addFilters ApiRequest{..} rReq =
ActionRead _ -> qsFilters ActionRead _ -> qsFilters
_ -> qsFiltersNotRoot _ -> qsFiltersNotRoot
addFilterToNode :: (EmbedPath, Filter) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest addFilterToNode :: (EmbedPath, Filter) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
addFilterToNode = addFilterToNode =
updateNode (\flt (Node (q@Select {where_=lf}, i) f) -> Node (q{ReadQuery.where_=addFilterToLogicForest flt lf}, i) f) updateNode (\flt (Node q@ReadPlan{where_=lf} f) -> Node q{ReadPlan.where_=addFilterToLogicForest flt lf} f)
addOrders :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest addOrders :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addOrders ApiRequest{..} rReq = addOrders ApiRequest{..} rReq =
case iAction of case iAction of
ActionMutate _ -> Right rReq ActionMutate _ -> Right rReq
@@ -266,10 +296,10 @@ addOrders ApiRequest{..} rReq =
where where
QueryParams.QueryParams{..} = iQueryParams QueryParams.QueryParams{..} = iQueryParams
addOrderToNode :: (EmbedPath, [OrderTerm]) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest addOrderToNode :: (EmbedPath, [OrderTerm]) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
addOrderToNode = updateNode (\o (Node (q,i) f) -> Node (q{order=o}, i) f) addOrderToNode = updateNode (\o (Node q f) -> Node q{order=o} f)
addRanges :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest addRanges :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addRanges ApiRequest{..} rReq = addRanges ApiRequest{..} rReq =
case iAction of case iAction of
ActionMutate _ -> Right rReq ActionMutate _ -> Right rReq
@@ -278,20 +308,20 @@ addRanges ApiRequest{..} rReq =
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)] ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
ranges = first QueryParamError $ QueryParams.pRequestRange `traverse` HM.toList iRange ranges = first QueryParamError $ QueryParams.pRequestRange `traverse` HM.toList iRange
addRangeToNode :: (EmbedPath, NonnegRange) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest addRangeToNode :: (EmbedPath, NonnegRange) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
addRangeToNode = updateNode (\r (Node (q,i) f) -> Node (q{range_=r}, i) f) addRangeToNode = updateNode (\r (Node q f) -> Node q{range_=r} f)
addLogicTrees :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest addLogicTrees :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addLogicTrees ApiRequest{..} rReq = addLogicTrees ApiRequest{..} rReq =
foldr addLogicTreeToNode (Right rReq) qsLogic foldr addLogicTreeToNode (Right rReq) qsLogic
where where
QueryParams.QueryParams{..} = iQueryParams QueryParams.QueryParams{..} = iQueryParams
addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
addLogicTreeToNode = updateNode (\t (Node (q@Select{where_=lf},i) f) -> Node (q{ReadQuery.where_=t:lf}, i) f) addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{where_=lf} f) -> Node q{ReadPlan.where_=t:lf} f)
-- Find a Node of the Tree and apply a function to it -- Find a Node of the Tree and apply a function to it
updateNode :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest updateNode :: (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
updateNode f ([], a) rr = f a <$> rr updateNode f ([], a) rr = f a <$> rr
updateNode _ _ (Left e) = Left e updateNode _ _ (Left e) = Left e
updateNode f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) = updateNode f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) =
@@ -301,14 +331,14 @@ updateNode f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) =
(\node -> Node rootNode $ node : delete target forest) <$> (\node -> Node rootNode $ node : delete target forest) <$>
updateNode f (remainingPath, a) (Right target) updateNode f (remainingPath, a) (Right target)
where where
findNode :: Maybe ReadRequest findNode :: Maybe ReadPlanTree
findNode = find (\(Node (_,(nodeName,_,alias,_,_, _)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest findNode = find (\(Node ReadPlan{relName, relAlias} _) -> relName == targetNodeName || relAlias == Just targetNodeName) forest
mutateRequest :: Mutation -> Schema -> TableName -> ApiRequest -> [FieldName] -> ReadRequest -> Either Error MutateRequest mutatePlan :: Mutation -> QualifiedIdentifier -> ApiRequest -> SchemaCache -> ReadPlanTree -> Either Error MutatePlan
mutateRequest mutation schema tName ApiRequest{..} pkCols readReq = mapLeft ApiRequestError $ mutatePlan mutation qi ApiRequest{..} sCache readReq = mapLeft ApiRequestError $
case mutation of case mutation of
MutationCreate -> MutationCreate ->
Right $ Insert qi iColumns body ((,) <$> iPreferResolution <*> Just confCols) [] returnings Right $ Insert qi iColumns body ((,) <$> iPreferResolution <*> Just confCols) [] returnings pkCols
MutationUpdate -> Right $ Update qi iColumns body combinedLogic iTopLevelRange rootOrder returnings MutationUpdate -> Right $ Update qi iColumns body combinedLogic iTopLevelRange rootOrder returnings
MutationSingleUpsert -> MutationSingleUpsert ->
if null qsLogic && if null qsLogic &&
@@ -317,31 +347,31 @@ mutateRequest mutation schema tName ApiRequest{..} pkCols readReq = mapLeft ApiR
all (\case all (\case
Filter _ (OpExpr False (Op OpEqual _)) -> True Filter _ (OpExpr False (Op OpEqual _)) -> True
_ -> False) qsFiltersRoot _ -> False) qsFiltersRoot
then Right $ Insert qi iColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings then Right $ Insert qi iColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings mempty
else else
Left InvalidFilters Left InvalidFilters
MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings
where where
confCols = fromMaybe pkCols qsOnConflict confCols = fromMaybe pkCols qsOnConflict
QueryParams.QueryParams{..} = iQueryParams QueryParams.QueryParams{..} = iQueryParams
qi = QualifiedIdentifier schema tName
returnings = returnings =
if iPreferRepresentation == None if iPreferRepresentation == None
then [] then []
else returningCols readReq pkCols else inferColsEmbedNeeds readReq pkCols
pkCols = maybe mempty tablePKCols $ HM.lookup qi $ dbTables sCache
logic = map snd qsLogic logic = map snd qsLogic
rootOrder = maybe [] snd $ find (\(x, _) -> null x) qsOrder rootOrder = maybe [] snd $ find (\(x, _) -> null x) qsOrder
combinedLogic = foldr addFilterToLogicForest logic qsFiltersRoot combinedLogic = foldr addFilterToLogicForest logic qsFiltersRoot
body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates) body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates)
callRequest :: ProcDescription -> ApiRequest -> ReadRequest -> CallRequest callPlan :: ProcDescription -> ApiRequest -> ReadPlanTree -> CallPlan
callRequest proc apiReq readReq = FunctionCall { callPlan proc apiReq readReq = FunctionCall {
funCQi = QualifiedIdentifier (pdSchema proc) (pdName proc) funCQi = QualifiedIdentifier (pdSchema proc) (pdName proc)
, funCParams = callParams , funCParams = callParams
, funCArgs = payRaw <$> iPayload apiReq , funCArgs = payRaw <$> iPayload apiReq
, funCScalar = procReturnsScalar proc , funCScalar = procReturnsScalar proc
, funCMultipleCall = iPreferParameters apiReq == Just MultipleObjects , funCMultipleCall = iPreferParameters apiReq == Just MultipleObjects
, funCReturning = returningCols readReq [] , funCReturning = inferColsEmbedNeeds readReq []
} }
where where
paramsAsSingleObject = iPreferParameters apiReq == Just SingleObject paramsAsSingleObject = iPreferParameters apiReq == Just SingleObject
@@ -352,32 +382,40 @@ callRequest proc apiReq readReq = FunctionCall {
prms -> KeyParams $ specifiedParams prms prms -> KeyParams $ specifiedParams prms
specifiedParams = filter (\x -> ppName x `S.member` iColumns apiReq) specifiedParams = filter (\x -> ppName x `S.member` iColumns apiReq)
returningCols :: ReadRequest -> [FieldName] -> [FieldName] -- | Infers the columns needed for an embed to be successful after a mutation or a function call.
returningCols rr@(Node _ forest) pkCols inferColsEmbedNeeds :: ReadPlanTree -> [FieldName] -> [FieldName]
inferColsEmbedNeeds (Node ReadPlan{select} forest) pkCols
-- if * is part of the select, we must not add pk or fk columns manually - -- if * is part of the select, we must not add pk or fk columns manually -
-- otherwise those would be selected and output twice -- otherwise those would be selected and output twice
| "*" `elem` fldNames = ["*"] | "*" `elem` fldNames = ["*"]
| otherwise = returnings | otherwise = returnings
where where
fldNames = fstFieldNames rr fldNames = (\((fld, _), _, _) -> fld) <$> select
-- Without fkCols, when a mutateRequest 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
-- fail because the following JOIN would need the "client_id" column from -- fail because the following JOIN would need the "client_id" column from
-- projects. So this adds the foreign key columns to ensure the embedding -- projects. So this adds the foreign key columns to ensure the embedding
-- succeeds, result would be `RETURNING name, client_id`. -- succeeds, result would be `RETURNING name, client_id`.
fkCols = concat $ mapMaybe (\case fkCols = concat $ mapMaybe (\case
Node (_, (_, Just Relationship{relCardinality=O2M _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols Node ReadPlan{relToParent=Just Relationship{relCardinality=O2M _ cols}} _ ->
Node (_, (_, Just Relationship{relCardinality=M2O _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols Just $ fst <$> cols
Node (_, (_, Just Relationship{relCardinality=O2O _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols Node ReadPlan{relToParent=Just Relationship{relCardinality=M2O _ cols}} _ ->
Node (_, (_, Just Relationship{relCardinality=M2M Junction{junColumns1, junColumns2}}, _, _, _, _)) _ -> Just $ (fst <$> junColumns1) ++ (fst <$> junColumns2) Just $ fst <$> cols
_ -> Nothing Node ReadPlan{relToParent=Just Relationship{relCardinality=O2O _ cols}} _ ->
Just $ fst <$> cols
Node ReadPlan{relToParent=Just Relationship{relCardinality=M2M Junction{junColsSource=cols}}} _ ->
Just $ fst <$> cols
Node ReadPlan{relToParent=Just ComputedRelationship{}} _ ->
Nothing
Node ReadPlan{relToParent=Nothing} _ ->
Nothing
) forest ) forest
hasComputedRel = isJust $ find (\case hasComputedRel = isJust $ find (\case
Node (_, (_, Just ComputedRelationship{}, _, _, _, _)) _ -> True Node ReadPlan{relToParent=Just ComputedRelationship{}} _ -> True
_ -> False _ -> False
) forest ) forest
-- However if the "client_id" is present, e.g. mutateRequest to -- However if the "client_id" is present, e.g. mutatePlan to
-- /projects?select=client_id,name,clients(name) we would get `RETURNING -- /projects?select=client_id,name,clients(name) we would get `RETURNING
-- client_id, name, client_id` and then we would produce the "column -- client_id, name, client_id` and then we would produce the "column
-- reference \"client_id\" is ambiguous" error from PostgreSQL. So we -- reference \"client_id\" is ambiguous" error from PostgreSQL. So we
+25
View File
@@ -0,0 +1,25 @@
module PostgREST.Plan.CallPlan
( CallPlan(..)
, CallParams(..)
)
where
import qualified Data.ByteString.Lazy as LBS
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier)
import PostgREST.SchemaCache.Proc (ProcParam (..))
import Protolude
data CallPlan = FunctionCall
{ funCQi :: QualifiedIdentifier
, funCParams :: CallParams
, funCArgs :: Maybe LBS.ByteString
, funCScalar :: Bool
, funCMultipleCall :: Bool
, funCReturning :: [FieldName]
}
data CallParams
= KeyParams [ProcParam] -- ^ Call with key params: func(a := val1, b:= val2)
| OnePosParam ProcParam -- ^ Call with positional params(only one supported): func(val)
@@ -1,23 +1,20 @@
module PostgREST.Request.MutateQuery module PostgREST.Plan.MutatePlan
( MutateQuery(..) ( MutatePlan(..)
, MutateRequest
) )
where where
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import qualified Data.Set as S import qualified Data.Set as S
import PostgREST.DbStructure.Identifiers (FieldName, import PostgREST.ApiRequest.Preferences (PreferResolution)
QualifiedIdentifier) import PostgREST.ApiRequest.Types (LogicTree, OrderTerm)
import PostgREST.RangeQuery (NonnegRange) import PostgREST.RangeQuery (NonnegRange)
import PostgREST.Request.Preferences (PreferResolution) import PostgREST.SchemaCache.Identifiers (FieldName,
import PostgREST.Request.Types (LogicTree, OrderTerm) QualifiedIdentifier)
import Protolude import Protolude
type MutateRequest = MutateQuery data MutatePlan
data MutateQuery
= Insert = Insert
{ in_ :: QualifiedIdentifier { in_ :: QualifiedIdentifier
, insCols :: S.Set FieldName , insCols :: S.Set FieldName
@@ -25,6 +22,7 @@ data MutateQuery
, onConflict :: Maybe (PreferResolution, [FieldName]) , onConflict :: Maybe (PreferResolution, [FieldName])
, where_ :: [LogicTree] , where_ :: [LogicTree]
, returning :: [FieldName] , returning :: [FieldName]
, insPkCols :: [FieldName]
} }
| Update | Update
{ in_ :: QualifiedIdentifier { in_ :: QualifiedIdentifier
+45
View File
@@ -0,0 +1,45 @@
module PostgREST.Plan.ReadPlan
( ReadPlanTree
, ReadPlan(..)
, JoinCondition(..)
) where
import Data.Tree (Tree (..))
import PostgREST.ApiRequest.Types (Alias, Cast, Depth, Field,
Hint, JoinType, LogicTree,
NodeName, OrderTerm)
import PostgREST.RangeQuery (NonnegRange)
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier)
import PostgREST.SchemaCache.Relationship (Relationship)
import Protolude
type ReadPlanTree = Tree ReadPlan
data JoinCondition =
JoinCondition
(QualifiedIdentifier, FieldName)
(QualifiedIdentifier, FieldName)
deriving (Eq)
data ReadPlan = ReadPlan
{ select :: [(Field, Maybe Cast, Maybe Alias)]
, from :: QualifiedIdentifier
, fromAlias :: Maybe Alias
, where_ :: [LogicTree]
, order :: [OrderTerm]
, range_ :: NonnegRange
, relName :: NodeName
, relToParent :: Maybe Relationship
, relJoinConds :: [JoinCondition]
, relAlias :: Maybe Alias
, relAggAlias :: Alias
, relHint :: Maybe Hint
, relJoinType :: Maybe JoinType
, depth :: Depth
-- ^ used for aliasing
}
deriving (Eq)
+294
View File
@@ -0,0 +1,294 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Query
( createQuery
, deleteQuery
, invokeQuery
, openApiQuery
, readQuery
, singleUpsertQuery
, txMode
, updateQuery
, setPgLocals
, DbHandler
) where
import qualified Data.Aeson as JSON
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.HashMap.Strict as HM
import qualified Data.Set as S
import qualified Data.Text.Encoding as T
import qualified Hasql.Decoders as HD
import qualified Hasql.DynamicStatements.Snippet as SQL (Snippet)
import qualified Hasql.DynamicStatements.Statement as SQL
import qualified Hasql.Transaction as SQL
import qualified Hasql.Transaction.Sessions as SQL
import qualified PostgREST.Error as Error
import qualified PostgREST.Query.QueryBuilder as QueryBuilder
import qualified PostgREST.Query.Statements as Statements
import qualified PostgREST.RangeQuery as RangeQuery
import qualified PostgREST.SchemaCache as SchemaCache
import qualified PostgREST.SchemaCache.Proc as Proc
import Data.Scientific (FPFormat (..), formatScientific, isInteger)
import PostgREST.ApiRequest (Action (..),
ApiRequest (..),
InvokeMethod (..),
Target (..))
import PostgREST.ApiRequest.Preferences (PreferCount (..),
PreferParameters (..),
PreferTransaction (..),
shouldCount)
import PostgREST.Config (AppConfig (..),
OpenAPIMode (..))
import PostgREST.Config.PgVersion (PgVersion (..),
pgVersion140)
import PostgREST.Error (Error)
import PostgREST.MediaType (MediaType (..))
import PostgREST.Plan (CallReadPlan (..),
MutateReadPlan (..))
import PostgREST.Plan.MutatePlan (MutatePlan (..))
import PostgREST.Plan.ReadPlan (ReadPlanTree)
import PostgREST.Query.SqlFragment (fromQi, intercalateSnippet,
pgFmtIdentList,
setConfigLocal,
setConfigLocalJson)
import PostgREST.Query.Statements (ResultSet (..))
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
Schema)
import PostgREST.SchemaCache.Proc (ProcDescription (..),
ProcVolatility (..),
ProcsMap)
import PostgREST.SchemaCache.Table (TablesMap)
import Protolude hiding (Handler)
type DbHandler = ExceptT Error SQL.Transaction
readQuery :: ReadPlanTree -> AppConfig -> ApiRequest -> DbHandler ResultSet
readQuery req conf@AppConfig{..} apiReq@ApiRequest{..} = do
let countQuery = QueryBuilder.readPlanToCountQuery req
resultSet <-
lift . SQL.statement mempty $
Statements.prepareRead
(QueryBuilder.readPlanToQuery req)
(if iPreferCount == Just EstimatedCount then
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
QueryBuilder.limitedQuery countQuery ((+ 1) <$> configDbMaxRows)
else
countQuery
)
(shouldCount iPreferCount)
iAcceptMediaType
iBinaryField
configDbPreparedStatements
failNotSingular iAcceptMediaType resultSet
optionalRollback conf apiReq
resultSetWTotal conf apiReq resultSet countQuery
resultSetWTotal :: AppConfig -> ApiRequest -> ResultSet -> SQL.Snippet -> DbHandler ResultSet
resultSetWTotal _ _ rs@RSPlan{} _ = return rs
resultSetWTotal AppConfig{..} ApiRequest{..} rs@RSStandard{rsTableTotal=tableTotal} countQuery =
case iPreferCount of
Just PlannedCount -> do
total <- explain
return rs{rsTableTotal=total}
Just EstimatedCount ->
if tableTotal > (fromIntegral <$> configDbMaxRows) then do
total <- max tableTotal <$> explain
return rs{rsTableTotal=total}
else
return rs
Just ExactCount ->
return rs
Nothing ->
return rs
where
explain =
lift . SQL.statement mempty . Statements.preparePlanRows countQuery $
configDbPreparedStatements
createQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
createQuery mrPlan apiReq@ApiRequest{..} conf = do
resultSet <- writeQuery mrPlan apiReq conf
failNotSingular iAcceptMediaType resultSet
optionalRollback conf apiReq
pure resultSet
updateQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
updateQuery mrPlan apiReq@ApiRequest{..} conf = do
resultSet <- writeQuery mrPlan apiReq conf
failNotSingular iAcceptMediaType resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq
pure resultSet
singleUpsertQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
singleUpsertQuery mrPlan apiReq conf = do
resultSet <- writeQuery mrPlan apiReq conf
failPut resultSet
optionalRollback conf apiReq
pure resultSet
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted,
-- PUT /items?id=eq.14 { "id" : 2, .. } is rejected.
-- If this condition is not satisfied then nothing is inserted,
-- check the WHERE for INSERT in QueryBuilder.hs to see how it's done
failPut :: ResultSet -> DbHandler ()
failPut RSPlan{} = pure ()
failPut RSStandard{rsQueryTotal=queryTotal} =
when (queryTotal /= 1) $ do
lift SQL.condemn
throwError Error.PutMatchingPkError
deleteQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
deleteQuery mrPlan apiReq@ApiRequest{..} conf = do
resultSet <- writeQuery mrPlan apiReq conf
failNotSingular iAcceptMediaType resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq
pure resultSet
invokeQuery :: ProcDescription -> CallReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
invokeQuery proc CallReadPlan{crReadPlan, crCallPlan} apiReq@ApiRequest{..} conf@AppConfig{..} = do
resultSet <-
lift . SQL.statement mempty $
Statements.prepareCall
(Proc.procReturnsScalar proc)
(Proc.procReturnsSingle proc)
(QueryBuilder.callPlanToQuery crCallPlan)
(QueryBuilder.readPlanToQuery crReadPlan)
(QueryBuilder.readPlanToCountQuery crReadPlan)
(shouldCount iPreferCount)
iAcceptMediaType
(iPreferParameters == Just MultipleObjects)
iBinaryField
configDbPreparedStatements
optionalRollback conf apiReq
failNotSingular iAcceptMediaType resultSet
pure resultSet
openApiQuery :: SchemaCache -> PgVersion -> AppConfig -> Schema -> DbHandler (Maybe (TablesMap, ProcsMap, Maybe Text))
openApiQuery sCache pgVer AppConfig{..} tSchema =
lift $ case configOpenApiMode of
OAFollowPriv -> do
tableAccess <- SQL.statement [tSchema] (SchemaCache.accessibleTables pgVer configDbPreparedStatements)
Just <$> ((,,)
(HM.filterWithKey (\qi _ -> S.member qi tableAccess) $ SchemaCache.dbTables sCache)
<$> SQL.statement tSchema (SchemaCache.accessibleProcs pgVer configDbPreparedStatements)
<*> SQL.statement tSchema (SchemaCache.schemaDescription configDbPreparedStatements))
OAIgnorePriv ->
Just <$> ((,,)
(HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ SchemaCache.dbTables sCache)
(HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ SchemaCache.dbProcs sCache)
<$> SQL.statement tSchema (SchemaCache.schemaDescription configDbPreparedStatements))
OADisabled ->
pure Nothing
txMode :: ApiRequest -> SQL.Mode
txMode ApiRequest{..} =
case (iAction, iTarget) of
(ActionRead _, _) ->
SQL.Read
(ActionInspect _, _) ->
SQL.Read
(ActionInvoke InvGet, _) ->
SQL.Read
(ActionInvoke InvHead, _) ->
SQL.Read
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Stable} _) ->
SQL.Read
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Immutable} _) ->
SQL.Read
_ ->
SQL.Write
writeQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
writeQuery MutateReadPlan{mrReadPlan, mrMutatePlan} apiReq conf =
let
(isInsert, pkCols) = case mrMutatePlan of {Insert{insPkCols} -> (True, insPkCols); _ -> (False, mempty);}
in
lift . SQL.statement mempty $
Statements.prepareWrite
(QueryBuilder.readPlanToQuery mrReadPlan)
(QueryBuilder.mutatePlanToQuery mrMutatePlan)
isInsert
(iAcceptMediaType apiReq)
(iPreferRepresentation apiReq)
pkCols
(configDbPreparedStatements conf)
-- |
-- Fail a response if a single JSON object was requested and not exactly one
-- was found.
failNotSingular :: MediaType -> ResultSet -> DbHandler ()
failNotSingular _ RSPlan{} = pure ()
failNotSingular mediaType RSStandard{rsQueryTotal=queryTotal} =
when (mediaType == MTSingularJSON && queryTotal /= 1) $ do
lift SQL.condemn
throwError $ Error.singularityError queryTotal
failsChangesOffLimits :: Maybe Integer -> ResultSet -> DbHandler ()
failsChangesOffLimits _ RSPlan{} = pure ()
failsChangesOffLimits Nothing _ = pure ()
failsChangesOffLimits (Just maxChanges) RSStandard{rsQueryTotal=queryTotal} =
when (queryTotal > fromIntegral maxChanges) $ do
lift SQL.condemn
throwError $ Error.OffLimitsChangesError queryTotal maxChanges
-- | Set a transaction to roll back if requested
optionalRollback :: AppConfig -> ApiRequest -> DbHandler ()
optionalRollback AppConfig{..} ApiRequest{..} = do
lift $ when (shouldRollback || (configDbTxRollbackAll && not shouldCommit)) $ do
SQL.sql "SET CONSTRAINTS ALL IMMEDIATE"
SQL.condemn
where
shouldCommit =
configDbTxAllowOverride && iPreferTransaction == Just Commit
shouldRollback =
configDbTxAllowOverride && iPreferTransaction == Just Rollback
-- | Runs local(transaction scoped) GUCs for every request, plus the pre-request function
setPgLocals :: AppConfig -> KM.KeyMap JSON.Value -> Text ->
ApiRequest -> ByteString -> PgVersion -> DbHandler ()
setPgLocals conf claims role req jsonDbS actualPgVersion = do
lift $ SQL.statement mempty $ SQL.dynamicallyParameterized
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql ++ specSql))
HD.noResult (configDbPreparedStatements conf)
lift $ traverse_ SQL.sql preReqSql
where
methodSql = setConfigLocal mempty ("request.method", iMethod req)
pathSql = setConfigLocal mempty ("request.path", iPath req)
headersSql = if usesLegacyGucs
then setConfigLocal "request.header." <$> iHeaders req
else setConfigLocalJson "request.headers" (iHeaders req)
cookiesSql = if usesLegacyGucs
then setConfigLocal "request.cookie." <$> iCookies req
else setConfigLocalJson "request.cookies" (iCookies req)
claimsSql = if usesLegacyGucs
then setConfigLocal "request.jwt.claim." <$> [(toUtf8 $ K.toText c, toUtf8 $ unquoted v) | (c,v) <- KM.toList claims]
else [setConfigLocal mempty ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
roleSql = [setConfigLocal mempty ("role", toUtf8 role)]
appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings conf)
searchPathSql =
let schemas = pgFmtIdentList (iSchema req : configDbExtraSearchPath conf) in
setConfigLocal mempty ("search_path", schemas)
preReqSql = (\f -> "select " <> fromQi f <> "();") <$> configDbPreRequest conf
specSql = case iTarget req of
TargetProc{tpIsRootSpec=True} -> [setConfigLocal mempty ("request.spec", jsonDbS)]
_ -> mempty
usesLegacyGucs = configDbUseLegacyGucs conf && 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
+50 -51
View File
@@ -5,14 +5,14 @@ Module : PostgREST.Query.QueryBuilder
Description : PostgREST SQL queries generating functions. Description : PostgREST SQL queries generating functions.
This module provides functions to consume data types that This module provides functions to consume data types that
represent database queries (e.g. ReadRequest, MutateRequest) and SqlFragment represent database queries (e.g. ReadPlanTree, MutatePlan) and SqlFragment
to produce SqlQuery type outputs. to produce SqlQuery type outputs.
-} -}
module PostgREST.Query.QueryBuilder module PostgREST.Query.QueryBuilder
( readRequestToQuery ( readPlanToQuery
, mutateRequestToQuery , mutatePlanToQuery
, readRequestToCountQuery , readPlanToCountQuery
, requestToCallProcQuery , callPlanToQuery
, limitedQuery , limitedQuery
) where ) where
@@ -22,46 +22,45 @@ import qualified Hasql.DynamicStatements.Snippet as SQL
import Data.Tree (Tree (..)) import Data.Tree (Tree (..))
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..)) import PostgREST.ApiRequest.Preferences (PreferResolution (..))
import PostgREST.DbStructure.Proc (ProcParam (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.DbStructure.Relationship (Cardinality (..), import PostgREST.SchemaCache.Proc (ProcParam (..))
import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
Relationship (..)) Relationship (..))
import PostgREST.Request.Preferences (PreferResolution (..))
import PostgREST.ApiRequest.Types
import PostgREST.Plan.CallPlan
import PostgREST.Plan.MutatePlan
import PostgREST.Plan.ReadPlan
import PostgREST.Query.SqlFragment import PostgREST.Query.SqlFragment
import PostgREST.RangeQuery (allRange) import PostgREST.RangeQuery (allRange)
import PostgREST.Request.MutateQuery
import PostgREST.Request.ReadQuery
import PostgREST.Request.Types
import Protolude import Protolude
readRequestToQuery :: ReadRequest -> SQL.Snippet readPlanToQuery :: ReadPlanTree -> SQL.Snippet
readRequestToQuery (Node (Select colSelects mainQi tblAlias logicForest joinConditions_ ordts range, (_, rel, _, _, _, _)) forest) = readPlanToQuery (Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds} forest) =
"SELECT " <> "SELECT " <>
intercalateSnippet ", " ((pgFmtSelectItem qi <$> colSelects) ++ selects) <> " " <> intercalateSnippet ", " ((pgFmtSelectItem qi <$> select) ++ selects) <> " " <>
fromFrag <> " " <> fromFrag <> " " <>
intercalateSnippet " " joins <> " " <> intercalateSnippet " " joins <> " " <>
(if null logicForest && null joinConditions_ (if null logicForest && null relJoinConds
then mempty then mempty
else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_)) <> " " <> else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition relJoinConds)) <> " " <>
orderF qi ordts <> " " <> orderF qi order <> " " <>
limitOffsetF range limitOffsetF readRange
where where
fromFrag = fromF rel mainQi tblAlias fromFrag = fromF relToParent mainQi fromAlias
qi = getQualifiedIdentifier rel mainQi tblAlias qi = getQualifiedIdentifier relToParent mainQi fromAlias
(selects, joins) = foldr getSelectsJoins ([],[]) forest (selects, joins) = foldr getSelectsJoins ([],[]) forest
getSelectsJoins :: ReadRequest -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet]) getSelectsJoins :: ReadPlanTree -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet])
getSelectsJoins (Node (_, (_, Nothing, _, _, _, _)) _) _ = ([], []) getSelectsJoins (Node ReadPlan{relToParent=Nothing} _) _ = ([], [])
getSelectsJoins rr@(Node (_, (name, Just rel, alias, _, joinType, _)) _) (selects,joins) = getSelectsJoins rr@(Node ReadPlan{relName, relToParent=Just rel, relAggAlias, relAlias, relJoinType=joinType} _) (selects,joins) =
let let
subquery = readRequestToQuery rr subquery = readPlanToQuery rr
aliasOrName = fromMaybe name alias aliasOrName = pgFmtIdent $ fromMaybe relName relAlias
locTblName = qiName (relTable rel) <> "_" <> aliasOrName aggAlias = pgFmtIdent relAggAlias
localTableName = pgFmtIdent locTblName
internalTableName = pgFmtIdent $ "_" <> locTblName
correlatedSubquery sub al cond = correlatedSubquery sub al cond =
(if joinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> SQL.sql al <> " ON " <> cond (if joinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> SQL.sql al <> " ON " <> cond
isToOne = case rel of isToOne = case rel of
@@ -71,19 +70,19 @@ getSelectsJoins rr@(Node (_, (name, Just rel, alias, _, joinType, _)) _) (select
_ -> False _ -> False
(sel, joi) = if isToOne (sel, joi) = if isToOne
then then
( SQL.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName) ( SQL.sql ("row_to_json(" <> aggAlias <> ".*) AS " <> aliasOrName)
, correlatedSubquery subquery localTableName "TRUE") , correlatedSubquery subquery aggAlias "TRUE")
else else
( SQL.sql $ "COALESCE( " <> localTableName <> "." <> internalTableName <> ", '[]') AS " <> pgFmtIdent aliasOrName ( SQL.sql $ "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> aliasOrName
, correlatedSubquery ( , correlatedSubquery (
"SELECT json_agg(" <> SQL.sql internalTableName <> ") AS " <> SQL.sql internalTableName <> "SELECT json_agg(" <> SQL.sql aggAlias <> ") AS " <> SQL.sql aggAlias <>
"FROM (" <> subquery <> " ) AS " <> SQL.sql internalTableName "FROM (" <> subquery <> " ) AS " <> SQL.sql aggAlias
) localTableName $ if joinType == Just JTInner then SQL.sql localTableName <> " IS NOT NULL" else "TRUE") ) aggAlias $ if joinType == Just JTInner then SQL.sql aggAlias <> " IS NOT NULL" else "TRUE")
in in
(sel:selects, joi:joins) (sel:selects, joi:joins)
mutateRequestToQuery :: MutateRequest -> SQL.Snippet mutatePlanToQuery :: MutatePlan -> SQL.Snippet
mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returnings) = mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _) =
"WITH " <> normalizedBody body <> " " <> "WITH " <> normalizedBody body <> " " <>
"INSERT INTO " <> SQL.sql (fromQi mainQi) <> SQL.sql (if S.null iCols then " " else "(" <> cols <> ") ") <> "INSERT INTO " <> SQL.sql (fromQi mainQi) <> SQL.sql (if S.null iCols then " " else "(" <> cols <> ") ") <>
"SELECT " <> SQL.sql cols <> " " <> "SELECT " <> SQL.sql cols <> " " <>
@@ -95,7 +94,7 @@ mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returning
if null oncCols then if null oncCols then
mempty mempty
else else
"ON CONFLICT(" <> BS.intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of " ON CONFLICT(" <> BS.intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
IgnoreDuplicates -> IgnoreDuplicates ->
"DO NOTHING" "DO NOTHING"
MergeDuplicates -> MergeDuplicates ->
@@ -109,7 +108,7 @@ mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returning
cols = BS.intercalate ", " $ pgFmtIdent <$> S.toList iCols cols = BS.intercalate ", " $ pgFmtIdent <$> S.toList iCols
-- An update without a limit is always filtered with a WHERE -- An update without a limit is always filtered with a WHERE
mutateRequestToQuery (Update mainQi uCols body logicForest range ordts returnings) mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings)
| S.null uCols = | S.null uCols =
-- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
-- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select= -- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select=
@@ -145,7 +144,7 @@ mutateRequestToQuery (Update mainQi uCols body logicForest range ordts returning
rangeCols = BS.intercalate ", " ((\col -> pgFmtIdent col <> " = (SELECT " <> pgFmtIdent col <> " FROM pgrst_update_body) ") <$> S.toList uCols) rangeCols = BS.intercalate ", " ((\col -> pgFmtIdent col <> " = (SELECT " <> pgFmtIdent col <> " FROM pgrst_update_body) ") <$> S.toList uCols)
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts) (whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts)
mutateRequestToQuery (Delete mainQi logicForest range ordts returnings) mutatePlanToQuery (Delete mainQi logicForest range ordts returnings)
| range == allRange = | range == allRange =
"DELETE FROM " <> SQL.sql (fromQi mainQi) <> " " <> "DELETE FROM " <> SQL.sql (fromQi mainQi) <> " " <>
whereLogic <> " " <> whereLogic <> " " <>
@@ -168,8 +167,8 @@ mutateRequestToQuery (Delete mainQi logicForest range ordts returnings)
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest) whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts) (whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts)
requestToCallProcQuery :: CallRequest -> SQL.Snippet callPlanToQuery :: CallPlan -> SQL.Snippet
requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall returnings) = callPlanToQuery (FunctionCall qi params args returnsScalar multipleCall returnings) =
prmsCTE <> argsBody prmsCTE <> argsBody
where where
(prmsCTE, argFrag) = case params of (prmsCTE, argFrag) = case params of
@@ -223,31 +222,31 @@ requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall r
-- For this case, we use a WHERE EXISTS instead of an INNER JOIN on the count query. -- For this case, we use a WHERE EXISTS instead of an INNER JOIN on the count query.
-- See https://github.com/PostgREST/postgrest/issues/2009#issuecomment-977473031 -- See https://github.com/PostgREST/postgrest/issues/2009#issuecomment-977473031
-- Only for the nodes that have an INNER JOIN linked to the root level. -- Only for the nodes that have an INNER JOIN linked to the root level.
readRequestToCountQuery :: ReadRequest -> SQL.Snippet readPlanToCountQuery :: ReadPlanTree -> SQL.Snippet
readRequestToCountQuery (Node (Select{from=mainQi, fromAlias=tblAlias, where_=logicForest, joinConditions=joinConditions_}, (_, rel, _, _, _, _)) forest) = readPlanToCountQuery (Node ReadPlan{from=mainQi, fromAlias=tblAlias, where_=logicForest, relToParent=rel, relJoinConds} forest) =
"SELECT 1 " <> fromFrag <> "SELECT 1 " <> fromFrag <>
(if null logicForest && null joinConditions_ && null subQueries (if null logicForest && null relJoinConds && null subQueries
then mempty then mempty
else " WHERE " ) <> else " WHERE " ) <>
intercalateSnippet " AND " ( intercalateSnippet " AND " (
map (pgFmtLogicTree qi) logicForest ++ map (pgFmtLogicTree qi) logicForest ++
map pgFmtJoinCondition joinConditions_ ++ map pgFmtJoinCondition relJoinConds ++
subQueries subQueries
) )
where where
qi = getQualifiedIdentifier rel mainQi tblAlias qi = getQualifiedIdentifier rel mainQi tblAlias
fromFrag = fromF rel mainQi tblAlias fromFrag = fromF rel mainQi tblAlias
subQueries = foldr existsSubquery [] forest subQueries = foldr existsSubquery [] forest
existsSubquery :: ReadRequest -> [SQL.Snippet] -> [SQL.Snippet] existsSubquery :: ReadPlanTree -> [SQL.Snippet] -> [SQL.Snippet]
existsSubquery readReq@(Node (_, (_, _, _, _, joinType, _)) _) rest = existsSubquery readReq@(Node ReadPlan{relJoinType=joinType} _) rest =
if joinType == Just JTInner if joinType == Just JTInner
then ("EXISTS (" <> readRequestToCountQuery readReq <> " )"):rest then ("EXISTS (" <> readPlanToCountQuery readReq <> " )"):rest
else rest else rest
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)
-- TODO refactor so this function is uneeded and ComputedRelationship QualifiedIdentifier comes from the ReadQuery type -- TODO refactor so this function is uneeded and ComputedRelationship QualifiedIdentifier comes from the ReadPlan type
getQualifiedIdentifier :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> QualifiedIdentifier getQualifiedIdentifier :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> QualifiedIdentifier
getQualifiedIdentifier rel mainQi tblAlias = case rel of getQualifiedIdentifier rel mainQi tblAlias = case rel of
Just ComputedRelationship{relFunction} -> QualifiedIdentifier mempty $ fromMaybe (qiName relFunction) tblAlias Just ComputedRelationship{relFunction} -> QualifiedIdentifier mempty $ fromMaybe (qiName relFunction) tblAlias
+38 -15
View File
@@ -1,5 +1,6 @@
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE QuasiQuotes #-}
{-| {-|
Module : PostgREST.Query.SqlFragment Module : PostgREST.Query.SqlFragment
Description : Helper functions for PostgREST.QueryBuilder. Description : Helper functions for PostgREST.QueryBuilder.
@@ -38,27 +39,27 @@ module PostgREST.Query.SqlFragment
, unknownEncoder , unknownEncoder
, intercalateSnippet , intercalateSnippet
, explainF , explainF
, setConfigLocal
, setConfigLocalJson
) where ) where
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.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Hasql.DynamicStatements.Snippet as SQL import qualified Hasql.DynamicStatements.Snippet as SQL
import qualified Hasql.Encoders as HE import qualified Hasql.Encoders as HE
import Control.Arrow ((***))
import Data.Foldable (foldr1) import Data.Foldable (foldr1)
import Text.InterpolatedString.Perl6 (qc) import Text.InterpolatedString.Perl6 (qc)
import PostgREST.DbStructure.Identifiers (FieldName, import PostgREST.ApiRequest.Types (Alias, Cast, Field,
QualifiedIdentifier (..)) Filter (..),
import PostgREST.MediaType (MTPlanFormat (..),
MTPlanOption (..))
import PostgREST.RangeQuery (NonnegRange, allRange,
rangeLimit, rangeOffset)
import PostgREST.Request.ReadQuery (SelectItem)
import PostgREST.Request.Types (Alias, Field, Filter (..),
FtsOperator (..), FtsOperator (..),
JoinCondition (..),
JsonOperand (..), JsonOperand (..),
JsonOperation (..), JsonOperation (..),
JsonPath, JsonPath,
@@ -70,6 +71,13 @@ import PostgREST.Request.Types (Alias, Field, Filter (..),
OrderTerm (..), OrderTerm (..),
SimpleOperator (..), SimpleOperator (..),
TrileanVal (..)) TrileanVal (..))
import PostgREST.MediaType (MTPlanFormat (..),
MTPlanOption (..))
import PostgREST.Plan.ReadPlan (JoinCondition (..))
import PostgREST.RangeQuery (NonnegRange, allRange,
rangeLimit, rangeOffset)
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..))
import Protolude hiding (cast) import Protolude hiding (cast)
@@ -128,7 +136,7 @@ normalizedBody body =
"END AS val", "END AS val",
"FROM pgrst_payload)"]) "FROM pgrst_payload)"])
where where
jsonPlaceHolder = SQL.encoderAndParam (HE.nullable HE.unknown) (LBS.toStrict <$> body) <> "::json" jsonPlaceHolder = SQL.encoderAndParam (HE.nullable HE.jsonLazyBytes) body
singleParameter :: Maybe LBS.ByteString -> ByteString -> SQL.Snippet singleParameter :: Maybe LBS.ByteString -> ByteString -> SQL.Snippet
singleParameter body typ = singleParameter body typ =
@@ -227,12 +235,12 @@ pgFmtField table (c, []) = SQL.sql (pgFmtColumn table c)
-- "operator does not exist: json = unknown" -- "operator does not exist: json = unknown"
pgFmtField table (c, jp) = SQL.sql ("to_jsonb(" <> pgFmtColumn table c <> ")") <> pgFmtJsonPath jp pgFmtField table (c, jp) = SQL.sql ("to_jsonb(" <> pgFmtColumn table c <> ")") <> pgFmtJsonPath jp
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SQL.Snippet pgFmtSelectItem :: QualifiedIdentifier -> (Field, Maybe Cast, Maybe Alias) -> SQL.Snippet
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _, _) = pgFmtField table f <> SQL.sql (pgFmtAs fName jp alias) pgFmtSelectItem table (f@(fName, jp), Nothing, alias) = pgFmtField table f <> SQL.sql (pgFmtAs fName jp alias)
-- 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 (f@(fName, jp), Just cast, alias, _, _) = "CAST (" <> pgFmtField table f <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> SQL.sql (pgFmtAs fName jp alias) pgFmtSelectItem table (f@(fName, jp), Just cast, alias) = "CAST (" <> pgFmtField table f <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> SQL.sql (pgFmtAs fName jp alias)
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SQL.Snippet pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SQL.Snippet
pgFmtOrderTerm qi ot = pgFmtOrderTerm qi ot =
@@ -386,3 +394,18 @@ explainF fmt opts snip =
fmtPlanFmt PlanJSON = "FORMAT JSON" fmtPlanFmt PlanJSON = "FORMAT JSON"
fmtPlanFmt PlanText = "FORMAT TEXT" fmtPlanFmt PlanText = "FORMAT TEXT"
-- | Do a pg set_config(setting, value, true) call. This is equivalent to a SET LOCAL.
setConfigLocal :: ByteString -> (ByteString, ByteString) -> SQL.Snippet
setConfigLocal prefix (k, v) =
"set_config(" <> unknownEncoder (prefix <> k) <> ", " <> unknownEncoder v <> ", true)"
-- | 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
setConfigLocalJson :: ByteString -> [(ByteString, ByteString)] -> [SQL.Snippet]
setConfigLocalJson prefix keyVals = [setConfigLocal mempty (prefix, gucJsonVal keyVals)]
where
gucJsonVal :: [(ByteString, ByteString)] -> ByteString
gucJsonVal = LBS.toStrict . JSON.encode . HM.fromList . arrayByteStringToText
arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)]
arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal
+10 -23
View File
@@ -15,30 +15,23 @@ module PostgREST.Query.Statements
, ResultSet (..) , ResultSet (..)
) where ) where
import qualified Data.Aeson as JSON
import qualified Data.Aeson.Lens as L import qualified Data.Aeson.Lens as L
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Hasql.Decoders as HD import qualified Hasql.Decoders as HD
import qualified Hasql.DynamicStatements.Snippet as SQL import qualified Hasql.DynamicStatements.Snippet as SQL
import qualified Hasql.DynamicStatements.Statement as SQL import qualified Hasql.DynamicStatements.Statement as SQL
import qualified Hasql.Statement as SQL import qualified Hasql.Statement as SQL
import Control.Lens ((^?)) import Control.Lens ((^?))
import Data.Maybe (fromJust) import Data.Maybe (fromJust)
import Data.Text.Read (decimal)
import Network.HTTP.Types.Status (Status)
import PostgREST.Error (Error (..)) import PostgREST.ApiRequest.Preferences
import PostgREST.GucHeader (GucHeader)
import PostgREST.DbStructure.Identifiers (FieldName)
import PostgREST.MediaType (MTPlanAttrs (..), import PostgREST.MediaType (MTPlanAttrs (..),
MTPlanFormat (..), MTPlanFormat (..),
MediaType (..), MediaType (..),
getMediaType) getMediaType)
import PostgREST.Query.SqlFragment import PostgREST.Query.SqlFragment
import PostgREST.Request.Preferences import PostgREST.SchemaCache.Identifiers (FieldName)
import Protolude import Protolude
@@ -54,9 +47,9 @@ data ResultSet
-- variable bindings like @"k1=eq.42"@, or the empty list if there is no location header. -- variable bindings like @"k1=eq.42"@, or the empty list if there is no location header.
, rsBody :: BS.ByteString , rsBody :: BS.ByteString
-- ^ the aggregated body of the query -- ^ the aggregated body of the query
, rsGucHeaders :: Either Error [GucHeader] , rsGucHeaders :: Maybe BS.ByteString
-- ^ the HTTP headers to be added to the response -- ^ the HTTP headers to be added to the response
, rsGucStatus :: Either Error (Maybe Status) , rsGucStatus :: Maybe Text
-- ^ the HTTP status to be added to the response -- ^ the HTTP status to be added to the response
} }
| RSPlan BS.ByteString -- ^ the plan of the query | RSPlan BS.ByteString -- ^ the plan of the query
@@ -104,7 +97,7 @@ prepareWrite selectQuery mutateQuery isInsert mt rep pKeys =
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mt of decodeIt = case mt of
MTPlan{} -> planRow MTPlan{} -> planRow
_ -> fromMaybe (RSStandard Nothing 0 mempty mempty (Right []) (Right Nothing)) <$> HD.rowMaybe (standardRow False) _ -> fromMaybe (RSStandard Nothing 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow False)
prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> Maybe FieldName -> Bool -> SQL.Statement () ResultSet prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> Maybe FieldName -> Bool -> SQL.Statement () ResultSet
prepareRead selectQuery countQuery countTotal mt binaryField = prepareRead selectQuery countQuery countTotal mt binaryField =
@@ -169,7 +162,7 @@ prepareCall returnsScalar returnsSingle callProcQuery selectQuery countQuery cou
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mt of decodeIt = case mt of
MTPlan{} -> planRow MTPlan{} -> planRow
_ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty (Right []) (Right Nothing)) <$> HD.rowMaybe (standardRow True) _ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty 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 =
@@ -185,8 +178,8 @@ 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) <*> column HD.bytea
<*> (fromMaybe (Right []) <$> nullableColumn decodeGucHeaders) <*> nullableColumn HD.bytea
<*> (fromMaybe (Right Nothing) <$> nullableColumn decodeGucStatus) <*> nullableColumn HD.text
where where
splitKeyValue :: ByteString -> (ByteString, ByteString) splitKeyValue :: ByteString -> (ByteString, ByteString)
splitKeyValue kv = splitKeyValue kv =
@@ -202,12 +195,6 @@ mtSnippet mediaType snippet = case mediaType of
planRow :: HD.Result ResultSet planRow :: HD.Result ResultSet
planRow = RSPlan . BS.unlines <$> HD.rowList (column HD.bytea) planRow = RSPlan . BS.unlines <$> HD.rowList (column HD.bytea)
decodeGucHeaders :: HD.Value (Either Error [GucHeader])
decodeGucHeaders = first (const GucHeadersError) . JSON.eitherDecode . LBS.fromStrict <$> HD.bytea
decodeGucStatus :: HD.Value (Either Error (Maybe Status))
decodeGucStatus = first (const GucStatusError) . fmap (Just . toEnum . fst) . decimal <$> HD.text
column :: HD.Value a -> HD.Row a column :: HD.Value a -> HD.Row a
column = HD.column . HD.nonNullable column = HD.column . HD.nonNullable
+7
View File
@@ -12,6 +12,7 @@ module PostgREST.RangeQuery (
, allRange , allRange
, limitZeroRange , limitZeroRange
, hasLimitZero , hasLimitZero
, convertToLimitZeroRange
, NonnegRange , NonnegRange
, rangeStatusHeader , rangeStatusHeader
, contentRangeH , contentRangeH
@@ -86,6 +87,12 @@ limitZeroRange = Range (BoundaryBelow 0) (BoundaryAbove (-1))
hasLimitZero :: Range Integer -> Bool hasLimitZero :: Range Integer -> Bool
hasLimitZero r = rangeUpper r == rangeUpper limitZeroRange hasLimitZero r = rangeUpper r == rangeUpper limitZeroRange
-- Used to convert a range into a special limitZeroRange if it has a
-- limit=0 in order to bypass validations for empty ranges.
convertToLimitZeroRange :: Range Integer -> Range Integer -> Range Integer
convertToLimitZeroRange range fallbackRange =
if hasLimitZero range then limitZeroRange else fallbackRange
rangeStatusHeader :: NonnegRange -> Int64 -> Maybe Int64 -> (Status, Header) rangeStatusHeader :: NonnegRange -> Int64 -> Maybe Int64 -> (Status, Header)
rangeStatusHeader topLevelRange queryTotal tableTotal = rangeStatusHeader topLevelRange queryTotal tableTotal =
let lower = rangeOffset topLevelRange let lower = rangeOffset topLevelRange
-46
View File
@@ -1,46 +0,0 @@
module PostgREST.Request.ReadQuery
( ReadNode
, ReadQuery(..)
, ReadRequest
, SelectItem
, fstFieldNames
) where
import Data.Tree (Tree (..))
import PostgREST.DbStructure.Identifiers (FieldName,
QualifiedIdentifier)
import PostgREST.DbStructure.Relationship (Relationship)
import PostgREST.RangeQuery (NonnegRange)
import PostgREST.Request.Types (Alias, Cast, Depth, Field,
Hint, JoinCondition,
JoinType, LogicTree,
NodeName, OrderTerm)
import Protolude
type ReadRequest = Tree ReadNode
type ReadNode =
(ReadQuery, (NodeName, Maybe Relationship, Maybe Alias, Maybe Hint, Maybe JoinType, Depth))
-- | The select value in `/tbl?select=alias:field::cast`
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe Hint, Maybe JoinType)
data ReadQuery = Select
{ select :: [SelectItem]
, from :: QualifiedIdentifier
, fromAlias :: Maybe Alias
-- ^ A table alias is used in case of self joins
, where_ :: [LogicTree]
, joinConditions :: [JoinCondition]
, order :: [OrderTerm]
, range_ :: NonnegRange
}
deriving (Eq)
-- First level FieldNames(e.g get a,b from /table?select=a,b,other(c,d))
fstFieldNames :: ReadRequest -> [FieldName]
fstFieldNames (Node (sel, _) _) =
fst . (\(f, _, _, _, _) -> f) <$> select sel
+288
View File
@@ -0,0 +1,288 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Response
( createResponse
, deleteResponse
, infoResponse
, invokeResponse
, openApiResponse
, readResponse
, singleUpsertResponse
, updateResponse
, addRetryHint
, isServiceUnavailable
, optionalRollback
) where
import qualified Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.HashMap.Strict as HM
import Data.Text.Read (decimal)
import qualified Network.HTTP.Types.Header as HTTP
import qualified Network.HTTP.Types.Status 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.MediaType as MediaType
import qualified PostgREST.RangeQuery as RangeQuery
import qualified PostgREST.Response.OpenAPI as OpenAPI
import PostgREST.ApiRequest (ApiRequest (..),
InvokeMethod (..),
Target (..))
import PostgREST.ApiRequest.Preferences (PreferRepresentation (..),
PreferTransaction (..),
shouldCount,
toAppliedHeader)
import PostgREST.ApiRequest.QueryParams (QueryParams (..))
import PostgREST.Config (AppConfig (..))
import PostgREST.MediaType (MediaType (..))
import PostgREST.Plan (MutateReadPlan (..))
import PostgREST.Plan.MutatePlan (MutatePlan (..))
import PostgREST.Query.Statements (ResultSet (..))
import PostgREST.Response.GucHeader (GucHeader, unwrapGucHeader)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
Schema)
import PostgREST.SchemaCache.Proc (ProcDescription (..),
ProcVolatility (..),
ProcsMap)
import PostgREST.SchemaCache.Table (Table (..), TablesMap)
import qualified PostgREST.ApiRequest.Types as ApiRequestTypes
import qualified PostgREST.SchemaCache.Proc as Proc
import Protolude hiding (Handler, toS)
import Protolude.Conv (toS)
readResponse :: Bool -> QualifiedIdentifier -> ApiRequest -> ResultSet -> Wai.Response
readResponse headersOnly identifier ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
RSStandard{..} -> do
let
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
response = gucResponse rsGucStatus rsGucHeaders
headers =
[ contentRange
, ( "Content-Location"
, "/"
<> toUtf8 (qiName identifier)
<> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams
)
]
++ contentTypeHeaders ctxApiRequest
rsOrErrBody = if status == HTTP.status416
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
else LBS.fromStrict rsBody
response status headers $ if headersOnly then mempty else rsOrErrBody
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
createResponse :: QualifiedIdentifier -> MutateReadPlan -> ApiRequest -> ResultSet -> Wai.Response
createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan} ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
RSStandard{..} -> do
let
pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;}
response = gucResponse rsGucStatus rsGucHeaders
headers =
catMaybes
[ if null rsLocation then
Nothing
else
Just
( HTTP.hLocation
, "/"
<> toUtf8 qiName
<> HTTP.renderSimpleQuery True rsLocation
)
, Just . RangeQuery.contentRangeH 1 0 $
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
, if null pkCols && isNothing (qsOnConflict iQueryParams) then
Nothing
else
toAppliedHeader <$> iPreferResolution
]
if iPreferRepresentation == Full then
response HTTP.status201 (headers ++ contentTypeHeaders ctxApiRequest) (LBS.fromStrict rsBody)
else
response HTTP.status201 headers mempty
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
updateResponse :: ApiRequest -> ResultSet -> Wai.Response
updateResponse ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
RSStandard{..} -> do
let
response = gucResponse rsGucStatus rsGucHeaders
contentRangeHeader =
RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
headers = [contentRangeHeader]
if iPreferRepresentation == Full then
response HTTP.status200
(headers ++ contentTypeHeaders ctxApiRequest)
(LBS.fromStrict rsBody)
else
response HTTP.status204 headers mempty
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
singleUpsertResponse :: ApiRequest -> ResultSet -> Wai.Response
singleUpsertResponse ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
RSStandard {..} -> do
let
response = gucResponse rsGucStatus rsGucHeaders
if iPreferRepresentation == Full then
response HTTP.status200 (contentTypeHeaders ctxApiRequest) (LBS.fromStrict rsBody)
else
response HTTP.status204 [] mempty
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
deleteResponse :: ApiRequest -> ResultSet -> Wai.Response
deleteResponse ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
RSStandard {..} -> do
let
response = gucResponse rsGucStatus rsGucHeaders
contentRangeHeader =
RangeQuery.contentRangeH 1 0 $
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
headers = [contentRangeHeader]
if iPreferRepresentation == Full then
response HTTP.status200
(headers ++ contentTypeHeaders ctxApiRequest)
(LBS.fromStrict rsBody)
else
response HTTP.status204 headers mempty
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
infoResponse :: Target -> SchemaCache -> Wai.Response
infoResponse target sCache =
case target of
TargetIdent identifier ->
case HM.lookup identifier (dbTables sCache) of
Just tbl -> respondInfo $ allowH tbl
Nothing -> Error.errorResponseFor $ Error.ApiRequestError ApiRequestTypes.NotFound
TargetProc pd _
| pdVolatility pd == Volatile -> respondInfo "OPTIONS,POST"
| otherwise -> respondInfo "OPTIONS,GET,HEAD,POST"
TargetDefaultSpec _ -> respondInfo "OPTIONS,GET,HEAD"
where
respondInfo allowHeader = Wai.responseLBS HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty
allOrigins = ("Access-Control-Allow-Origin", "*")
allowH table =
let hasPK = not . null $ tablePKCols table in
BS.intercalate "," $
["OPTIONS,GET,HEAD"] ++
["POST" | tableInsertable table] ++
["PUT" | tableInsertable table && tableUpdatable table && hasPK] ++
["PATCH" | tableUpdatable table] ++
["DELETE" | tableDeletable table]
invokeResponse :: InvokeMethod -> ProcDescription -> ApiRequest -> ResultSet -> Wai.Response
invokeResponse invMethod proc ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
RSStandard {..} -> do
let
response = gucResponse rsGucStatus rsGucHeaders
(status, contentRange) =
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
rsOrErrBody = if status == HTTP.status416
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
else LBS.fromStrict rsBody
headers = [contentRange]
if Proc.procReturnsVoid proc then
response HTTP.status204 headers mempty
else
response status
(headers ++ contentTypeHeaders ctxApiRequest)
(if invMethod == InvHead then mempty else rsOrErrBody)
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
openApiResponse :: Bool -> Maybe (TablesMap, ProcsMap, Maybe Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Wai.Response
openApiResponse headersOnly body conf sCache schema negotiatedByProfile =
Wai.responseLBS HTTP.status200
(MediaType.toContentType MTOpenAPI : maybeToList (profileHeader schema negotiatedByProfile))
(maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode conf sCache x y z) body)
-- | Response with headers and status overridden from GUCs.
gucResponse
:: Maybe Text
-> Maybe BS.ByteString
-> HTTP.Status
-> [HTTP.Header]
-> 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 (Right []) $ first (const Error.GucHeadersError) . JSON.eitherDecode . LBS.fromStrict
decodeGucStatus :: Maybe Text -> Either Error.Error (Maybe HTTP.Status)
decodeGucStatus =
maybe (Right Nothing) $ first (const Error.GucStatusError) . fmap (Just . toEnum . fst) . decimal
contentTypeHeaders :: ApiRequest -> [HTTP.Header]
contentTypeHeaders ApiRequest{..} =
MediaType.toContentType iAcceptMediaType : maybeToList (profileHeader iSchema iNegotiatedByProfile)
profileHeader :: Schema -> Bool -> Maybe HTTP.Header
profileHeader schema negotiatedByProfile =
if negotiatedByProfile
then Just $ (,) "Content-Profile" (toS schema)
else
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{..} resp = do
newRes <- catchError resp $ return . Error.errorResponseFor
return $ Wai.mapResponseHeaders preferenceApplied newRes
where
shouldCommit =
configDbTxAllowOverride && iPreferTransaction == Just Commit
shouldRollback =
configDbTxAllowOverride && iPreferTransaction == 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
addHeadersIfNotIncluded :: [HTTP.Header] -> [HTTP.Header] -> [HTTP.Header]
addHeadersIfNotIncluded newHeaders initialHeaders =
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
initialHeaders
@@ -1,7 +1,6 @@
module PostgREST.GucHeader module PostgREST.Response.GucHeader
( GucHeader ( GucHeader
, unwrapGucHeader , unwrapGucHeader
, addHeadersIfNotIncluded
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
@@ -29,9 +28,3 @@ instance JSON.FromJSON GucHeader where
unwrapGucHeader :: GucHeader -> Header unwrapGucHeader :: GucHeader -> Header
unwrapGucHeader (GucHeader (k, v)) = (k, v) unwrapGucHeader (GucHeader (k, v)) = (k, v)
-- | Add headers not already included to allow the user to override them instead of duplicating them
addHeadersIfNotIncluded :: [Header] -> [Header] -> [Header]
addHeadersIfNotIncluded newHeaders initialHeaders =
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
initialHeaders
@@ -4,7 +4,7 @@ Description : Generates the OpenAPI output
-} -}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.OpenAPI (encode) where module PostgREST.Response.OpenAPI (encode) 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
@@ -26,14 +26,14 @@ import Data.Swagger
import PostgREST.Config (AppConfig (..), Proxy (..), import PostgREST.Config (AppConfig (..), Proxy (..),
isMalformedProxyUri, toURI) isMalformedProxyUri, toURI)
import PostgREST.DbStructure (DbStructure (..)) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.DbStructure.Proc (ProcDescription (..), import PostgREST.SchemaCache.Proc (ProcDescription (..),
ProcParam (..)) ProcParam (..))
import PostgREST.DbStructure.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Relationship (..), Relationship (..),
RelationshipsMap) RelationshipsMap)
import PostgREST.DbStructure.Table (Column (..), Table (..), import PostgREST.SchemaCache.Table (Column (..), Table (..),
TablesMap) TablesMap)
import PostgREST.Version (docsVersion, prettyVersion) import PostgREST.Version (docsVersion, prettyVersion)
@@ -41,11 +41,11 @@ import PostgREST.MediaType
import Protolude hiding (Proxy, get) import Protolude hiding (Proxy, get)
encode :: AppConfig -> DbStructure -> TablesMap -> HM.HashMap k [ProcDescription] -> Maybe Text -> LBS.ByteString encode :: AppConfig -> SchemaCache -> TablesMap -> HM.HashMap k [ProcDescription] -> Maybe Text -> LBS.ByteString
encode conf dbStructure tables procs schemaDescription = encode conf sCache tables procs schemaDescription =
JSON.encode $ JSON.encode $
postgrestSpec postgrestSpec
(dbRelationships dbStructure) (dbRelationships sCache)
(concat $ HM.elems procs) (concat $ HM.elems procs)
(snd <$> HM.toList tables) (snd <$> HM.toList tables)
(proxyUri conf) (proxyUri conf)
@@ -66,10 +66,16 @@ toSwaggerType "bigint" = Just SwaggerInteger
toSwaggerType "numeric" = Just SwaggerNumber toSwaggerType "numeric" = Just SwaggerNumber
toSwaggerType "real" = Just SwaggerNumber toSwaggerType "real" = Just SwaggerNumber
toSwaggerType "double precision" = Just SwaggerNumber toSwaggerType "double precision" = Just SwaggerNumber
toSwaggerType "ARRAY" = Just SwaggerArray
toSwaggerType "json" = Nothing toSwaggerType "json" = Nothing
toSwaggerType "jsonb" = Nothing toSwaggerType "jsonb" = Nothing
toSwaggerType _ = Just SwaggerString toSwaggerType colType = case T.takeEnd 2 colType of
"[]" -> Just SwaggerArray
_ -> Just SwaggerString
makeSwaggerItemType :: Maybe (SwaggerType t) -> Text -> Maybe (Referenced Schema)
makeSwaggerItemType itemType colType = case itemType of
Just SwaggerArray -> Just $ Inline (mempty & type_ .~ toSwaggerType (T.dropEnd 2 colType))
_ -> Nothing
parseDefault :: Text -> Text -> Text parseDefault :: Text -> Text -> Text
parseDefault colType colDefault = parseDefault colType colDefault =
@@ -97,11 +103,14 @@ makeProperty tbl rels col = (colName col, Inline s)
fk :: Maybe Text fk :: Maybe Text
fk = fk =
let let
searchedRels = fromMaybe mempty $ HM.lookup (QualifiedIdentifier (tableSchema tbl) (tableName tbl), tableSchema tbl) rels
-- Sorts the relationship list to get tables first
relsSortedByIsView = sortOn relFTableIsView [ r | r@Relationship{} <- searchedRels]
-- Finds the relationship that has a single column foreign key -- Finds the relationship that has a single column foreign key
rel = find (\case rel = find (\case
Relationship{relCardinality=(M2O _ relColumns)} -> [colName col] == (fst <$> relColumns) Relationship{relCardinality=(M2O _ relColumns)} -> [colName col] == (fst <$> relColumns)
_ -> False _ -> False
) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier (tableSchema tbl) (tableName tbl), tableSchema tbl) rels ) relsSortedByIsView
fCol = (headMay . (\r -> snd <$> relColumns (relCardinality r)) =<< rel) fCol = (headMay . (\r -> snd <$> relColumns (relCardinality r)) =<< rel)
fTbl = qiName . relForeignTable <$> rel fTbl = qiName . relForeignTable <$> rel
fTblCol = (,) <$> fTbl <*> fCol fTblCol = (,) <$> fTbl <*> fCol
@@ -119,6 +128,7 @@ makeProperty tbl rels col = (colName col, Inline s)
Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n) Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n)
else else
colDescription col colDescription col
pType = toSwaggerType (colType col)
s = s =
(mempty :: Schema) (mempty :: Schema)
& default_ .~ (JSON.decode . toUtf8Lazy . parseDefault (colType col) =<< colDefault col) & default_ .~ (JSON.decode . toUtf8Lazy . parseDefault (colType col) =<< colDefault col)
@@ -126,7 +136,8 @@ makeProperty tbl rels col = (colName col, Inline s)
& enum_ .~ e & enum_ .~ e
& format ?~ colType col & format ?~ colType col
& maxLength .~ (fromIntegral <$> colMaxLen col) & maxLength .~ (fromIntegral <$> colMaxLen col)
& type_ .~ toSwaggerType (colType col) & type_ .~ pType
& items .~ (SwaggerItemsObject <$> makeSwaggerItemType pType (colType col))
makeProcSchema :: ProcDescription -> Schema makeProcSchema :: ProcDescription -> Schema
makeProcSchema pd = makeProcSchema pd =
@@ -141,6 +152,7 @@ makeProcProperty (ProcParam n t _ _) = (n, Inline s)
where where
s = (mempty :: Schema) s = (mempty :: Schema)
& type_ .~ toSwaggerType t & type_ .~ toSwaggerType t
& items .~ (SwaggerItemsObject <$> makeSwaggerItemType (toSwaggerType t) t)
& format ?~ t & format ?~ t
makePreferParam :: [Text] -> Param makePreferParam :: [Text] -> Param
@@ -152,7 +164,15 @@ makePreferParam ts =
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamHeader & in_ .~ ParamHeader
& type_ ?~ SwaggerString & type_ ?~ SwaggerString
& enum_ .~ JSON.decode (JSON.encode ts)) & enum_ .~ JSON.decode (JSON.encode $ foldl (<>) [] (val <$> ts)))
where
val :: Text -> [Text]
val = \case
"count" -> ["count=none"]
"params" -> ["params=single-object"]
"return" -> ["return=representation", "return=minimal", "return=none"]
"resolution" -> ["resolution=ignore-duplicates", "resolution=merge-duplicates"]
_ -> []
makeProcParam :: ProcDescription -> [Referenced Param] makeProcParam :: ProcDescription -> [Referenced Param]
makeProcParam pd = makeProcParam pd =
@@ -165,9 +185,11 @@ makeProcParam pd =
makeParamDefs :: [Table] -> [(Text, Param)] makeParamDefs :: [Table] -> [(Text, Param)]
makeParamDefs ti = makeParamDefs ti =
[ ("preferParams", makePreferParam ["params=single-object"]) -- TODO: create Prefer for each method (GET, PATCH, etc.)
, ("preferReturn", makePreferParam ["return=representation", "return=minimal", "return=none"]) [ ("preferParams", makePreferParam ["params"])
, ("preferCount", makePreferParam ["count=none"]) , ("preferReturn", makePreferParam ["return"])
, ("preferCount", makePreferParam ["count"])
, ("preferPost", makePreferParam ["return", "resolution"])
, ("select", (mempty :: Param) , ("select", (mempty :: Param)
& name .~ "select" & name .~ "select"
& description ?~ "Filtering Columns" & description ?~ "Filtering Columns"
@@ -267,7 +289,7 @@ makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t
) )
) )
postOp = tOp postOp = tOp
& parameters .~ fmap ref ["body." <> tn, "select", "preferReturn"] & parameters .~ fmap ref ["body." <> tn, "select", "preferPost"]
& at 201 ?~ "Created" & at 201 ?~ "Created"
patchOp = tOp patchOp = tOp
& parameters .~ fmap ref (rs <> ["body." <> tn, "preferReturn"]) & parameters .~ fmap ref (rs <> ["body." <> tn, "preferReturn"])
@@ -1,8 +1,8 @@
{-| {-|
Module : PostgREST.DbStructure Module : PostgREST.SchemaCache
Description : PostgREST schema cache Description : PostgREST schema cache
This module contains queries that target PostgreSQL system catalogs, these are used to build the schema cache(DbStructure). This module(used to be named DbStructure) contains queries that target PostgreSQL system catalogs, these are used to build the schema cache(SchemaCache).
The schema cache is necessary for resource embedding, foreign keys are used for inferring the relationships between tables. The schema cache is necessary for resource embedding, foreign keys are used for inferring the relationships between tables.
@@ -18,9 +18,9 @@ These queries are executed once at startup or when PostgREST is reloaded.
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE TypeSynonymInstances #-}
module PostgREST.DbStructure module PostgREST.SchemaCache
( DbStructure(..) ( SchemaCache(..)
, queryDbStructure , querySchemaCache
, accessibleTables , accessibleTables
, accessibleProcs , accessibleProcs
, schemaDescription , schemaDescription
@@ -40,25 +40,25 @@ import Text.InterpolatedString.Perl6 (q)
import PostgREST.Config.Database (pgVersionStatement) import PostgREST.Config.Database (pgVersionStatement)
import PostgREST.Config.PgVersion (PgVersion, pgVersion100, import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
pgVersion110) pgVersion110)
import PostgREST.DbStructure.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (AccessSet, FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
Schema) Schema)
import PostgREST.DbStructure.Proc (PgType (..), import PostgREST.SchemaCache.Proc (PgType (..),
ProcDescription (..), ProcDescription (..),
ProcParam (..), ProcParam (..),
ProcVolatility (..), ProcVolatility (..),
ProcsMap, RetType (..)) ProcsMap, RetType (..))
import PostgREST.DbStructure.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
Relationship (..), Relationship (..),
RelationshipsMap) RelationshipsMap)
import PostgREST.DbStructure.Table (Column (..), Table (..), import PostgREST.SchemaCache.Table (Column (..), Table (..),
TablesMap) TablesMap)
import Protolude import Protolude
data DbStructure = DbStructure data SchemaCache = SchemaCache
{ dbTables :: TablesMap { dbTables :: TablesMap
, dbRelationships :: RelationshipsMap , dbRelationships :: RelationshipsMap
, dbProcs :: ProcsMap , dbProcs :: ProcsMap
@@ -66,12 +66,33 @@ data DbStructure = DbStructure
deriving (Generic, JSON.ToJSON) deriving (Generic, JSON.ToJSON)
-- | 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.
--
-- create view projects_view as
-- select
-- id as id_1,
-- id as id_2,
-- id as id_3,
-- name
-- from projects
--
-- In this case, the keyDepCols mapping maps projects.id to all three of the columns:
--
-- [('id', ['id_1', 'id_2', 'id_3'])]
--
-- Depending on key type, we can then choose how to handle this case. Primary keys
-- can arbitrarily choose one of the columns, but for foreign keys we need to create
-- relationships for each possible mutations.
--
-- Previously, we stored a (FieldName, FieldName) tuple only, but then we had no
-- way to make a difference between a multi-column-key and a single-column-key with multiple
-- references in the view. Or even worse in the multi-column-key-multi-reference case...
data ViewKeyDependency = ViewKeyDependency { data ViewKeyDependency = ViewKeyDependency {
keyDepTable :: QualifiedIdentifier keyDepTable :: QualifiedIdentifier
, keyDepView :: QualifiedIdentifier , keyDepView :: QualifiedIdentifier
, keyDepCons :: Text , keyDepCons :: Text
, keyDepType :: KeyDep , keyDepType :: KeyDep
, keyDepCols :: [(FieldName, FieldName)] -- ^ First element is the table column, second is the view column , keyDepCols :: [(FieldName, [FieldName])] -- ^ First element is the table column, second is a list of view columns
} deriving (Eq) } deriving (Eq)
data KeyDep data KeyDep
= PKDep -- ^ PK dependency = PKDep -- ^ PK dependency
@@ -82,8 +103,8 @@ data KeyDep
-- | A SQL query that can be executed independently -- | A SQL query that can be executed independently
type SqlQuery = ByteString type SqlQuery = ByteString
queryDbStructure :: [Schema] -> [Schema] -> Bool -> SQL.Transaction DbStructure querySchemaCache :: [Schema] -> [Schema] -> Bool -> SQL.Transaction SchemaCache
queryDbStructure schemas extraSearchPath prepared = do querySchemaCache schemas extraSearchPath prepared = 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
pgVer <- SQL.statement mempty pgVersionStatement pgVer <- SQL.statement mempty pgVersionStatement
tabs <- SQL.statement schemas $ allTables pgVer prepared tabs <- SQL.statement schemas $ allTables pgVer prepared
@@ -95,7 +116,7 @@ queryDbStructure schemas extraSearchPath prepared = do
let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps
rels = addInverseRels $ addM2MRels tabsWViewsPks $ addViewM2OAndO2ORels keyDeps m2oRels rels = addInverseRels $ addM2MRels tabsWViewsPks $ addViewM2OAndO2ORels keyDeps m2oRels
return $ removeInternal schemas $ DbStructure { return $ removeInternal schemas $ SchemaCache {
dbTables = tabsWViewsPks dbTables = tabsWViewsPks
, dbRelationships = getOverrideRelationshipsMap rels cRels , dbRelationships = getOverrideRelationshipsMap rels cRels
, dbProcs = procs , dbProcs = procs
@@ -121,10 +142,10 @@ getOverrideRelationshipsMap rels cRels =
deformedRelMap = HM.fromListWith (++) . fmap addDeformedRelKey . HM.toList deformedRelMap = HM.fromListWith (++) . fmap addDeformedRelKey . HM.toList
addDeformedRelKey ((relT, relFT), rls) = ((relT, qiSchema relFT), rls) addDeformedRelKey ((relT, relFT), rls) = ((relT, qiSchema relFT), rls)
-- | Remove db objects that belong to an internal schema(not exposed through the API) from the DbStructure. -- | Remove db objects that belong to an internal schema(not exposed through the API) from the SchemaCache.
removeInternal :: [Schema] -> DbStructure -> DbStructure removeInternal :: [Schema] -> SchemaCache -> SchemaCache
removeInternal schemas dbStruct = removeInternal schemas dbStruct =
DbStructure { SchemaCache {
dbTables = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch `elem` schemas) $ dbTables dbStruct dbTables = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch `elem` schemas) $ dbTables dbStruct
, dbRelationships = filter (\r -> qiSchema (relForeignTable r) `elem` schemas && not (hasInternalJunction r)) <$> , dbRelationships = filter (\r -> qiSchema (relForeignTable r) `elem` schemas && not (hasInternalJunction r)) <$>
HM.filterWithKey (\(QualifiedIdentifier sch _, _) _ -> sch `elem` schemas ) (dbRelationships dbStruct) HM.filterWithKey (\(QualifiedIdentifier sch _, _) _ -> sch `elem` schemas ) (dbRelationships dbStruct)
@@ -136,6 +157,14 @@ removeInternal schemas dbStruct =
M2M Junction{junTable} -> qiSchema junTable `notElem` schemas M2M Junction{junTable} -> qiSchema junTable `notElem` schemas
_ -> False _ -> False
decodeAccessibleIdentifiers :: HD.Result AccessSet
decodeAccessibleIdentifiers =
S.fromList <$> HD.rowList row
where
row = QualifiedIdentifier
<$> column HD.text
<*> column HD.text
decodeTables :: HD.Result TablesMap decodeTables :: HD.Result TablesMap
decodeTables = decodeTables =
HM.fromList . map (\tbl@Table{tableSchema, tableName} -> (QualifiedIdentifier tableSchema tableName, tbl)) <$> HD.rowList tblRow HM.fromList . map (\tbl@Table{tableSchema, tableName} -> (QualifiedIdentifier tableSchema tableName, tbl)) <$> HD.rowList tblRow
@@ -184,9 +213,9 @@ decodeViewKeyDeps =
<*> compositeArrayColumn <*> compositeArrayColumn
((,) ((,)
<$> compositeField HD.text <$> compositeField HD.text
<*> compositeField HD.text) <*> compositeFieldArray HD.text)
viewKeyDepFromRow :: (Text,Text,Text,Text,Text,Text,[(Text, Text)]) -> ViewKeyDependency viewKeyDepFromRow :: (Text,Text,Text,Text,Text,Text,[(Text, [Text])]) -> ViewKeyDependency
viewKeyDepFromRow (s1,t1,s2,v2,cons,consType,sCols) = ViewKeyDependency (QualifiedIdentifier s1 t1) (QualifiedIdentifier s2 v2) cons keyDep sCols viewKeyDepFromRow (s1,t1,s2,v2,cons,consType,sCols) = ViewKeyDependency (QualifiedIdentifier s1 t1) (QualifiedIdentifier s2 v2) cons keyDep sCols
where where
keyDep | consType == "p" = PKDep keyDep | consType == "p" = PKDep
@@ -331,11 +360,27 @@ schemaDescription =
where where
n.nspname = $1 |] n.nspname = $1 |]
accessibleTables :: PgVersion -> Bool -> SQL.Statement [Schema] TablesMap accessibleTables :: PgVersion -> Bool -> SQL.Statement [Schema] AccessSet
accessibleTables pgVer = accessibleTables pgVer =
SQL.Statement sql (arrayParam HE.text) decodeTables SQL.Statement sql (arrayParam HE.text) decodeAccessibleIdentifiers
where where
sql = tablesSqlQuery False pgVer sql = [q|
SELECT
n.nspname AS table_schema,
c.relname AS table_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('v','r','m','f','p')
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
AND n.nspname = ANY($1)
AND (
pg_has_role(c.relowner, 'USAGE')
or has_table_privilege(c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER')
or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES')
) |] <>
relIsPartition <>
"ORDER BY table_schema, table_name"
relIsPartition = if pgVer >= pgVersion100 then " AND not c.relispartition " else mempty
{- {-
Adds M2O and O2O relationships for views to tables, tables to views, and views to views. The example below is taken from the test fixtures, but the views names/colnames were modified. Adds M2O and O2O relationships for views to tables, tables to views, and views to views. The example below is taken from the test fixtures, but the views names/colnames were modified.
@@ -370,19 +415,21 @@ addViewM2OAndO2ORels keyDeps rels =
(keyDepView vwTbl) (keyDepView vwTbl)
relForeignTable relForeignTable
False False
((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vCol) (_, fCol)-> (vCol, fCol)) (keyDepCols vwTbl) relCols) ((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vCol) (_, fCol)-> (vCol, fCol)) keyDepColsVwTbl relCols)
True True
False False
| vwTbl <- viewTableRels ] | vwTbl <- viewTableRels
, keyDepColsVwTbl <- expandKeyDepCols $ keyDepCols vwTbl ]
++ ++
[ Relationship [ Relationship
relTable relTable
(keyDepView tblVw) (keyDepView tblVw)
False False
((if isM2O card then M2O else O2O) cons $ zipWith (\(tCol, _) (_, vCol) -> (tCol, vCol)) relCols (keyDepCols tblVw)) ((if isM2O card then M2O else O2O) cons $ zipWith (\(tCol, _) (_, vCol) -> (tCol, vCol)) relCols keyDepColsTblVw)
False False
True True
| tblVw <- tableViewRels ] | tblVw <- tableViewRels
, keyDepColsTblVw <- expandKeyDepCols $ keyDepCols tblVw ]
++ ++
[ [
let let
@@ -393,13 +440,16 @@ addViewM2OAndO2ORels keyDeps rels =
vw1 vw1
vw2 vw2
(vw1 == vw2) (vw1 == vw2)
((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vcol1) (_, vcol2) -> (vcol1, vcol2)) (keyDepCols vwTbl) (keyDepCols tblVw)) ((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vcol1) (_, vcol2) -> (vcol1, vcol2)) keyDepColsVwTbl keyDepColsTblVw)
True True
True True
| vwTbl <- viewTableRels | vwTbl <- viewTableRels
, tblVw <- tableViewRels ] , keyDepColsVwTbl <- expandKeyDepCols $ keyDepCols vwTbl
, tblVw <- tableViewRels
, keyDepColsTblVw <- expandKeyDepCols $ keyDepCols tblVw ]
else [] else []
viewRels _ = [] viewRels _ = []
expandKeyDepCols kdc = zip (fst <$> kdc) <$> sequenceA (snd <$> kdc)
addInverseRels :: [Relationship] -> [Relationship] addInverseRels :: [Relationship] -> [Relationship]
addInverseRels rels = addInverseRels rels =
@@ -428,18 +478,24 @@ addViewPrimaryKeys tabs keyDeps =
else tbl) <$> tabs else tbl) <$> tabs
where where
findViewPKCols sch vw = findViewPKCols sch vw =
maybe [] (\(ViewKeyDependency _ _ _ _ pkCols) -> snd <$> pkCols) $ concatMap (\(ViewKeyDependency _ _ _ _ pkCols) -> takeFirstPK pkCols) $
find (\(ViewKeyDependency _ viewQi _ dep _) -> dep == PKDep && viewQi == QualifiedIdentifier sch vw) keyDeps filter (\(ViewKeyDependency _ viewQi _ dep _) -> dep == PKDep && viewQi == QualifiedIdentifier sch vw) keyDeps
-- In the case of multiple reference to the same PK (see comment for ViewKeyDependency) we take the first reference available.
-- We assume this to be safe to do, because:
-- * We don't have any logic that requires the client to name a PK column (compared to the column hints in embedding for FKs),
-- so we don't need to know about the other references.
-- * We need to choose a single reference for each column, otherwise we'd output too many columns in location headers etc.
takeFirstPK pkCols = catMaybes $ head . snd <$> pkCols
allTables :: PgVersion -> Bool -> SQL.Statement [Schema] TablesMap allTables :: PgVersion -> Bool -> SQL.Statement [Schema] TablesMap
allTables pgVer = allTables pgVer =
SQL.Statement sql (arrayParam HE.text) decodeTables SQL.Statement sql (arrayParam HE.text) decodeTables
where where
sql = tablesSqlQuery True pgVer sql = tablesSqlQuery pgVer
-- | Gets tables with their PK cols -- | Gets tables with their PK cols
tablesSqlQuery :: Bool -> PgVersion -> SqlQuery tablesSqlQuery :: PgVersion -> SqlQuery
tablesSqlQuery getAll pgVer = tablesSqlQuery pgVer =
-- the tbl_constraints/key_col_usage CTEs are based on the standard "information_schema.table_constraints"/"information_schema.key_column_usage" views, -- the tbl_constraints/key_col_usage CTEs are based on the standard "information_schema.table_constraints"/"information_schema.key_column_usage" views,
-- we cannot use those directly as they include the following privilege filter: -- we cannot use those directly as they include the following privilege filter:
-- (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text)); -- (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text));
@@ -456,13 +512,11 @@ tablesSqlQuery getAll pgVer =
CASE CASE
WHEN t.typtype = 'd' THEN WHEN t.typtype = 'd' THEN
CASE CASE
WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text
WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer) WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer)
ELSE format_type(a.atttypid, a.atttypmod) ELSE format_type(a.atttypid, a.atttypmod)
END END
ELSE ELSE
CASE CASE
WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text
WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer) WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer)
ELSE format_type(a.atttypid, a.atttypmod) ELSE format_type(a.atttypid, a.atttypmod)
END END
@@ -630,16 +684,8 @@ tablesSqlQuery getAll pgVer =
WHERE c.relkind IN ('v','r','m','f','p') WHERE c.relkind IN ('v','r','m','f','p')
AND n.nspname NOT IN ('pg_catalog', 'information_schema') |] <> AND n.nspname NOT IN ('pg_catalog', 'information_schema') |] <>
relIsPartition <> relIsPartition <>
fltTables <>
"ORDER BY table_schema, table_name" "ORDER BY table_schema, table_name"
where where
fltTables = if getAll then mempty else [q|
AND n.nspname = ANY($1)
AND (
pg_has_role(c.relowner, 'USAGE')
or has_table_privilege(c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER')
or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES')
)|]
relIsPartition = if pgVer >= pgVersion100 then " AND not c.relispartition " else mempty relIsPartition = if pgVer >= pgVersion100 then " AND not c.relispartition " else mempty
@@ -679,9 +725,9 @@ allM2OandO2ORels pgVer =
FROM pg_constraint traint FROM pg_constraint traint
JOIN LATERAL ( JOIN LATERAL (
SELECT SELECT
array_agg(row(cols.attname, refs.attname) order by cols.attnum) AS cols_and_fcols, array_agg(row(cols.attname, refs.attname) order by ord) AS cols_and_fcols,
jsonb_agg(cols.attname order by cols.attnum) AS cols jsonb_agg(cols.attname order by ord) AS cols
FROM ( SELECT unnest(traint.conkey) AS col, unnest(traint.confkey) AS ref) _ FROM unnest(traint.conkey, traint.confkey) WITH ORDINALITY AS _(col, ref, ord)
JOIN pg_attribute cols ON cols.attrelid = traint.conrelid AND cols.attnum = col JOIN pg_attribute cols ON cols.attrelid = traint.conrelid AND cols.attnum = col
JOIN pg_attribute refs ON refs.attrelid = traint.confrelid AND refs.attnum = ref JOIN pg_attribute refs ON refs.attrelid = traint.confrelid AND refs.attnum = ref
) AS column_info ON TRUE ) AS column_info ON TRUE
@@ -710,13 +756,13 @@ allComputedRels =
), ),
computed_rels as ( computed_rels as (
select select
p.pronamespace::regnamespace::text as schema, (parse_ident(p.pronamespace::regnamespace::text))[1] as schema,
p.proname::text as name, p.proname::text as name,
arg_schema.nspname::text as rel_table_schema, arg_schema.nspname::text as rel_table_schema,
arg_name.typname::text as rel_table_name, arg_name.typname::text as rel_table_name,
ret_schema.nspname::text as rel_ftable_schema, ret_schema.nspname::text as rel_ftable_schema,
ret_name.typname::text as rel_ftable_name, ret_name.typname::text as rel_ftable_name,
p.prorows = 1 as single_row not p.proretset or p.prorows = 1 as single_row
from pg_proc p from pg_proc p
join pg_type arg_name on arg_name.oid = p.proargtypes[0] join pg_type arg_name on arg_name.oid = p.proargtypes[0]
join pg_namespace arg_schema on arg_schema.oid = arg_name.typnamespace join pg_namespace arg_schema on arg_schema.oid = arg_name.typnamespace
@@ -756,18 +802,24 @@ allViewsKeyDependencies =
select select
contype::text as contype, contype::text as contype,
conname, conname,
array_length(conkey, 1) as ncol,
conrelid as resorigtbl, conrelid as resorigtbl,
unnest(conkey) as resorigcol col as resorigcol,
ord
from pg_constraint from pg_constraint
left join lateral unnest(conkey) with ordinality as _(col, ord) on true
where contype IN ('p', 'f') where contype IN ('p', 'f')
union union
-- fk referenced col -- fk referenced col
select select
concat(contype, '_ref') as contype, concat(contype, '_ref') as contype,
conname, conname,
array_length(confkey, 1) as ncol,
confrelid, confrelid,
unnest(confkey) col,
ord
from pg_constraint from pg_constraint
left join lateral unnest(confkey) with ordinality as _(col, ord) on true
where contype='f' where contype='f'
), ),
views as ( views as (
@@ -875,8 +927,13 @@ allViewsKeyDependencies =
(entry->>'resorigcol')::int as resorigcol (entry->>'resorigcol')::int as resorigcol
from target_entries from target_entries
), ),
recursion as( -- CYCLE detection according to PG docs: https://www.postgresql.org/docs/current/queries-with.html#QUERIES-WITH-CYCLE
select r.* -- Can be replaced with CYCLE clause once PG v13 is EOL.
recursion(view_id, view_schema, view_name, view_column, resorigtbl, resorigcol, is_cycle, path) as(
select
r.*,
false,
ARRAY[resorigtbl]
from results r from results r
where view_schema = ANY ($1) where view_schema = ANY ($1)
union all union all
@@ -886,25 +943,46 @@ allViewsKeyDependencies =
view.view_name, view.view_name,
view.view_column, view.view_column,
tab.resorigtbl, tab.resorigtbl,
tab.resorigcol tab.resorigcol,
tab.resorigtbl = ANY(path),
path || tab.resorigtbl
from recursion view from recursion view
join results tab on view.resorigtbl=tab.view_id and view.resorigcol=tab.view_column join results tab on view.resorigtbl=tab.view_id and view.resorigcol=tab.view_column
where not is_cycle
),
repeated_references as(
select
view_id,
view_schema,
view_name,
resorigtbl,
resorigcol,
array_agg(attname) as view_columns
from recursion
join pg_attribute vcol on vcol.attrelid = view_id and vcol.attnum = view_column
group by
view_id,
view_schema,
view_name,
resorigtbl,
resorigcol
) )
select select
sch.nspname as table_schema, sch.nspname as table_schema,
tbl.relname as table_name, tbl.relname as table_name,
rec.view_schema, rep.view_schema,
rec.view_name, rep.view_name,
pks_fks.conname as constraint_name, pks_fks.conname as constraint_name,
pks_fks.contype as constraint_type, pks_fks.contype as constraint_type,
array_agg(row(col.attname, vcol.attname) order by col.attnum) as column_dependencies array_agg(row(col.attname, view_columns) order by pks_fks.ord) as column_dependencies
from recursion rec from repeated_references rep
join pg_class tbl on tbl.oid = rec.resorigtbl
join pg_attribute col on col.attrelid = tbl.oid and col.attnum = rec.resorigcol
join pg_attribute vcol on vcol.attrelid = rec.view_id and vcol.attnum = rec.view_column
join pg_namespace sch on sch.oid = tbl.relnamespace
join pks_fks using (resorigtbl, resorigcol) join pks_fks using (resorigtbl, resorigcol)
group by sch.nspname, tbl.relname, rec.view_schema, rec.view_name, pks_fks.conname, pks_fks.contype join pg_class tbl on tbl.oid = rep.resorigtbl
join pg_attribute col on col.attrelid = tbl.oid and col.attnum = rep.resorigcol
join pg_namespace sch on sch.oid = tbl.relnamespace
group by sch.nspname, tbl.relname, rep.view_schema, rep.view_name, pks_fks.conname, pks_fks.contype, pks_fks.ncol
-- make sure we only return key for which all columns are referenced in the view - no partial PKs or FKs
having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1)
|] |]
param :: HE.Value a -> HE.Params a param :: HE.Value a -> HE.Params a
@@ -1,16 +1,18 @@
{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
module PostgREST.DbStructure.Identifiers module PostgREST.SchemaCache.Identifiers
( QualifiedIdentifier(..) ( QualifiedIdentifier(..)
, Schema , Schema
, TableName , TableName
, FieldName , FieldName
, AccessSet
, dumpQi , dumpQi
, toQi , toQi
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.Set as S
import qualified Data.Text as T import qualified Data.Text as T
import Protolude import Protolude
@@ -40,3 +42,5 @@ toQi txt = case T.drop 1 <$> T.breakOn "." txt of
type Schema = Text type Schema = Text
type TableName = Text type TableName = Text
type FieldName = Text type FieldName = Text
type AccessSet = S.Set QualifiedIdentifier
@@ -1,7 +1,7 @@
{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
module PostgREST.DbStructure.Proc module PostgREST.SchemaCache.Proc
( PgType(..) ( PgType(..)
, ProcDescription(..) , ProcDescription(..)
, ProcParam(..) , ProcParam(..)
@@ -17,7 +17,7 @@ module PostgREST.DbStructure.Proc
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 PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..), import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
Schema, TableName) Schema, TableName)
import Protolude import Protolude
@@ -1,7 +1,7 @@
{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
module PostgREST.DbStructure.Relationship module PostgREST.SchemaCache.Relationship
( Cardinality(..) ( Cardinality(..)
, Relationship(..) , Relationship(..)
, Junction(..) , Junction(..)
@@ -11,7 +11,7 @@ module PostgREST.DbStructure.Relationship
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 PostgREST.DbStructure.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier, Schema) QualifiedIdentifier, Schema)
import Protolude import Protolude
@@ -55,8 +55,8 @@ data Junction = Junction
{ junTable :: QualifiedIdentifier { junTable :: QualifiedIdentifier
, junConstraint1 :: FKConstraint , junConstraint1 :: FKConstraint
, junConstraint2 :: FKConstraint , junConstraint2 :: FKConstraint
, junColumns1 :: [(FieldName, FieldName)] , junColsSource :: [(FieldName, FieldName)]
, junColumns2 :: [(FieldName, FieldName)] , junColsTarget :: [(FieldName, FieldName)]
} }
deriving (Eq, Ord, Generic, JSON.ToJSON) deriving (Eq, Ord, Generic, JSON.ToJSON)
@@ -1,7 +1,7 @@
{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
module PostgREST.DbStructure.Table module PostgREST.SchemaCache.Table
( Column(..) ( Column(..)
, Table(..) , Table(..)
, TablesMap , TablesMap
@@ -10,7 +10,7 @@ module PostgREST.DbStructure.Table
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 PostgREST.DbStructure.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
Schema, TableName) Schema, TableName)
+1 -1
View File
@@ -47,7 +47,7 @@ installSignalHandlers appState = do
install Signals.sigINT interrupt install Signals.sigINT interrupt
install Signals.sigTERM interrupt install Signals.sigTERM interrupt
-- The SIGUSR1 signal updates the internal 'DbStructure' by running -- The SIGUSR1 signal updates the internal 'SchemaCache' by running
-- 'connectionWorker' exactly as before. -- 'connectionWorker' exactly as before.
install Signals.sigUSR1 $ Workers.connectionWorker appState install Signals.sigUSR1 $ Workers.connectionWorker appState
+105 -26
View File
@@ -1,30 +1,40 @@
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.Workers module PostgREST.Workers
( connectionWorker ( connectionWorker
, reReadConfig , reReadConfig
, listener , runListener
, runAdmin
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import qualified Data.Text as T
import qualified Data.Text.Encoding as T import qualified Data.Text.Encoding as T
import qualified Hasql.Notifications as SQL import qualified Hasql.Notifications 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.Wai as Wai
import qualified Network.Wai.Handler.Warp as Warp
import Control.Retry (RetryStatus, capDelay, exponentialBackoff, import Control.Retry (RetryStatus, capDelay, exponentialBackoff,
retrying, rsPreviousDelay) retrying, rsPreviousDelay)
import Hasql.Connection (acquire) import Hasql.Connection (acquire)
import Network.Socket
import Network.Socket.ByteString
import PostgREST.AppState (AppState) import PostgREST.AppState (AppState)
import PostgREST.Config (AppConfig (..), readAppConfig) import PostgREST.Config (AppConfig (..), readAppConfig)
import PostgREST.Config.Database (queryDbSettings, queryPgVersion) import PostgREST.Config.Database (queryDbSettings, queryPgVersion)
import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion) import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion)
import PostgREST.DbStructure (queryDbStructure)
import PostgREST.Error (PgError (PgError), checkIsFatal, import PostgREST.Error (PgError (PgError), checkIsFatal,
errorPayload) errorPayload)
import PostgREST.SchemaCache (querySchemaCache)
import qualified PostgREST.AppState as AppState import qualified PostgREST.AppState as AppState
@@ -45,7 +55,7 @@ data SCacheStatus
| SCFatalFail | SCFatalFail
-- | The purpose of this worker is to obtain a healthy connection to pg and an -- | The purpose of this worker is to obtain a healthy connection to pg and an
-- up-to-date schema cache(DbStructure). This method is meant to be called -- up-to-date schema cache(SchemaCache). This method is meant to be called
-- multiple times by the same thread, but does nothing if the previous -- multiple times by the same thread, but does nothing if the previous
-- invocation has not terminated. In all cases this method does not halt the -- invocation has not terminated. In all cases this method does not halt the
-- calling thread, the work is performed in a separate thread. -- calling thread, the work is performed in a separate thread.
@@ -54,7 +64,7 @@ data SCacheStatus
-- 1. Tries to connect to pg server and will keep trying until success. -- 1. Tries to connect to pg server and will keep trying until success.
-- 2. Checks if the pg version is supported and if it's not it kills the main -- 2. Checks if the pg version is supported and if it's not it kills the main
-- program. -- program.
-- 3. Obtains the dbStructure. If this fails, it goes back to 1. -- 3. Obtains the sCache. If this fails, it goes back to 1.
connectionWorker :: AppState -> IO () connectionWorker :: AppState -> IO ()
connectionWorker appState = do connectionWorker appState = do
runExclusively (AppState.getWorkerSem appState) work runExclusively (AppState.getWorkerSem appState) work
@@ -68,13 +78,13 @@ connectionWorker appState = do
work = do work = do
AppConfig{..} <- AppState.getConfig appState AppConfig{..} <- AppState.getConfig appState
AppState.logWithZTime appState "Attempting to connect to the database..." AppState.logWithZTime appState "Attempting to connect to the database..."
connected <- connectionStatus appState connected <- establishConnection appState
case connected of case connected of
FatalConnectionError reason -> FatalConnectionError reason ->
-- Fatal error when connecting -- Fatal error when connecting
AppState.logWithZTime appState reason >> killThread (AppState.getMainThreadId appState) AppState.logWithZTime appState reason >> killThread (AppState.getMainThreadId appState)
NotConnected -> NotConnected ->
-- Unreachable because connectionStatus will keep trying to connect -- Unreachable because establishConnection will keep trying to connect
return () return ()
Connected actualPgVersion -> do Connected actualPgVersion -> do
-- Procede with initialization -- Procede with initialization
@@ -97,19 +107,20 @@ connectionWorker appState = do
-- die if our schema cache query has an error -- die if our schema cache query has an error
killThread $ AppState.getMainThreadId appState killThread $ AppState.getMainThreadId appState
-- | Check if a connection from the pool allows access to the PostgreSQL -- | Repeatedly flush the pool, and check if a connection from the
-- database. If not, the pool connections are released and a new connection is -- pool allows access to the PostgreSQL database.
-- tried. Releasing the pool is key for rapid recovery. Otherwise, the pool --
-- Releasing the pool is key for rapid recovery. Otherwise, the pool
-- timeout would have to be reached for new healthy connections to be acquired. -- timeout would have to be reached for new healthy connections to be acquired.
-- Which might not happen if the server is busy with requests. No idle -- Which might not happen if the server is busy with requests. No idle
-- connection, no pool timeout. -- connection, no pool timeout.
-- --
-- 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.
connectionStatus :: AppState -> IO ConnectionStatus establishConnection :: AppState -> IO ConnectionStatus
connectionStatus appState = establishConnection appState =
retrying retrySettings shouldRetry $ retrying retrySettings shouldRetry $
const $ AppState.releasePool appState >> getConnectionStatus const $ AppState.flushPool appState >> getConnectionStatus
where where
retrySettings = capDelay delayMicroseconds $ exponentialBackoff backoffMicroseconds retrySettings = capDelay delayMicroseconds $ exponentialBackoff backoffMicroseconds
delayMicroseconds = 32000000 -- 32 seconds delayMicroseconds = 32000000 -- 32 seconds
@@ -147,14 +158,14 @@ connectionStatus appState =
when itShould $ AppState.putRetryNextIn appState delay when itShould $ AppState.putRetryNextIn appState delay
return itShould return itShould
-- | Load the DbStructure by using a connection from the pool. -- | Load the SchemaCache by using a connection from the pool.
loadSchemaCache :: AppState -> IO SCacheStatus loadSchemaCache :: AppState -> IO SCacheStatus
loadSchemaCache appState = do loadSchemaCache appState = do
AppConfig{..} <- AppState.getConfig appState 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 . transaction SQL.ReadCommitted SQL.Read $ AppState.usePool appState . transaction SQL.ReadCommitted SQL.Read $
queryDbStructure (toList configDbSchemas) configDbExtraSearchPath configDbPreparedStatements querySchemaCache (toList configDbSchemas) configDbExtraSearchPath configDbPreparedStatements
case result of case result of
Left e -> do Left e -> do
let let
@@ -167,18 +178,22 @@ loadSchemaCache appState = do
AppState.logWithZTime appState hint AppState.logWithZTime appState hint
return SCFatalFail return SCFatalFail
Nothing -> do Nothing -> do
AppState.putDbStructure appState Nothing AppState.putSchemaCache appState Nothing
AppState.logWithZTime appState "An error ocurred when loading the schema cache" AppState.logWithZTime appState "An error ocurred when loading the schema cache"
putErr putErr
return SCOnRetry return SCOnRetry
Right dbStructure -> do Right sCache -> do
AppState.putDbStructure appState (Just dbStructure) AppState.putSchemaCache appState (Just sCache)
when (isJust configDbRootSpec) . when (isJust configDbRootSpec) .
AppState.putJsonDbS appState . LBS.toStrict $ JSON.encode dbStructure AppState.putJsonDbS appState . LBS.toStrict $ JSON.encode sCache
AppState.logWithZTime appState "Schema cache loaded" AppState.logWithZTime appState "Schema cache loaded"
return SCLoaded return SCLoaded
runListener :: AppConfig -> AppState -> IO ()
runListener AppConfig{configDbChannelEnabled} appState =
when configDbChannelEnabled $ listener appState
-- | Starts a dedicated pg connection to LISTEN for notifications. When a -- | Starts a dedicated pg connection to LISTEN for notifications. When a
-- NOTIFY <db-channel> - with an empty payload - is done, it refills the schema -- NOTIFY <db-channel> - with an empty payload - is done, it refills the schema
-- cache. It uses the connectionWorker in case the LISTEN connection dies. -- cache. It uses the connectionWorker in case the LISTEN connection dies.
@@ -215,16 +230,15 @@ listener appState = do
listener appState listener appState
handleNotification _ msg handleNotification _ msg
| BS.null msg = scLoader -- reload the schema cache | BS.null msg = cacheReloader
| msg == "reload schema" = scLoader -- reload the schema cache | msg == "reload schema" = cacheReloader
| msg == "reload config" = reReadConfig False appState -- reload the config | msg == "reload config" = reReadConfig False appState
| otherwise = pure () -- Do nothing if anything else than an empty message is sent | otherwise = pure () -- Do nothing if anything else than an empty message is sent
scLoader = cacheReloader =
-- It's not necessary to check the loadSchemaCache success -- reloads the schema cache + restarts pool connections
-- here. If the connection drops, the thread will die and -- it's necessary to restart the pg connections because they cache the pg catalog(see #2620)
-- proceed to recover. connectionWorker appState
void $ loadSchemaCache 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 ()
@@ -263,3 +277,68 @@ reReadConfig startingUp appState = do
pass pass
else else
AppState.logWithZTime appState "Config reloaded" AppState.logWithZTime appState "Config reloaded"
runAdmin :: AppConfig -> AppState -> Warp.Settings -> IO ()
runAdmin conf@AppConfig{configAdminServerPort} appState settings =
whenJust configAdminServerPort $ \adminPort -> do
AppState.logWithZTime appState $ "Admin server listening on port " <> show adminPort
void . forkIO $ Warp.runSettings (settings & Warp.setPort adminPort) adminApp
where
whenJust :: Applicative m => Maybe a -> (a -> m ()) -> m ()
whenJust mg f = maybe (pure ()) f mg
adminApp = admin appState conf
-- | PostgREST admin application
admin :: AppState.AppState -> AppConfig -> Wai.Application
admin appState appConfig req respond = do
isMainAppReachable <- any isRight <$> reachMainApp appConfig
isSchemaCacheLoaded <- isJust <$> AppState.getSchemaCache appState
isConnectionUp <-
if configDbChannelEnabled appConfig
then AppState.getIsListenerOn appState
else isRight <$> AppState.usePool appState (SQL.sql "SELECT 1")
case Wai.pathInfo req of
["ready"] ->
respond $ Wai.responseLBS (if isMainAppReachable && isConnectionUp && isSchemaCacheLoaded then HTTP.status200 else HTTP.status503) [] mempty
["live"] ->
respond $ Wai.responseLBS (if isMainAppReachable then HTTP.status200 else HTTP.status503) [] mempty
_ ->
respond $ Wai.responseLBS HTTP.status404 [] mempty
-- 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
-- The code for resolving the "*4", "!4", "*6", "!6", "*" special values is taken from
-- https://hackage.haskell.org/package/streaming-commons-0.2.2.4/docs/src/Data.Streaming.Network.html#bindPortGenEx
reachMainApp :: AppConfig -> IO [Either IOException ()]
reachMainApp AppConfig{..} =
case configServerUnixSocket of
Just path -> do
sock <- socket AF_UNIX Stream 0
(:[]) <$> 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
sendEmpty sock = void $ send sock mempty
tryAddr :: AddrInfo -> IO (Either IOException ())
tryAddr addr = do
sock <- socket (addrFamily addr) (addrSocketType addr) (addrProtocol addr)
try $ do
connect sock $ addrAddress addr
withSocketsDo $ bracket (pure sock) close sendEmpty
+20 -9
View File
@@ -10,12 +10,23 @@ nix:
pure: false pure: false
extra-deps: extra-deps:
- HTTP-4000.3.16@sha256:6042643c15a0b43e522a6693f1e322f05000d519543a84149cb80aeffee34f71,5947 - HTTP-4000.3.16
- configurator-pg-0.2.6@sha256:cd9b06a458428e493a4d6def725af7ab1ab0fef678fbd871f9586fc7f9aa70be,2849 - configurator-pg-0.2.6
- hasql-dynamic-statements-0.3.1.1@sha256:2cfe6e75990e690f595a87cbe553f2e90fcd738610f6c66749c81cc4396b2cc4,2675 - hashable-1.4.1.0
- hasql-implicits-0.1.0.4@sha256:0848d3cbc9d94e1e539948fa0be4d0326b26335034161bf8076785293444ca6f,1361 - hashtables-1.3
- hasql-pool-0.5.2.2@sha256:b56d4dea112d97a2ef4b2749508c0ca646828cb2d77b827e8dc433d249bb2062,2438 - hasql-1.6.1.1
- lens-aeson-1.1.3@sha256:52c8eaecd2d1c2a969c0762277c4a8ee72c339a686727d5785932e72ef9c3050,1764 - hasql-dynamic-statements-0.3.1.2
- optparse-applicative-0.16.1.0@sha256:418c22ed6a19124d457d96bc66bd22c93ac22fad0c7100fe4972bbb4ac989731,4982 - hasql-implicits-0.1.0.5
- protolude-0.3.2@sha256:2a38b3dad40d238ab644e234b692c8911423f9d3ed0e36b62287c4a698d92cd1,2240 - hasql-notifications-0.2.0.3
- ptr-0.16.8.2@sha256:708ebb95117f2872d2c5a554eb6804cf1126e86abe793b2673f913f14e5eb1ac,3959 - hasql-pool-0.8.0.6
- hasql-transaction-1.0.1.2
- isomorphism-class-0.1.0.6
- lens-aeson-1.1.3
- optparse-applicative-0.16.1.0
- postgresql-binary-0.12.5
- protolude-0.3.2
- ptr-0.16.8.2
- text-builder-0.6.7
- text-builder-dev-0.3.3
- git: https://github.com/PostgREST/postgresql-libpq.git
commit: 33ff97db570b5b432255f5f24a68db51453f6eb8
+90 -16
View File
@@ -10,63 +10,137 @@ packages:
size: 1428 size: 1428
sha256: b73a7f6d21cf20bbf819e19039409c9010efb5000d2b72cdd8fd67a9027c14e8 sha256: b73a7f6d21cf20bbf819e19039409c9010efb5000d2b72cdd8fd67a9027c14e8
original: original:
hackage: HTTP-4000.3.16@sha256:6042643c15a0b43e522a6693f1e322f05000d519543a84149cb80aeffee34f71,5947 hackage: HTTP-4000.3.16
- completed: - completed:
hackage: configurator-pg-0.2.6@sha256:cd9b06a458428e493a4d6def725af7ab1ab0fef678fbd871f9586fc7f9aa70be,2849 hackage: configurator-pg-0.2.6@sha256:cd9b06a458428e493a4d6def725af7ab1ab0fef678fbd871f9586fc7f9aa70be,2849
pantry-tree: pantry-tree:
size: 2463 size: 2463
sha256: 97efe7a22afc93033bda5adcffdabc0f1c30dc32b2c3ba02114ce7cd74c942fd sha256: 97efe7a22afc93033bda5adcffdabc0f1c30dc32b2c3ba02114ce7cd74c942fd
original: original:
hackage: configurator-pg-0.2.6@sha256:cd9b06a458428e493a4d6def725af7ab1ab0fef678fbd871f9586fc7f9aa70be,2849 hackage: configurator-pg-0.2.6
- completed: - completed:
hackage: hasql-dynamic-statements-0.3.1.1@sha256:2cfe6e75990e690f595a87cbe553f2e90fcd738610f6c66749c81cc4396b2cc4,2675 hackage: hashable-1.4.1.0@sha256:50b2f002c68fe67730ee7a3cd8607486197dd99b084255005ad51ecd6970a41b,5019
pantry-tree:
size: 1248
sha256: 9af2f7a42674f7effcabbebc043f97057240783f1709338a77f58216f4a5f18c
original:
hackage: hashable-1.4.1.0
- completed:
hackage: hashtables-1.3@sha256:ab21804fdafbbd8ad918b2911dabb729ae0ea891780fe66bf7804cbcd07edadf,10379
pantry-tree:
size: 2895
sha256: e71f113ad989dbc994e0fb52bcc219d62930de9afa8b3441bf7909e864481b33
original:
hackage: hashtables-1.3
- completed:
hackage: hasql-1.6.1.1@sha256:948a2137308cc5354e4997bc3666753867124cd25db792424cb9614b1c1b44cf,6626
pantry-tree:
size: 2622
sha256: 28d21bf061522fc513f040e9c383b90532222b7258216cc094e07736add8be10
original:
hackage: hasql-1.6.1.1
- completed:
hackage: hasql-dynamic-statements-0.3.1.2@sha256:417aa533c84f074e2fa16bb2c4d4231326aa512097dd1025d915388e56acd1eb,2675
pantry-tree: pantry-tree:
size: 595 size: 595
sha256: b84ae10a5c776f88f546df73bc957a35e61056400b7e805dad0b254612907e97 sha256: 91696d3f3e0ef3254772ae5a8e4e89be68285febb49b302ed83d85ac4037a417
original: original:
hackage: hasql-dynamic-statements-0.3.1.1@sha256:2cfe6e75990e690f595a87cbe553f2e90fcd738610f6c66749c81cc4396b2cc4,2675 hackage: hasql-dynamic-statements-0.3.1.2
- completed: - completed:
hackage: hasql-implicits-0.1.0.4@sha256:0848d3cbc9d94e1e539948fa0be4d0326b26335034161bf8076785293444ca6f,1361 hackage: hasql-implicits-0.1.0.5@sha256:d16aacad6dc21428d72447d3ae8bcc03839a2f0aa1ec29c797ed9aca4609f9af,1361
pantry-tree: pantry-tree:
size: 264 size: 264
sha256: d49af8f8749ab7039fa668af4b78f997f7fa2928b4aded6798f573a3d08e76a0 sha256: 0451b99a0a1d02db673d0c40acdf60d4e769e15852eed9e8dc05bffaf43efb70
original: original:
hackage: hasql-implicits-0.1.0.4@sha256:0848d3cbc9d94e1e539948fa0be4d0326b26335034161bf8076785293444ca6f,1361 hackage: hasql-implicits-0.1.0.5
- completed: - completed:
hackage: hasql-pool-0.5.2.2@sha256:b56d4dea112d97a2ef4b2749508c0ca646828cb2d77b827e8dc433d249bb2062,2438 hackage: hasql-notifications-0.2.0.3@sha256:aca3f7ee847a8f0b7ef6f989dc48f4a094a06c1a34e92aa3c8bb230085966ea6,2027
pantry-tree: pantry-tree:
size: 412 size: 452
sha256: 2741a33f947d28b4076c798c20c1f646beecd21f5eaf522c8256cbeb34d4d6d0 sha256: 999f0f2856a00d21f4498a8a58452bbefc4ea972fe2984fd234a68a5fe61d98b
original: original:
hackage: hasql-pool-0.5.2.2@sha256:b56d4dea112d97a2ef4b2749508c0ca646828cb2d77b827e8dc433d249bb2062,2438 hackage: hasql-notifications-0.2.0.3
- completed:
hackage: hasql-pool-0.8.0.6@sha256:b63bb83409bab5bc20ff24f5d62205e9b117701a0fc24531ddeac20ab8c2a42c,1818
pantry-tree:
size: 346
sha256: c4100946b7eae44375511e35a393abe2e1db0e5637c68cea8f53176b796bfd5b
original:
hackage: hasql-pool-0.8.0.6
- completed:
hackage: hasql-transaction-1.0.1.2@sha256:297b158cd1f0727f9b0e175bd7d3741c1bcb725a8094956d0ee79b41aafdb30a,2890
pantry-tree:
size: 983
sha256: 3679e6d5c835cc17a8fa0c252b8221e282880044b7219aa1de2531bbd5c40691
original:
hackage: hasql-transaction-1.0.1.2
- completed:
hackage: isomorphism-class-0.1.0.6@sha256:d93da31287359c761953b876354de28381f409c5c50e3241c572a443e50c553d,1703
pantry-tree:
size: 465
sha256: c97f922d1ae8f1a0db4c28fac9383d2716934879e95ff0b2b88ebb861d6fba14
original:
hackage: isomorphism-class-0.1.0.6
- completed: - completed:
hackage: lens-aeson-1.1.3@sha256:52c8eaecd2d1c2a969c0762277c4a8ee72c339a686727d5785932e72ef9c3050,1764 hackage: lens-aeson-1.1.3@sha256:52c8eaecd2d1c2a969c0762277c4a8ee72c339a686727d5785932e72ef9c3050,1764
pantry-tree: pantry-tree:
size: 541 size: 541
sha256: b31392b78f2a03111c805f4400007778eb93b49f998ab41dfbebaaf9b5526bad sha256: b31392b78f2a03111c805f4400007778eb93b49f998ab41dfbebaaf9b5526bad
original: original:
hackage: lens-aeson-1.1.3@sha256:52c8eaecd2d1c2a969c0762277c4a8ee72c339a686727d5785932e72ef9c3050,1764 hackage: lens-aeson-1.1.3
- completed: - completed:
hackage: optparse-applicative-0.16.1.0@sha256:418c22ed6a19124d457d96bc66bd22c93ac22fad0c7100fe4972bbb4ac989731,4982 hackage: optparse-applicative-0.16.1.0@sha256:418c22ed6a19124d457d96bc66bd22c93ac22fad0c7100fe4972bbb4ac989731,4982
pantry-tree: pantry-tree:
size: 2979 size: 2979
sha256: dd092d843091c08691485d68a1908517079b1bc6f3d73928f37635a19dc27fc1 sha256: dd092d843091c08691485d68a1908517079b1bc6f3d73928f37635a19dc27fc1
original: original:
hackage: optparse-applicative-0.16.1.0@sha256:418c22ed6a19124d457d96bc66bd22c93ac22fad0c7100fe4972bbb4ac989731,4982 hackage: optparse-applicative-0.16.1.0
- completed:
hackage: postgresql-binary-0.12.5@sha256:de9da3cba9be541d6c75ae8da2858c33d83dc1b2e0c639b0b9781816b78a91f4,5594
pantry-tree:
size: 1619
sha256: b392337f91031a5b3407393e2f04dfe4e7a28019e88eae6a9370538b90e28c51
original:
hackage: postgresql-binary-0.12.5
- completed: - completed:
hackage: protolude-0.3.2@sha256:2a38b3dad40d238ab644e234b692c8911423f9d3ed0e36b62287c4a698d92cd1,2240 hackage: protolude-0.3.2@sha256:2a38b3dad40d238ab644e234b692c8911423f9d3ed0e36b62287c4a698d92cd1,2240
pantry-tree: pantry-tree:
size: 1594 size: 1594
sha256: a36d2912ac552d950ba4476de7d950b56b82dd28e48b9f4d0efee938f10bc525 sha256: a36d2912ac552d950ba4476de7d950b56b82dd28e48b9f4d0efee938f10bc525
original: original:
hackage: protolude-0.3.2@sha256:2a38b3dad40d238ab644e234b692c8911423f9d3ed0e36b62287c4a698d92cd1,2240 hackage: protolude-0.3.2
- completed: - completed:
hackage: ptr-0.16.8.2@sha256:708ebb95117f2872d2c5a554eb6804cf1126e86abe793b2673f913f14e5eb1ac,3959 hackage: ptr-0.16.8.2@sha256:708ebb95117f2872d2c5a554eb6804cf1126e86abe793b2673f913f14e5eb1ac,3959
pantry-tree: pantry-tree:
size: 1303 size: 1303
sha256: 557c438345de19f82bf01d676100da2a191ef06f624e7a4b90b09ac17cbb52a5 sha256: 557c438345de19f82bf01d676100da2a191ef06f624e7a4b90b09ac17cbb52a5
original: original:
hackage: ptr-0.16.8.2@sha256:708ebb95117f2872d2c5a554eb6804cf1126e86abe793b2673f913f14e5eb1ac,3959 hackage: ptr-0.16.8.2
- completed:
hackage: text-builder-0.6.7@sha256:efbb3e06107e9c8d1cfe85c963938ca9f375a74379af03da3173be4ef5c37bcf,2364
pantry-tree:
size: 425
sha256: cd0ae197e6f9f3860a8ab71f5b87c4a8452ed1fce2fdfd35e36d68ded6e6648e
original:
hackage: text-builder-0.6.7
- completed:
hackage: text-builder-dev-0.3.3@sha256:79ec422defcc2e5b34f94129c72b98d34b2efc1ed8bbd945ccb8f4f535a892c3,2784
pantry-tree:
size: 724
sha256: 8883631a132438e7892fcb13e89d6bbcdc0ac76c56fbea8df8d7aa482ce81f73
original:
hackage: text-builder-dev-0.3.3
- completed:
name: postgresql-libpq
version: 0.9.4.3
git: https://github.com/PostgREST/postgresql-libpq.git
pantry-tree:
size: 1081
sha256: 0df271e48af32eb8292a45301af45e114110d54099ee73dbc609d39770e8175e
commit: 33ff97db570b5b432255f5f24a68db51453f6eb8
original:
git: https://github.com/PostgREST/postgresql-libpq.git
commit: 33ff97db570b5b432255f5f24a68db51453f6eb8
snapshots: snapshots:
- completed: - completed:
size: 618951 size: 618951
+3 -2
View File
@@ -13,6 +13,7 @@ main =
, "-XStandaloneDeriving" , "-XStandaloneDeriving"
, "-isrc" , "-isrc"
, "src/PostgREST/Query/SqlFragment.hs" , "src/PostgREST/Query/SqlFragment.hs"
, "src/PostgREST/Request/Preferences.hs" , "src/PostgREST/ApiRequest/Preferences.hs"
, "src/PostgREST/Request/QueryParams.hs" , "src/PostgREST/ApiRequest/QueryParams.hs"
, "src/PostgREST/Error.hs"
] ]
+55
View File
@@ -0,0 +1,55 @@
import os
import pathlib
import shutil
import signal
import pytest
import yaml
BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent
CONFIGSDIR = BASEDIR / "configs"
FIXTURES = yaml.load((BASEDIR / "fixtures.yaml").read_text(), Loader=yaml.Loader)
POSTGREST_BIN = shutil.which("postgrest")
SECRET = "reallyreallyreallyreallyverysafe"
@pytest.fixture
def dburi():
"Postgres database connection URI."
dbname = os.environ["PGDATABASE"]
host = os.environ["PGHOST"]
user = os.environ["PGUSER"]
return f"postgresql://?dbname={dbname}&host={host}&user={user}".encode()
@pytest.fixture
def baseenv():
"Base environment to connect to PostgreSQL"
return {
"PGDATABASE": os.environ["PGDATABASE"],
"PGHOST": os.environ["PGHOST"],
"PGUSER": os.environ["PGUSER"],
}
@pytest.fixture
def defaultenv(baseenv):
"Default environment for PostgREST."
return {
**baseenv,
"PGRST_DB_CONFIG": "true",
"PGRST_LOG_LEVEL": "info",
"PGRST_DB_POOL": "1",
"PGRST_NOT_EXISTING": "should not break any tests",
}
def hpctixfile():
"Returns an individual filename for each test, if the HPCTIXFILE environment variable is set."
if "HPCTIXFILE" not in os.environ:
return ""
tixfile = pathlib.Path(os.environ["HPCTIXFILE"])
test = hash(os.environ["PYTEST_CURRENT_TEST"])
return tixfile.with_suffix(f".{test}.tix")
+2
View File
@@ -1,2 +1,4 @@
# Not the default, but only works with PG* variables, which are not set # Not the default, but only works with PG* variables, which are not set
db-config = false db-config = false
# not existing config options should not break tests
not-existing = "should succeed"
+1 -1
View File
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
db-max-rows = 1000 db-max-rows = 1000
db-plan-enabled = false db-plan-enabled = false
db-pool = 10 db-pool = 10
db-pool-timeout = 3600 db-pool-acquisition-timeout = ""
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"
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
db-max-rows = "" db-max-rows = ""
db-plan-enabled = false db-plan-enabled = false
db-pool = 10 db-pool = 10
db-pool-timeout = 3600 db-pool-acquisition-timeout = ""
db-pre-request = "" db-pre-request = ""
db-prepared-statements = false db-prepared-statements = false
db-root-spec = "" db-root-spec = ""
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
db-max-rows = "" db-max-rows = ""
db-plan-enabled = false db-plan-enabled = false
db-pool = 10 db-pool = 10
db-pool-timeout = 3600 db-pool-acquisition-timeout = ""
db-pre-request = "" db-pre-request = ""
db-prepared-statements = false db-prepared-statements = false
db-root-spec = "" db-root-spec = ""
+1 -1
View File
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
db-max-rows = "" db-max-rows = ""
db-plan-enabled = false db-plan-enabled = false
db-pool = 10 db-pool = 10
db-pool-timeout = 3600 db-pool-acquisition-timeout = ""
db-pre-request = "" db-pre-request = ""
db-prepared-statements = true db-prepared-statements = true
db-root-spec = "" db-root-spec = ""
@@ -5,7 +5,7 @@ db-extra-search-path = "public,extensions,other"
db-max-rows = 100 db-max-rows = 100
db-plan-enabled = true db-plan-enabled = true
db-pool = 1 db-pool = 1
db-pool-timeout = 100 db-pool-acquisition-timeout = 10
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"
@@ -5,7 +5,7 @@ db-extra-search-path = "public,extensions,private"
db-max-rows = 1000 db-max-rows = 1000
db-plan-enabled = true db-plan-enabled = true
db-pool = 1 db-pool = 1
db-pool-timeout = 100 db-pool-acquisition-timeout = 10
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"
+1 -1
View File
@@ -5,7 +5,7 @@ db-extra-search-path = "public,test"
db-max-rows = 1000 db-max-rows = 1000
db-plan-enabled = true db-plan-enabled = true
db-pool = 1 db-pool = 1
db-pool-timeout = 100 db-pool-acquisition-timeout = 10
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"
+1 -1
View File
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
db-max-rows = "" db-max-rows = ""
db-plan-enabled = false db-plan-enabled = false
db-pool = 10 db-pool = 10
db-pool-timeout = 3600 db-pool-acquisition-timeout = ""
db-pre-request = "" db-pre-request = ""
db-prepared-statements = true db-prepared-statements = true
db-root-spec = "" db-root-spec = ""
+1 -1
View File
@@ -7,7 +7,7 @@ PGRST_DB_EXTRA_SEARCH_PATH: public, test
PGRST_DB_MAX_ROWS: 1000 PGRST_DB_MAX_ROWS: 1000
PGRST_DB_PLAN_ENABLED: true PGRST_DB_PLAN_ENABLED: true
PGRST_DB_POOL: 1 PGRST_DB_POOL: 1
PGRST_DB_POOL_TIMEOUT: 100 PGRST_DB_POOL_ACQUISITION_TIMEOUT: 10
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
+1 -1
View File
@@ -5,7 +5,7 @@ db-extra-search-path = "public, test"
db-max-rows = 1000 db-max-rows = 1000
db-plan-enabled = true db-plan-enabled = true
db-pool = 1 db-pool = 1
db-pool-timeout = 100 db-pool-acquisition-timeout = 10
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"
+2
View File
@@ -16,10 +16,12 @@ ALTER ROLE db_config_authenticator SET pgrst.db_prepared_statements = 'false';
ALTER ROLE db_config_authenticator SET pgrst.db_pre_request = 'test.custom_headers'; ALTER ROLE db_config_authenticator SET pgrst.db_pre_request = 'test.custom_headers';
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';
-- 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';
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.db_extra_search_path = 'public, extensions, private'; ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.db_extra_search_path = 'public, extensions, private';
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.not_existing = 'should be ignored';
-- other database settings that should be ignored -- other database settings that should be ignored
CREATE DATABASE other; CREATE DATABASE other;
+12
View File
@@ -86,3 +86,15 @@ $$ language sql;
create or replace function hello() returns text as $$ create or replace function hello() returns text as $$
select 'hello'; select 'hello';
$$ language sql; $$ language sql;
create table cats(id uuid primary key, name text);
grant all on cats to postgrest_test_anonymous;
create function drop_change_cats() returns void
language sql security definer
as $$
drop table cats;
create table cats(id bigint primary key, name text);
grant all on table cats to postgrest_test_anonymous;
notify pgrst, 'reload schema';
$$;
+180
View File
@@ -0,0 +1,180 @@
"Fixtures to run PostgREST as a server."
import contextlib
import dataclasses
import os
import pathlib
import socket
import subprocess
import tempfile
import time
import urllib.parse
import pytest
import requests
import requests_unixsocket
from config import *
class PostgrestTimedOut(Exception):
"Connecting to PostgREST endpoint timed out."
class PostgrestSession(requests_unixsocket.Session):
"HTTP client session directed at a PostgREST endpoint."
def __init__(self, baseurl, *args, **kwargs):
super(PostgrestSession, self).__init__(*args, **kwargs)
self.baseurl = baseurl
def request(self, method, url, *args, **kwargs):
# Not using urllib.parse.urljoin to compose the url, as it doesn't play
# well with our 'http+unix://' unix domain socket urls.
fullurl = self.baseurl + url
return super(PostgrestSession, self).request(method, fullurl, *args, **kwargs)
@dataclasses.dataclass
class PostgrestProcess:
"Running PostgREST process and its corresponding main and admin endpoints."
admin: object
process: object
session: object
@contextlib.contextmanager
def run(
configpath=None,
stdin=None,
env=None,
port=None,
host=None,
wait_for_readiness=True,
no_pool_connection_available=False,
):
"Run PostgREST and yield an endpoint that is ready for connections."
with tempfile.TemporaryDirectory() as tmpdir:
if port:
env["PGRST_SERVER_PORT"] = str(port)
env["PGRST_SERVER_HOST"] = host or "localhost"
baseurl = f"http://localhost:{port}"
else:
socketfile = pathlib.Path(tmpdir) / "postgrest.sock"
env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile)
baseurl = "http+unix://" + urllib.parse.quote_plus(str(socketfile))
adminport = freeport()
env["PGRST_ADMIN_SERVER_PORT"] = str(adminport)
adminurl = f"http://localhost:{adminport}"
command = [POSTGREST_BIN]
env["HPCTIXFILE"] = hpctixfile()
if configpath:
command.append(configpath)
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
)
os.set_blocking(process.stdout.fileno(), False)
try:
process.stdin.write(stdin or b"")
process.stdin.close()
if wait_for_readiness:
wait_until_ready(adminurl + "/ready")
process.stdout.read()
if no_pool_connection_available:
sleep_pool_connection(baseurl, 10)
yield PostgrestProcess(
process=process,
session=PostgrestSession(baseurl),
admin=PostgrestSession(adminurl),
)
finally:
remaining_output = process.stdout.read()
if remaining_output:
print(remaining_output.decode())
process.terminate()
try:
process.wait(timeout=1)
except:
process.kill()
process.wait()
@pytest.fixture(scope="module")
def metapostgrest():
"A shared postgrest instance to use for interacting with the database independently of the instance under test"
role = "meta_authenticator"
env = {
"PGDATABASE": os.environ["PGDATABASE"],
"PGHOST": os.environ["PGHOST"],
"PGUSER": role,
"PGRST_DB_ANON_ROLE": role,
"PGRST_DB_CONFIG": "true",
"PGRST_LOG_LEVEL": "info",
"PGRST_DB_POOL": "1",
}
with run(env=env) as postgrest:
yield postgrest
def freeport():
"Find a free port on localhost."
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind(("", 0))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return s.getsockname()[1]
def wait_until_exit(postgrest):
"Wait for PostgREST to exit, or times out"
try:
return postgrest.process.wait(timeout=1)
except (subprocess.TimeoutExpired):
raise PostgrestTimedOut()
def wait_until_ready(url):
"Wait for the given HTTP endpoint to return a status of 200."
session = requests_unixsocket.Session()
response = None
for _ in range(10):
try:
response = session.get(url, timeout=1)
if response.status_code == 200:
return
except (requests.ConnectionError, requests.ReadTimeout):
pass
time.sleep(0.1)
if response:
raise PostgrestTimedOut(f"{response.status_code}: {response.text}")
else:
raise PostgrestTimedOut()
def sleep_pool_connection(url, seconds):
"Sleep a pool connection by calling an RPC that uses pg_sleep"
session = requests_unixsocket.Session()
# The try/except is a hack for not waiting for the response,
# taken from https://stackoverflow.com/a/45601591/4692662
try:
session.get(url + f"/rpc/sleep?seconds={seconds}", timeout=0.1)
except requests.exceptions.ReadTimeout:
pass
+227
View File
@@ -0,0 +1,227 @@
"Unit tests for Input/Ouput of PostgREST seen as a black box."
import contextlib
import dataclasses
from datetime import datetime
from itertools import repeat
from operator import attrgetter
import os
import pathlib
import re
import shutil
import signal
import socket
import subprocess
import tempfile
import threading
import time
import urllib.parse
import jwt
import pytest
import requests
import requests_unixsocket
import yaml
from config import *
def itemgetter(*items):
"operator.itemgetter with None as fallback when key does not exist"
if len(items) == 1:
item = items[0]
def g(obj):
return obj.get(item)
else:
def g(obj):
return tuple(obj.get(item) for item in items)
return g
class PostgrestError(Exception):
"Postgrest exited with a non-zero return code."
def cli(args, env=None, stdin=None):
"Run PostgREST and return stdout."
env = env or {}
command = [POSTGREST_BIN] + args
env["HPCTIXFILE"] = hpctixfile()
process = subprocess.Popen(
command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
process.stdin.write(stdin or b"")
try:
result = process.communicate(timeout=5)[0]
if process.returncode != 0:
raise PostgrestError()
return result.decode()
finally:
process.kill()
process.wait()
def dumpconfig(configpath=None, env=None, stdin=None):
"Dump the config as parsed by PostgREST."
args = ["--dump-config"]
if configpath:
args.append(configpath)
return cli(args, env=env, stdin=stdin)
@pytest.mark.parametrize(
"args,env,use_defaultenv,expect",
map(itemgetter("args", "env", "use_defaultenv", "expect"), FIXTURES["cli"]),
ids=map(itemgetter("name"), FIXTURES["cli"]),
)
def test_cli(args, env, use_defaultenv, expect, defaultenv):
"""
When PostgREST is run with <args> arguments and <env>/<defaultenv> environment variabales
it should return. Exit code should be according to <expect_error>.
"""
# use --dump-config by default to make sure that the postgrest process will terminate for sure
args = args or ["--dump-config"]
env = env or {}
if use_defaultenv:
env = {**defaultenv, **env}
if expect == "error":
with pytest.raises(PostgrestError):
print(cli(args, env=env))
else:
dump = cli(args, env=env).split("\n")
if expect:
assert expect in dump
@pytest.mark.parametrize(
"expectedconfig",
[
expectedconfig
for expectedconfig in (CONFIGSDIR / "expected").iterdir()
if (CONFIGSDIR / expectedconfig.name).exists()
],
ids=attrgetter("name"),
)
def test_expected_config(expectedconfig):
"""
Configs as dumped by PostgREST should match an expected output.
Used to test default values, config aliases and environment variables. The
expected output for each file in 'configs', if available, is found in the
'configs/expected' directory.
"""
expected = expectedconfig.read_text()
config = CONFIGSDIR / expectedconfig.name
assert dumpconfig(config) == expected
def test_expected_config_from_environment():
"Config should be read directly from environment without config file."
envfile = (CONFIGSDIR / "no-defaults-env.yaml").read_text()
env = {k: str(v) for k, v in yaml.load(envfile, Loader=yaml.Loader).items()}
expected = (CONFIGSDIR / "expected" / "no-defaults.config").read_text()
assert dumpconfig(env=env) == expected
@pytest.mark.parametrize(
"role, expectedconfig",
[
("db_config_authenticator", "no-defaults-with-db.config"),
("other_authenticator", "no-defaults-with-db-other-authenticator.config"),
],
)
def test_expected_config_from_db_settings(baseenv, role, expectedconfig):
"Config should be overriden from database settings"
config = CONFIGSDIR / "no-defaults.config"
env = {
**baseenv,
"PGUSER": role,
"PGRST_DB_URI": "postgresql://",
"PGRST_DB_CONFIG": "true",
}
expected = (CONFIGSDIR / "expected" / expectedconfig).read_text()
assert dumpconfig(configpath=config, env=env) == expected
@pytest.mark.parametrize(
"config",
[conf for conf in CONFIGSDIR.iterdir() if conf.suffix == ".config"],
ids=attrgetter("name"),
)
def test_stable_config(tmp_path, config, defaultenv):
"""
A dumped, re-read and re-dumped config should match the dumped config.
Note: only dump vs. re-dump must be equal, as the original config file might
be different because of default values, whitespace, and quoting.
"""
# Set environment variables that some of the configs expect. Using a
# complex ROLE_CLAIM_KEY to make sure quoting works.
env = {
**defaultenv,
"ROLE_CLAIM_KEY": '."https://www.example.com/roles"[0].value',
"POSTGREST_TEST_SOCKET": "/tmp/postgrest.sock",
"POSTGREST_TEST_PORT": "80",
"JWT_SECRET_FILE": "a_file",
}
# Some configs expect input from stdin, at least on base64.
stdin = b"Y29ubmVjdGlvbl9zdHJpbmc="
dumped = dumpconfig(config, env=env, stdin=stdin)
tmpconfigpath = tmp_path / "config"
tmpconfigpath.write_text(dumped)
redumped = dumpconfig(tmpconfigpath, env=env)
assert dumped == redumped
@pytest.mark.parametrize("invalidroleclaimkey", FIXTURES["invalidroleclaimkeys"])
def test_invalid_role_claim_key(invalidroleclaimkey, defaultenv):
"Given an invalid role-claim-key, Postgrest should exit with a non-zero exit code."
env = {
**defaultenv,
"PGRST_JWT_ROLE_CLAIM_KEY": invalidroleclaimkey,
}
with pytest.raises(PostgrestError):
dump = dumpconfig(env=env)
for line in dump.split("\n"):
if line.startswith("jwt-role-claim-key"):
print(line)
@pytest.mark.parametrize("invalidopenapimodes", FIXTURES["invalidopenapimodes"])
def test_invalid_openapi_mode(invalidopenapimodes, defaultenv):
"Given an invalid openapi-mode, Postgrest should exit with a non-zero exit code."
env = {
**defaultenv,
"PGRST_OPENAPI_MODE": invalidopenapimodes,
}
with pytest.raises(PostgrestError):
dump = dumpconfig(CONFIGSDIR / "defaults.config", env=env)
for line in dump.split("\n"):
if line.startswith("openapi-mode"):
print(line)
+90 -457
View File
@@ -1,435 +1,18 @@
"Unit tests for Input/Ouput of PostgREST seen as a black box." "Unit tests for Input/Ouput of PostgREST seen as a black box."
import contextlib
import dataclasses
from datetime import datetime from datetime import datetime
from itertools import repeat
from operator import attrgetter from operator import attrgetter
import os import os
import pathlib
import re import re
import shutil
import signal import signal
import socket import socket
import subprocess
import tempfile
import threading
import time import time
import urllib.parse
import jwt
import pytest import pytest
import requests
import requests_unixsocket
import yaml
from config import *
BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent from util import *
CONFIGSDIR = BASEDIR / "configs" from postgrest import *
FIXTURES = yaml.load((BASEDIR / "fixtures.yaml").read_text(), Loader=yaml.Loader)
POSTGREST_BIN = shutil.which("postgrest")
SECRET = "reallyreallyreallyreallyverysafe"
def itemgetter(*items):
"operator.itemgetter with None as fallback when key does not exist"
if len(items) == 1:
item = items[0]
def g(obj):
return obj.get(item)
else:
def g(obj):
return tuple(obj.get(item) for item in items)
return g
class Thread(threading.Thread):
"Variant of threading.Thread that re-raises any exceptions when joining the thread"
def __init__(self, *args, **kwargs):
self._exception = None
super(Thread, self).__init__(*args, **kwargs)
def run(self):
try:
super(Thread, self).run()
except Exception as e:
self._exception = e
def join(self):
super(Thread, self).join()
if self._exception is not None:
raise self._exception
class PostgrestTimedOut(Exception):
"Connecting to PostgREST endpoint timed out."
class PostgrestError(Exception):
"Postgrest exited with a non-zero return code."
class PostgrestSession(requests_unixsocket.Session):
"HTTP client session directed at a PostgREST endpoint."
def __init__(self, baseurl, *args, **kwargs):
super(PostgrestSession, self).__init__(*args, **kwargs)
self.baseurl = baseurl
def request(self, method, url, *args, **kwargs):
# Not using urllib.parse.urljoin to compose the url, as it doesn't play
# well with our 'http+unix://' unix domain socket urls.
fullurl = self.baseurl + url
return super(PostgrestSession, self).request(method, fullurl, *args, **kwargs)
@dataclasses.dataclass
class PostgrestProcess:
"Running PostgREST process and its corresponding main and admin endpoints."
admin: object
process: object
session: object
@pytest.fixture
def dburi():
"Postgres database connection URI."
dbname = os.environ["PGDATABASE"]
host = os.environ["PGHOST"]
user = os.environ["PGUSER"]
return f"postgresql://?dbname={dbname}&host={host}&user={user}".encode()
@pytest.fixture
def baseenv():
"Base environment to connect to PostgreSQL"
return {
"PGDATABASE": os.environ["PGDATABASE"],
"PGHOST": os.environ["PGHOST"],
"PGUSER": os.environ["PGUSER"],
}
@pytest.fixture
def defaultenv(baseenv):
"Default environment for PostgREST."
return {
**baseenv,
"PGRST_DB_CONFIG": "true",
"PGRST_LOG_LEVEL": "info",
"PGRST_DB_POOL": "1",
"PGRST_DB_POOL_TIMEOUT": "1",
}
@pytest.fixture(scope="module")
def metapostgrest():
"A shared postgrest instance to use for interacting with the database independently of the instance under test"
role = "meta_authenticator"
env = {
"PGDATABASE": os.environ["PGDATABASE"],
"PGHOST": os.environ["PGHOST"],
"PGUSER": role,
"PGRST_DB_ANON_ROLE": role,
"PGRST_DB_CONFIG": "true",
"PGRST_LOG_LEVEL": "info",
"PGRST_DB_POOL": "1",
"PGRST_DB_POOL_TIMEOUT": "1",
}
with run(env=env) as postgrest:
yield postgrest
def hpctixfile():
"Returns an individual filename for each test, if the HPCTIXFILE environment variable is set."
if "HPCTIXFILE" not in os.environ:
return ""
tixfile = pathlib.Path(os.environ["HPCTIXFILE"])
test = hash(os.environ["PYTEST_CURRENT_TEST"])
return tixfile.with_suffix(f".{test}.tix")
def cli(args, env=None, stdin=None):
"Run PostgREST and return stdout."
env = env or {}
command = [POSTGREST_BIN] + args
env["HPCTIXFILE"] = hpctixfile()
process = subprocess.Popen(
command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
process.stdin.write(stdin or b"")
try:
result = process.communicate(timeout=5)[0]
if process.returncode != 0:
raise PostgrestError()
return result.decode()
finally:
process.kill()
process.wait()
def dumpconfig(configpath=None, env=None, stdin=None):
"Dump the config as parsed by PostgREST."
args = ["--dump-config"]
if configpath:
args.append(configpath)
return cli(args, env=env, stdin=stdin)
@contextlib.contextmanager
def run(
configpath=None,
stdin=None,
env=None,
port=None,
host=None,
no_pool_connection_available=False,
):
"Run PostgREST and yield an endpoint that is ready for connections."
with tempfile.TemporaryDirectory() as tmpdir:
if port:
env["PGRST_SERVER_PORT"] = str(port)
env["PGRST_SERVER_HOST"] = host or "localhost"
baseurl = f"http://localhost:{port}"
else:
socketfile = pathlib.Path(tmpdir) / "postgrest.sock"
env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile)
baseurl = "http+unix://" + urllib.parse.quote_plus(str(socketfile))
adminport = freeport()
env["PGRST_ADMIN_SERVER_PORT"] = str(adminport)
adminurl = f"http://localhost:{adminport}"
command = [POSTGREST_BIN]
env["HPCTIXFILE"] = hpctixfile()
if configpath:
command.append(configpath)
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
)
os.set_blocking(process.stdout.fileno(), False)
try:
process.stdin.write(stdin or b"")
process.stdin.close()
wait_until_ready(adminurl + "/ready")
process.stdout.read()
yield PostgrestProcess(
process=process,
session=PostgrestSession(baseurl),
admin=PostgrestSession(adminurl),
)
finally:
if no_pool_connection_available:
sleep_pool_connection(baseurl, 10)
remaining_output = process.stdout.read()
if remaining_output:
print(remaining_output.decode())
process.terminate()
try:
process.wait(timeout=1)
except:
process.kill()
process.wait()
def freeport():
"Find a free port on localhost."
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind(("", 0))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return s.getsockname()[1]
def wait_until_ready(url):
"Wait for the given HTTP endpoint to return a status of 200."
session = requests_unixsocket.Session()
response = None
for _ in range(10):
try:
response = session.get(url, timeout=1)
if response.status_code == 200:
return
except (requests.ConnectionError, requests.ReadTimeout):
pass
time.sleep(0.1)
if response:
raise PostgrestTimedOut(f"{response.status_code}: {response.text}")
else:
raise PostgrestTimedOut()
def sleep_pool_connection(url, seconds):
"Sleep a pool connection by calling an RPC that uses pg_sleep"
session = requests_unixsocket.Session()
# The try/except is a hack for not waiting for the response,
# taken from https://stackoverflow.com/a/45601591/4692662
try:
session.get(url + f"/rpc/sleep?seconds={seconds}", timeout=0.1)
except requests.exceptions.ReadTimeout:
pass
def authheader(token):
"Bearer token HTTP authorization header."
return {"Authorization": f"Bearer {token}"}
def jwtauthheader(claim, secret):
"Authorization header with signed JWT."
return authheader(jwt.encode(claim, secret))
@pytest.mark.parametrize(
"args,env,use_defaultenv,expect",
map(itemgetter("args", "env", "use_defaultenv", "expect"), FIXTURES["cli"]),
ids=map(itemgetter("name"), FIXTURES["cli"]),
)
def test_cli(args, env, use_defaultenv, expect, defaultenv):
"""
When PostgREST is run with <args> arguments and <env>/<defaultenv> environment variabales
it should return. Exit code should be according to <expect_error>.
"""
# use --dump-config by default to make sure that the postgrest process will terminate for sure
args = args or ["--dump-config"]
env = env or {}
if use_defaultenv:
env = {**defaultenv, **env}
if expect == "error":
with pytest.raises(PostgrestError):
print(cli(args, env=env))
else:
dump = cli(args, env=env).split("\n")
if expect:
assert expect in dump
@pytest.mark.parametrize(
"expectedconfig",
[
expectedconfig
for expectedconfig in (CONFIGSDIR / "expected").iterdir()
if (CONFIGSDIR / expectedconfig.name).exists()
],
ids=attrgetter("name"),
)
def test_expected_config(expectedconfig):
"""
Configs as dumped by PostgREST should match an expected output.
Used to test default values, config aliases and environment variables. The
expected output for each file in 'configs', if available, is found in the
'configs/expected' directory.
"""
expected = expectedconfig.read_text()
config = CONFIGSDIR / expectedconfig.name
assert dumpconfig(config) == expected
def test_expected_config_from_environment():
"Config should be read directly from environment without config file."
envfile = (CONFIGSDIR / "no-defaults-env.yaml").read_text()
env = {k: str(v) for k, v in yaml.load(envfile, Loader=yaml.Loader).items()}
expected = (CONFIGSDIR / "expected" / "no-defaults.config").read_text()
assert dumpconfig(env=env) == expected
@pytest.mark.parametrize(
"role, expectedconfig",
[
("db_config_authenticator", "no-defaults-with-db.config"),
("other_authenticator", "no-defaults-with-db-other-authenticator.config"),
],
)
def test_expected_config_from_db_settings(baseenv, role, expectedconfig):
"Config should be overriden from database settings"
config = CONFIGSDIR / "no-defaults.config"
env = {
**baseenv,
"PGUSER": role,
"PGRST_DB_URI": "postgresql://",
"PGRST_DB_CONFIG": "true",
}
expected = (CONFIGSDIR / "expected" / expectedconfig).read_text()
assert dumpconfig(configpath=config, env=env) == expected
@pytest.mark.parametrize(
"config",
[conf for conf in CONFIGSDIR.iterdir() if conf.suffix == ".config"],
ids=attrgetter("name"),
)
def test_stable_config(tmp_path, config, defaultenv):
"""
A dumped, re-read and re-dumped config should match the dumped config.
Note: only dump vs. re-dump must be equal, as the original config file might
be different because of default values, whitespace, and quoting.
"""
# Set environment variables that some of the configs expect. Using a
# complex ROLE_CLAIM_KEY to make sure quoting works.
env = {
**defaultenv,
"ROLE_CLAIM_KEY": '."https://www.example.com/roles"[0].value',
"POSTGREST_TEST_SOCKET": "/tmp/postgrest.sock",
"POSTGREST_TEST_PORT": "80",
"JWT_SECRET_FILE": "a_file",
}
# Some configs expect input from stdin, at least on base64.
stdin = b"Y29ubmVjdGlvbl9zdHJpbmc="
dumped = dumpconfig(config, env=env, stdin=stdin)
tmpconfigpath = tmp_path / "config"
tmpconfigpath.write_text(dumped)
redumped = dumpconfig(tmpconfigpath, env=env)
assert dumped == redumped
def test_port_connection(defaultenv):
"Connections via a port on localhost should work."
with run(env=defaultenv, port=freeport()):
pass
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -483,6 +66,15 @@ def test_read_secret_from_stdin_dbconfig(defaultenv):
assert response.status_code == 200 assert response.status_code == 200
def test_fail_with_invalid_password(defaultenv):
"Connecting with an invalid password should fail without retries."
uri = f'postgresql://?dbname={defaultenv["PGDATABASE"]}&host={defaultenv["PGHOST"]}&user=some_protected_user&password=invalid_pass'
env = {**defaultenv, "PGRST_DB_URI": uri}
with run(env=env, wait_for_readiness=False) as postgrest:
exitCode = wait_until_exit(postgrest)
assert exitCode == 1
def test_connect_with_dburi(dburi, defaultenv): def test_connect_with_dburi(dburi, defaultenv):
"Connecting with db-uri instead of LIPQ* environment variables should work." "Connecting with db-uri instead of LIPQ* environment variables should work."
defaultenv_without_libpq = { defaultenv_without_libpq = {
@@ -538,36 +130,6 @@ def test_role_claim_key(roleclaim, defaultenv):
assert response.status_code == roleclaim["expected_status"] assert response.status_code == roleclaim["expected_status"]
@pytest.mark.parametrize("invalidroleclaimkey", FIXTURES["invalidroleclaimkeys"])
def test_invalid_role_claim_key(invalidroleclaimkey, defaultenv):
"Given an invalid role-claim-key, Postgrest should exit with a non-zero exit code."
env = {
**defaultenv,
"PGRST_JWT_ROLE_CLAIM_KEY": invalidroleclaimkey,
}
with pytest.raises(PostgrestError):
dump = dumpconfig(env=env)
for line in dump.split("\n"):
if line.startswith("jwt-role-claim-key"):
print(line)
@pytest.mark.parametrize("invalidopenapimodes", FIXTURES["invalidopenapimodes"])
def test_invalid_openapi_mode(invalidopenapimodes, defaultenv):
"Given an invalid openapi-mode, Postgrest should exit with a non-zero exit code."
env = {
**defaultenv,
"PGRST_OPENAPI_MODE": invalidopenapimodes,
}
with pytest.raises(PostgrestError):
dump = dumpconfig(CONFIGSDIR / "defaults.config", env=env)
for line in dump.split("\n"):
if line.startswith("openapi-mode"):
print(line)
def test_iat_claim(defaultenv): def test_iat_claim(defaultenv):
""" """
A claim with an 'iat' (issued at) attribute should be successful. A claim with an 'iat' (issued at) attribute should be successful.
@@ -590,9 +152,9 @@ def test_iat_claim(defaultenv):
time.sleep(0.1) time.sleep(0.1)
def test_app_settings(defaultenv): def test_app_settings_flush_pool(defaultenv):
""" """
App settings should not reset when the db pool times out. App settings should not reset when the db pool is flushed.
See: https://github.com/PostgREST/postgrest/issues/1141 See: https://github.com/PostgREST/postgrest/issues/1141
@@ -601,15 +163,40 @@ def test_app_settings(defaultenv):
env = {**defaultenv, "PGRST_APP_SETTINGS_EXTERNAL_API_SECRET": "0123456789abcdef"} env = {**defaultenv, "PGRST_APP_SETTINGS_EXTERNAL_API_SECRET": "0123456789abcdef"}
with run(env=env) as postgrest: with run(env=env) as postgrest:
# Wait for the db pool to time out, set to 1s in config uri = "/rpc/get_guc_value?name=app.settings.external_api_secret"
time.sleep(2) response = postgrest.session.get(uri)
assert response.text == '"0123456789abcdef"'
# SIGUSR1 causes the postgres connection pool to be flushed
postgrest.process.send_signal(signal.SIGUSR1)
time.sleep(0.1)
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)
assert response.text == '"0123456789abcdef"' assert response.text == '"0123456789abcdef"'
def test_flush_pool_no_interrupt(defaultenv):
"Flushing the pool via SIGUSR1 doesn't interrupt ongoing requests"
with run(env=defaultenv) as postgrest:
def sleep():
response = postgrest.session.get("/rpc/sleep?seconds=0.5")
assert response.status_code == 204
t = Thread(target=sleep)
t.start()
# make sure the request has started
time.sleep(0.1)
# SIGUSR1 causes the postgres connection pool to be flushed
postgrest.process.send_signal(signal.SIGUSR1)
t.join()
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()
@@ -747,7 +334,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.1) time.sleep(0.2)
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\\""'
@@ -970,7 +557,22 @@ def test_pool_size(defaultenv, metapostgrest):
assert delta > 1 and delta < 1.5 assert delta > 1 and delta < 1.5
@pytest.mark.xfail(reason="issue #2401") def test_pool_acquisition_timeout(defaultenv, metapostgrest):
"Verify that PGRST_DB_POOL_ACQUISITON_TIMEOUT times out when the pool is empty"
env = {
**defaultenv,
"PGRST_DB_POOL": "1",
"PGRST_DB_POOL_ACQUISITION_TIMEOUT": "1", # 1 second
}
with run(env=env, no_pool_connection_available=True) as postgrest:
response = postgrest.session.get("/projects")
assert response.status_code == 504
data = response.json()
assert data["message"] == "Timed out acquiring connection from connection pool."
def test_change_statement_timeout_held_connection(defaultenv, metapostgrest): def test_change_statement_timeout_held_connection(defaultenv, metapostgrest):
"Statement timeout changes take effect immediately, even with a request outliving the reconfiguration" "Statement timeout changes take effect immediately, even with a request outliving the reconfiguration"
@@ -1215,6 +817,37 @@ def test_no_pool_connection_required_on_bad_jwt_claim(defaultenv):
assert response.status_code == 401 assert response.status_code == 401
def test_no_pool_connection_required_on_bad_embedding(defaultenv):
"no pool connection should be consumed for failing to embed"
with run(env=defaultenv, no_pool_connection_available=True) as postgrest:
# OPTIONS on a table shouldn't require opening a connection
response = postgrest.session.get("/projects?select=*,unexistent(*)")
assert response.status_code == 400
def test_notify_reloading_catalog_cache(defaultenv):
"notify should reload the connection catalog cache"
with run(env=defaultenv) as postgrest:
# first the id col is an uuid
response = postgrest.session.get(
"/cats?id=eq.dea27321-f988-4a57-93e4-8eeb38f3cf1e"
)
assert response.status_code == 200
# change it to a bigint
response = postgrest.session.post("/rpc/drop_change_cats")
assert response.status_code == 204
time.sleep(0.1)
# next request should succeed with a bigint value
response = postgrest.session.get("/cats?id=eq.1")
assert response.status_code == 200
# TODO: This test fails now because of https://github.com/PostgREST/postgrest/pull/2122 # TODO: This test fails now because of https://github.com/PostgREST/postgrest/pull/2122
# The stack size of 1K(-with-rtsopts=-K1K) is not enough and this fails with "stack overflow" # The stack size of 1K(-with-rtsopts=-K1K) is not enough and this fails with "stack overflow"
# A stack size of 200K seems to be enough for succeess # A stack size of 200K seems to be enough for succeess
+35
View File
@@ -0,0 +1,35 @@
"Sanity checks for the PostgREST black box testing infrastructure."
from datetime import datetime
from operator import attrgetter
import os
import re
import signal
import socket
import time
import pytest
from config import *
from util import *
from postgrest import *
def test_port_connection(defaultenv):
"Connections via a port on localhost should work."
with run(env=defaultenv, port=freeport()):
pass
def test_plain_get(defaultenv):
"run() should give a working PostgREST."
with run(env=defaultenv) as postgrest:
response = postgrest.session.get("/projects")
assert response.status_code == 200
def test_no_pool_connection_available(defaultenv):
"no_pool_connection_available option is functional"
with run(env=defaultenv, no_pool_connection_available=True) as postgrest:
with pytest.raises(Exception) as e:
postgrest.session.get("/projects", timeout=1)
+42
View File
@@ -0,0 +1,42 @@
import contextlib
import socket
import threading
import jwt
class Thread(threading.Thread):
"Variant of threading.Thread that re-raises any exceptions when joining the thread"
def __init__(self, *args, **kwargs):
self._exception = None
super(Thread, self).__init__(*args, **kwargs)
def run(self):
try:
super(Thread, self).run()
except Exception as e:
self._exception = e
def join(self):
super(Thread, self).join()
if self._exception is not None:
raise self._exception
def freeport():
"Find a free port on localhost."
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind(("", 0))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return s.getsockname()[1]
def authheader(token):
"Bearer token HTTP authorization header."
return {"Authorization": f"Bearer {token}"}
def jwtauthheader(claim, secret):
"Authorization header with signed JWT."
return authheader(jwt.encode(claim, secret))
+229 -6
View File
@@ -91,7 +91,7 @@ spec actualPgVersion = describe "OpenAPI" $ do
[ [
{ "$ref": "#/parameters/body.child_entities" }, { "$ref": "#/parameters/body.child_entities" },
{ "$ref": "#/parameters/select" }, { "$ref": "#/parameters/select" },
{ "$ref": "#/parameters/preferReturn" } { "$ref": "#/parameters/preferPost" }
] ]
|] |]
@@ -157,6 +157,38 @@ spec actualPgVersion = describe "OpenAPI" $ do
} }
|] |]
it "includes definitions to views" $ do
r <- simpleBody <$> get "/"
let def = r ^? key "definitions" . key "child_entities_view"
liftIO $
def `shouldBe` Just
[aesonQQ|
{
"type": "object",
"description": "child_entities_view comment",
"properties": {
"id": {
"description": "child_entities_view id comment\n\nNote:\nThis is a Primary Key.<pk/>",
"format": "integer",
"type": "integer"
},
"name": {
"description": "child_entities_view name comment. Can be longer than sixty-three characters long",
"format": "text",
"type": "string"
},
"parent_id": {
"description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' column='id'/>",
"format": "integer",
"type": "integer"
}
}
}
|]
it "doesn't include privileged table for anonymous" $ do it "doesn't include privileged table for anonymous" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
let tablePath = r ^? key "paths" . key "/authors_only" let tablePath = r ^? key "paths" . key "/authors_only"
@@ -278,6 +310,23 @@ spec actualPgVersion = describe "OpenAPI" $ do
} }
|] |]
describe "VIEW created for a TABLE with a O2M relationship" $ do
it "fk points to destination TABLE instead of the VIEW" $ do
r <- simpleBody <$> get "/"
let referralLink = r ^? key "definitions" . key "projects" . key "properties" . key "client_id"
liftIO $
referralLink `shouldBe` Just
[aesonQQ|
{
"format": "integer",
"type": "integer",
"description": "Note:\nThis is a Foreign Key to `clients.id`.<fk table='clients' column='id'/>"
}
|]
describe "PostgreSQL to Swagger Type Mapping" $ do describe "PostgreSQL to Swagger Type Mapping" $ do
it "character varying to string" $ do it "character varying to string" $ do
@@ -458,6 +507,117 @@ spec actualPgVersion = describe "OpenAPI" $ do
} }
|] |]
it "array types to array" $ do
r <- simpleBody <$> get "/"
let text_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text_arr"
let int_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_int_arr"
let bool_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bool_arr"
let char_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_char_arr"
let varchar_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_varchar_arr"
let bigint_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint_arr"
let numeric_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric_arr"
let json_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_json_arr"
let jsonb_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_jsonb_arr"
liftIO $ do
text_arr_types `shouldBe` Just
[aesonQQ|
{
"format": "text[]",
"type": "array",
"items": {
"type": "string"
}
}
|]
int_arr_types `shouldBe` Just
[aesonQQ|
{
"format": "integer[]",
"type": "array",
"items": {
"type": "integer"
}
}
|]
bool_arr_types `shouldBe` Just
[aesonQQ|
{
"format": "boolean[]",
"type": "array",
"items": {
"type": "boolean"
}
}
|]
char_arr_types `shouldBe` Just
[aesonQQ|
{
"format": "character[]",
"type": "array",
"items": {
"type": "string"
}
}
|]
varchar_arr_types `shouldBe` Just
[aesonQQ|
{
"format": "character varying[]",
"type": "array",
"items": {
"type": "string"
}
}
|]
bigint_arr_types `shouldBe` Just
[aesonQQ|
{
"format": "bigint[]",
"type": "array",
"items": {
"type": "integer"
}
}
|]
numeric_arr_types `shouldBe` Just
[aesonQQ|
{
"format": "numeric[]",
"type": "array",
"items": {
"type": "number"
}
}
|]
json_arr_types `shouldBe` Just
[aesonQQ|
{
"format": "json[]",
"type": "array",
"items": {}
}
|]
jsonb_arr_types `shouldBe` Just
[aesonQQ|
{
"format": "jsonb[]",
"type": "array",
"items": {}
}
|]
describe "Detects default values" $ do describe "Detects default values" $ do
it "text" $ do it "text" $ do
@@ -523,7 +683,7 @@ spec actualPgVersion = describe "OpenAPI" $ do
types `shouldBe` Just [aesonQQ| types `shouldBe` Just [aesonQQ|
{ {
"format": "enum_menagerie_type", "format": "test.enum_menagerie_type",
"type": "string", "type": "string",
"enum": [ "enum": [
"foo", "foo",
@@ -537,7 +697,7 @@ spec actualPgVersion = describe "OpenAPI" $ do
it "includes function summary/description and body schema for arguments" $ do it "includes function summary/description and body schema for arguments" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
let method s = key "paths" . key "/rpc/varied_arguments" . key s let method s = key "paths" . key "/rpc/varied_arguments_openapi" . key s
args = r ^? method "post" . key "parameters" . nth 0 . key "schema" args = r ^? method "post" . key "parameters" . nth 0 . key "schema"
summary = r ^? method "post" . key "summary" summary = r ^? method "post" . key "summary"
description = r ^? method "post" . key "description" description = r ^? method "post" . key "description"
@@ -558,7 +718,15 @@ spec actualPgVersion = describe "OpenAPI" $ do
"date", "date",
"money", "money",
"enum", "enum",
"arr" "text_arr",
"int_arr",
"bool_arr",
"char_arr",
"varchar_arr",
"bigint_arr",
"numeric_arr",
"json_arr",
"jsonb_arr"
], ],
"properties": { "properties": {
"double": { "double": {
@@ -585,9 +753,64 @@ spec actualPgVersion = describe "OpenAPI" $ do
"format": "enum_menagerie_type", "format": "enum_menagerie_type",
"type": "string" "type": "string"
}, },
"arr": { "text_arr": {
"format": "text[]", "format": "text[]",
"type": "string" "type": "array",
"items": {
"type": "string"
}
},
"int_arr": {
"format": "integer[]",
"type": "array",
"items": {
"type": "integer"
}
},
"bool_arr": {
"format": "boolean[]",
"type": "array",
"items": {
"type": "boolean"
}
},
"char_arr": {
"format": "character[]",
"type": "array",
"items": {
"type": "string"
}
},
"varchar_arr": {
"format": "character varying[]",
"type": "array",
"items": {
"type": "string"
}
},
"bigint_arr": {
"format": "bigint[]",
"type": "array",
"items": {
"type": "integer"
}
},
"numeric_arr": {
"format": "numeric[]",
"type": "array",
"items": {
"type": "number"
}
},
"json_arr": {
"format": "json[]",
"type": "array",
"items": {}
},
"jsonb_arr": {
"format": "jsonb[]",
"type": "array",
"items": {}
}, },
"integer": { "integer": {
"format": "integer", "format": "integer",
+1 -1
View File
@@ -200,7 +200,7 @@ spec actualPgVersion =
it "cannot have zero conditions" $ it "cannot have zero conditions" $
get "/entities?or=()" `shouldRespondWith` get "/entities?or=()" `shouldRespondWith`
[json|{ [json|{
"details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)", "details": "unexpected \")\" expecting field name (* or [a..z0..9_$]), negation operator (not) or logic operator (and, or)",
"message": "\"failed to parse logic tree (())\" (line 1, column 4)", "message": "\"failed to parse logic tree (())\" (line 1, column 4)",
"code": "PGRST100", "code": "PGRST100",
"hint": null "hint": null
+50 -6
View File
@@ -12,14 +12,24 @@ import SpecHelper
spec :: SpecWith ((), Application) spec :: SpecWith ((), Application)
spec = describe "computed relationships" $ do spec = describe "computed relationships" $ do
it "can define a many-to-one relationship and embed" $ it "can define a many-to-one relationship with SETOF and ROWS 1 and embed" $
get "/videogames?select=name,designers:computed_designers(name)" get "/videogames?select=name,designer:computed_designers(name)"
`shouldRespondWith` `shouldRespondWith`
[json|[ [json|[
{"name":"Civilization I","designers":{"name":"Sid Meier"}}, {"name":"Civilization I","designer":{"name":"Sid Meier"}},
{"name":"Civilization II","designers":{"name":"Sid Meier"}}, {"name":"Civilization II","designer":{"name":"Sid Meier"}},
{"name":"Final Fantasy I","designers":{"name":"Hironobu Sakaguchi"}}, {"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}},
{"name":"Final Fantasy II","designers":{"name":"Hironobu Sakaguchi"}} {"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}}
]|] { matchHeaders = [matchContentTypeJson] }
it "can define a many-to-one relationship without SETOF and embed" $
get "/videogames?select=name,designer:computed_designers_noset(name)"
`shouldRespondWith`
[json|[
{"name":"Civilization I","designer":{"name":"Sid Meier"}},
{"name":"Civilization II","designer":{"name":"Sid Meier"}},
{"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}},
{"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}}
]|] { matchHeaders = [matchContentTypeJson] } ]|] { matchHeaders = [matchContentTypeJson] }
it "can define a one-to-many relationship and embed" $ it "can define a one-to-many relationship and embed" $
@@ -48,6 +58,16 @@ spec = describe "computed relationships" $ do
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/2"] , matchHeaders = ["Content-Range" <:> "0-1/2"]
} }
request methodGet "/videogames?select=name,designer:computed_designers_noset!inner(name)&designer.name=like.*Hironobu*"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json|[
{"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}},
{"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}}
]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/2"]
}
it "works with rpc" $ do it "works with rpc" $ do
get "/rpc/getallvideogames?select=name,designer:computed_designers(name)" get "/rpc/getallvideogames?select=name,designer:computed_designers(name)"
@@ -107,3 +127,27 @@ spec = describe "computed relationships" $ do
get "/second_1?select=*,first_1(*)" get "/second_1?select=*,first_1(*)"
`shouldRespondWith` `shouldRespondWith`
[json|[]|] { matchHeaders = [matchContentTypeJson] } [json|[]|] { matchHeaders = [matchContentTypeJson] }
-- https://github.com/PostgREST/postgrest/issues/2455
it "creates queries with the right aliasing" $ do
get "/fee?select=*,jsbaz(*,janedoe(*))"
`shouldRespondWith`
[json|[]|] { matchHeaders = [matchContentTypeJson] }
get "/fee?select=*,jsbaz(*,johnsmith(*, fee(*)))"
`shouldRespondWith`
[json|[]|] { matchHeaders = [matchContentTypeJson] }
it "creates queries with the right aliasing when following a normal embed" $ do
get "/projects?select=name,clients(name,computed_projects(name))&limit=1"
`shouldRespondWith`
[json|
[{"name":"Windows 7","clients":{"name":"Microsoft","computed_projects":{"name":"Windows 7"}}}]
|] { matchHeaders = [matchContentTypeJson] }
get "/clients?select=name,projects(name,computed_clients(name))&limit=1"
`shouldRespondWith`
[json|[
{"name":"Microsoft","projects":[
{"name":"Windows 7","computed_clients":{"name":"Microsoft"}},
{"name":"Windows 10","computed_clients":{"name":"Microsoft"}}
]}
]|] { matchHeaders = [matchContentTypeJson] }
@@ -120,6 +120,41 @@ spec =
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
it "errs with multiple references to the same composite key columns in a view" $
get "/i2459_composite_v2?select=*,i2459_composite_v1(*)" `shouldRespondWith`
[json|
{
"code": "PGRST201",
"details": [
{
"cardinality": "many-to-one",
"embedding": "i2459_composite_v2 with i2459_composite_v1",
"relationship": "i2459_composite_t2_t1_a_t1_b_fkey using i2459_composite_v2(t1_a1, t1_b1) and i2459_composite_v1(a, b)"
},
{
"cardinality": "many-to-one",
"embedding": "i2459_composite_v2 with i2459_composite_v1",
"relationship": "i2459_composite_t2_t1_a_t1_b_fkey using i2459_composite_v2(t1_a1, t1_b2) and i2459_composite_v1(a, b)"
},
{
"cardinality": "many-to-one",
"embedding": "i2459_composite_v2 with i2459_composite_v1",
"relationship": "i2459_composite_t2_t1_a_t1_b_fkey using i2459_composite_v2(t1_a2, t1_b1) and i2459_composite_v1(a, b)"
},
{
"cardinality": "many-to-one",
"embedding": "i2459_composite_v2 with i2459_composite_v1",
"relationship": "i2459_composite_t2_t1_a_t1_b_fkey using i2459_composite_v2(t1_a2, t1_b2) and i2459_composite_v1(a, b)"
}
],
"hint": "Try changing 'i2459_composite_v1' to one of the following: 'i2459_composite_v1!i2459_composite_t2_t1_a_t1_b_fkey', 'i2459_composite_v1!i2459_composite_t2_t1_a_t1_b_fkey', 'i2459_composite_v1!i2459_composite_t2_t1_a_t1_b_fkey', 'i2459_composite_v1!i2459_composite_t2_t1_a_t1_b_fkey'. Find the desired relationship in the 'details' key.",
"message": "Could not embed because more than one relationship was found for 'i2459_composite_v2' and 'i2459_composite_v1'"
}
|]
{ matchStatus = 300
, matchHeaders = [matchContentTypeJson]
}
context "disambiguating requests with embed hints" $ do context "disambiguating requests with embed hints" $ do
context "using FK to specify the relationship" $ do context "using FK to specify the relationship" $ do
@@ -167,10 +202,10 @@ spec =
it "fails if the fk is not known" $ it "fails if the fk is not known" $
get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith` get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`
[json|{ [json|{
"hint":"Verify that 'message' and 'person' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.", "hint":null,
"message":"Could not find a relationship between 'message' and 'person' in the schema cache", "message":"Could not find a relationship between 'message' and 'person' in the schema cache",
"code": "PGRST200", "code": "PGRST200",
"details": null}|] "details":"Searched for a foreign key relationship between 'message' and 'person' using the hint 'space' in the schema 'test', but no matches were found."}|]
{ matchStatus = 400 { matchStatus = 400
, matchHeaders = [matchContentTypeJson] } , matchHeaders = [matchContentTypeJson] }
@@ -240,6 +275,20 @@ spec =
[json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |] [json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "can specify all view column names that reference the same base column" $ do
get "/i2459_simple_v1?select=*,i2459_simple_v2!t1_id1(*)" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
get "/i2459_simple_v1?select=*,i2459_simple_v2!t1_id2(*)" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
get "/i2459_simple_v2?select=*,i2459_simple_v1!t1_id1(*)" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
get "/i2459_simple_v2?select=*,i2459_simple_v1!t1_id2(*)" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
context "using the junction to disambiguate the request" $ context "using the junction to disambiguate the request" $
it "can specify the junction of an m2m relationship" $ do it "can specify the junction of an m2m relationship" $ do
get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith` get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
@@ -341,6 +390,14 @@ spec =
} }
]|] { matchHeaders = [matchContentTypeJson] } ]|] { matchHeaders = [matchContentTypeJson] }
it "can specify all view column names that reference the same base column" $ do
get "/i2459_self_v1?select=*,parent(*),grandparent(*)" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
get "/i2459_self_v2?select=*,parent(*),grandparent(*)" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
context "two self reference foreign keys" $ do context "two self reference foreign keys" $ do
it "embeds parents" $ it "embeds parents" $
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith` get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
@@ -435,10 +492,10 @@ spec =
it "doesn't work if the junction is only internal" $ it "doesn't work if the junction is only internal" $
get "/end_1?select=end_2(*)" `shouldRespondWith` get "/end_1?select=end_2(*)" `shouldRespondWith`
[json|{ [json|{
"hint":"Verify that 'end_1' and 'end_2' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.", "hint": null,
"message":"Could not find a relationship between 'end_1' and 'end_2' in the schema cache", "message":"Could not find a relationship between 'end_1' and 'end_2' in the schema cache",
"code":"PGRST200", "code":"PGRST200",
"details": null}|] "details": "Searched for a foreign key relationship between 'end_1' and 'end_2' in the schema 'test', but no matches were found."}|]
{ matchStatus = 400 { matchStatus = 400
, matchHeaders = [matchContentTypeJson] } , matchHeaders = [matchContentTypeJson] }
it "shouldn't try to embed if the private junction has an exposed homonym" $ it "shouldn't try to embed if the private junction has an exposed homonym" $
@@ -446,10 +503,10 @@ spec =
-- Ref: https://github.com/PostgREST/postgrest/issues/1587#issuecomment-734995669 -- Ref: https://github.com/PostgREST/postgrest/issues/1587#issuecomment-734995669
get "/schauspieler?select=filme(*)" `shouldRespondWith` get "/schauspieler?select=filme(*)" `shouldRespondWith`
[json|{ [json|{
"hint":"Verify that 'schauspieler' and 'filme' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.", "hint":null,
"message":"Could not find a relationship between 'schauspieler' and 'filme' in the schema cache", "message":"Could not find a relationship between 'schauspieler' and 'filme' in the schema cache",
"code":"PGRST200", "code":"PGRST200",
"details": null}|] "details":"Searched for a foreign key relationship between 'schauspieler' and 'filme' in the schema 'test', but no matches were found."}|]
{ matchStatus = 400 { matchStatus = 400
, matchHeaders = [matchContentTypeJson] } , matchHeaders = [matchContentTypeJson] }
@@ -507,3 +564,5 @@ spec =
]|] ]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "should not expose hidden FKs" $
get "/va?select=vb(*)" `shouldRespondWith` 200
+15 -4
View File
@@ -415,7 +415,7 @@ spec actualPgVersion = do
{"id": 204, "body": "yyy"}, {"id": 204, "body": "yyy"},
{"id": 205, "body": "zzz"}]|] {"id": 205, "body": "zzz"}]|]
`shouldRespondWith` `shouldRespondWith`
[json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)","code":"PGRST100","hint":null} |] [json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_$])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)","code":"PGRST100","hint":null} |]
{ matchStatus = 400 { matchStatus = 400
, matchHeaders = [] , matchHeaders = []
} }
@@ -591,7 +591,7 @@ spec actualPgVersion = do
} }
describe "Inserting into VIEWs" $ do describe "Inserting into VIEWs" $ do
context "requesting no representation" $ context "requesting no representation" $ do
it "succeeds with 201" $ it "succeeds with 201" $
post "/compound_pk_view" post "/compound_pk_view"
[json|{"k1":1,"k2":"test","extra":2}|] [json|{"k1":1,"k2":"test","extra":2}|]
@@ -602,6 +602,17 @@ spec actualPgVersion = do
, matchHeaderAbsent hLocation ] , matchHeaderAbsent hLocation ]
} }
it "returns a location header with pks from both tables" $
request methodPost "/with_multiple_pks" [("Prefer", "return=headers-only")]
[json|{"pk1":1,"pk2":2}|]
`shouldRespondWith`
""
{ matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/with_multiple_pks?pk1=eq.1&pk2=eq.2"
, "Content-Range" <:> "*/*" ]
}
context "requesting header only representation" $ do context "requesting header only representation" $ do
it "returns a location header with a composite PK col" $ it "returns a location header with a composite PK col" $
request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")] request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")]
@@ -614,13 +625,13 @@ spec actualPgVersion = do
, "Content-Range" <:> "*/*" ] , "Content-Range" <:> "*/*" ]
} }
it "returns location header with a single PK col" $ it "should not throw and return location header when a PK is null" $
request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")] request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")]
[json|{"id":1}|] [json|{"id":1}|]
`shouldRespondWith` `shouldRespondWith`
"" ""
{ matchStatus = 201 { matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1" , "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"
, "Content-Range" <:> "*/*" ] , "Content-Range" <:> "*/*" ]
} }
+26 -3
View File
@@ -74,10 +74,33 @@ spec =
} }
it "succeeds in reading a table from a schema with uppercase and special characters in its name" $ it "succeeds in reading a table from a schema with uppercase and special characters in its name" $
request methodGet "/names" [("Accept-Profile", "SPECIAL \"@/\\#~_-")] "" `shouldRespondWith` request methodGet "/names?select=id,name" [("Accept-Profile", "SPECIAL \"@/\\#~_-")] "" `shouldRespondWith`
[json|[ [json|[
{"id":1,"name":"John"}, {"id": 1, "name":"John"},
{"id":2,"name":"Mary"} {"id": 2, "name":"Mary"},
{"id": 3, "name":"José"}
]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "SPECIAL \"@/\\#~_-"]
}
it "succeeds in embedding with FK when the schema name has special characters" $
request methodGet "/names?select=name,languages(name)&id=in.(1,3)" [("Accept-Profile", "SPECIAL \"@/\\#~_-")] "" `shouldRespondWith`
[json|[
{"name": "John", languages: {"name": "English"}},
{"name": "José", languages: {"name": "Spanish"}}
]|]
{
matchStatus = 200
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "SPECIAL \"@/\\#~_-"]
}
it "succeeds in embedding with computed relationships when the schema name has special characters" $
request methodGet "/names?select=name,computed_languages(name)&id=in.(1,3)" [("Accept-Profile", "SPECIAL \"@/\\#~_-")] "" `shouldRespondWith`
[json|[
{"name": "John", computed_languages: {"name": "English"}},
{"name": "José", computed_languages: {"name": "Spanish"}}
]|] ]|]
{ {
matchStatus = 200 matchStatus = 200
@@ -114,3 +114,10 @@ spec =
{ "first_name": "Daniel B.", "last_name": "Lyon" }, { "first_name": "Daniel B.", "last_name": "Lyon" },
{ "first_name": "Edwin S.", "last_name": "Smith" } ]|] { "first_name": "Edwin S.", "last_name": "Smith" } ]|]
{ matchStatus = 200 } { matchStatus = 200 }
context "max-rows is set and limits are requested" $ do
it "should work with limit 0" $
get "/items?limit=0"
`shouldRespondWith`
[json| [] |]
{ matchHeaders = ["Content-Range" <:> "*/*"] }
+11 -8
View File
@@ -590,8 +590,8 @@ spec actualPgVersion = do
it "cannot request partitions as children from a partitioned table" $ it "cannot request partitions as children from a partitioned table" $
get "/car_models?id=in.(1,2,4)&select=id,name,car_model_sales_202101(id)&order=id.asc" `shouldRespondWith` get "/car_models?id=in.(1,2,4)&select=id,name,car_model_sales_202101(id)&order=id.asc" `shouldRespondWith`
[json| [json|
{"hint":"Verify that 'car_models' and 'car_model_sales_202101' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.", {"hint":"Perhaps you meant 'car_model_sales' instead of 'car_model_sales_202101'.",
"details":null, "details":"Searched for a foreign key relationship between 'car_models' and 'car_model_sales_202101' in the schema 'test', but no matches were found.",
"code":"PGRST200", "code":"PGRST200",
"message":"Could not find a relationship between 'car_models' and 'car_model_sales_202101' in the schema cache"} |] "message":"Could not find a relationship between 'car_models' and 'car_model_sales_202101' in the schema cache"} |]
{ matchStatus = 400 { matchStatus = 400
@@ -601,8 +601,8 @@ spec actualPgVersion = do
it "cannot request a partitioned table as parent from a partition" $ it "cannot request a partitioned table as parent from a partition" $
get "/car_model_sales_202101?select=id,name,car_models(id,name)&order=id.asc" `shouldRespondWith` get "/car_model_sales_202101?select=id,name,car_models(id,name)&order=id.asc" `shouldRespondWith`
[json| [json|
{"hint":"Verify that 'car_model_sales_202101' and 'car_models' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.", {"hint":"Perhaps you meant 'car_model_sales' instead of 'car_model_sales_202101'.",
"details":null, "details":"Searched for a foreign key relationship between 'car_model_sales_202101' and 'car_models' in the schema 'test', but no matches were found.",
"code":"PGRST200", "code":"PGRST200",
"message":"Could not find a relationship between 'car_model_sales_202101' and 'car_models' in the schema cache"} |] "message":"Could not find a relationship between 'car_model_sales_202101' and 'car_models' in the schema cache"} |]
{ matchStatus = 400 { matchStatus = 400
@@ -612,8 +612,8 @@ spec actualPgVersion = do
it "cannot request a partition as parent from a partitioned table" $ it "cannot request a partition as parent from a partitioned table" $
get "/car_model_sales?id=in.(1,3,4)&select=id,name,car_models_default(id,name)&order=id.asc" `shouldRespondWith` get "/car_model_sales?id=in.(1,3,4)&select=id,name,car_models_default(id,name)&order=id.asc" `shouldRespondWith`
[json| [json|
{"hint":"Verify that 'car_model_sales' and 'car_models_default' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.", {"hint":"Perhaps you meant 'car_models' instead of 'car_models_default'.",
"details":null, "details":"Searched for a foreign key relationship between 'car_model_sales' and 'car_models_default' in the schema 'test', but no matches were found.",
"code":"PGRST200", "code":"PGRST200",
"message":"Could not find a relationship between 'car_model_sales' and 'car_models_default' in the schema cache"} |] "message":"Could not find a relationship between 'car_model_sales' and 'car_models_default' in the schema cache"} |]
{ matchStatus = 400 { matchStatus = 400
@@ -623,8 +623,8 @@ spec actualPgVersion = do
it "cannot request partitioned tables as children from a partition" $ it "cannot request partitioned tables as children from a partition" $
get "/car_models_default?select=id,name,car_model_sales(id,name)&order=id.asc" `shouldRespondWith` get "/car_models_default?select=id,name,car_model_sales(id,name)&order=id.asc" `shouldRespondWith`
[json| [json|
{"hint":"Verify that 'car_models_default' and 'car_model_sales' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.", {"hint":"Perhaps you meant 'car_model_sales' instead of 'car_models_default'.",
"details":null, "details":"Searched for a foreign key relationship between 'car_models_default' and 'car_model_sales' in the schema 'test', but no matches were found.",
"code":"PGRST200", "code":"PGRST200",
"message":"Could not find a relationship between 'car_models_default' and 'car_model_sales' in the schema cache"} |] "message":"Could not find a relationship between 'car_models_default' and 'car_model_sales' in the schema cache"} |]
{ matchStatus = 400 { matchStatus = 400
@@ -739,6 +739,9 @@ spec actualPgVersion = do
{"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |] {"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "works when embedding two views that refer to tables with different column ordering" $
get "/v1?select=v2(*)" `shouldRespondWith` 200
it "can embed a view that has group by" $ it "can embed a view that has group by" $
get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith` get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`
[json| [json|
+109 -7
View File
@@ -39,6 +39,35 @@ spec = do
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |] [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
{ matchHeaders = ["Content-Range" <:> "0-14/*"] } { matchHeaders = ["Content-Range" <:> "0-14/*"] }
context "of invalid range" $ do
it "refuses a range with nonzero start when there are no items" $
request methodPost "/rpc/getitemrange?offset=1"
[("Prefer", "count=exact")] emptyRange
`shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 1 was requested, but there are only 0 rows.",
"hint":null
}|]
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"]
}
it "refuses a range requesting start past last item" $
request methodPost "/rpc/getitemrange?offset=100"
[("Prefer", "count=exact")] defaultRange
`shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 100 was requested, but there are only 15 rows.",
"hint":null
}|]
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"]
}
context "with range headers" $ do context "with range headers" $ do
context "of acceptable range" $ do context "of acceptable range" $ do
it "succeeds with partial content" $ do it "succeeds with partial content" $ do
@@ -82,12 +111,25 @@ spec = do
it "fails with 416 for offside range" $ it "fails with 416 for offside range" $
request methodPost "/rpc/getitemrange" request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 1 0) emptyRange (rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
`shouldRespondWith` 416 `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"The lower boundary must be lower than or equal to the upper boundary in the Range header.",
"hint":null
}|]
{ matchStatus = 416 }
it "refuses a range with nonzero start when there are no items" $ it "refuses a range with nonzero start when there are no items" $
request methodPost "/rpc/getitemrange" request methodPost "/rpc/getitemrange"
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange (rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
`shouldRespondWith` "[]" `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 1 was requested, but there are only 0 rows.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"] , matchHeaders = ["Content-Range" <:> "*/0"]
} }
@@ -95,7 +137,13 @@ spec = do
it "refuses a range requesting start past last item" $ it "refuses a range requesting start past last item" $
request methodPost "/rpc/getitemrange" request methodPost "/rpc/getitemrange"
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange (rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
`shouldRespondWith` "[]" `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 100 was requested, but there are only 15 rows.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"] , matchHeaders = ["Content-Range" <:> "*/15"]
} }
@@ -193,11 +241,46 @@ spec = do
it "fails if limit is negative" $ it "fails if limit is negative" $
get "/items?select=id&limit=-1" get "/items?select=id&limit=-1"
`shouldRespondWith` [json|{"message":"HTTP Range error","code":"PGRST103","details":null,"hint":null}|] `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"Limit should be greater than or equal to zero.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
context "of invalid range" $ do
it "refuses a range with nonzero start when there are no items" $
request methodGet "/menagerie?offset=1"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 1 was requested, but there are only 0 rows.",
"hint":null
}|]
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"]
}
it "refuses a range requesting start past last item" $
request methodGet "/items?offset=100"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 100 was requested, but there are only 15 rows.",
"hint":null
}|]
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"]
}
context "when count=planned" $ do context "when count=planned" $ do
it "obtains a filtered range" $ do it "obtains a filtered range" $ do
request methodGet "/items?select=id&id=gt.8" request methodGet "/items?select=id&id=gt.8"
@@ -343,12 +426,25 @@ spec = do
it "fails with 416 for offside range" $ it "fails with 416 for offside range" $
request methodGet "/items" request methodGet "/items"
(rangeHdrs $ ByteRangeFromTo 1 0) "" (rangeHdrs $ ByteRangeFromTo 1 0) ""
`shouldRespondWith` 416 `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"The lower boundary must be lower than or equal to the upper boundary in the Range header.",
"hint":null
}|]
{ matchStatus = 416 }
it "refuses a range with nonzero start when there are no items" $ it "refuses a range with nonzero start when there are no items" $
request methodGet "/menagerie" request methodGet "/menagerie"
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) "" (rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
`shouldRespondWith` "[]" `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 1 was requested, but there are only 0 rows.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"] , matchHeaders = ["Content-Range" <:> "*/0"]
} }
@@ -356,7 +452,13 @@ spec = do
it "refuses a range requesting start past last item" $ it "refuses a range requesting start past last item" $
request methodGet "/items" request methodGet "/items"
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) "" (rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
`shouldRespondWith` "[]" `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 100 was requested, but there are only 15 rows.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"] , matchHeaders = ["Content-Range" <:> "*/15"]
} }
+54 -28
View File
@@ -120,17 +120,39 @@ spec actualPgVersion =
it "should fail with 404 on unknown proc name" $ it "should fail with 404 on unknown proc name" $
get "/rpc/fake" `shouldRespondWith` 404 get "/rpc/fake" `shouldRespondWith` 404
it "should fail with 404 and hint the closest proc on unknown proc name" $
get "/rpc/sayhell" `shouldRespondWith`
[json| {
"hint":"Perhaps you meant to call the function test.sayhello",
"message":"Could not find the function test.sayhell without parameters in the schema cache",
"code":"PGRST202",
"details":"Searched for the function test.sayhell without parameters, but no matches were found in the schema cache."} |]
{ matchStatus = 404
, matchHeaders = [matchContentTypeJson]
}
it "should fail with 404 on unknown proc args" $ do it "should fail with 404 on unknown proc args" $ do
get "/rpc/sayhello" `shouldRespondWith` 404 get "/rpc/sayhello" `shouldRespondWith` 404
get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404 get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
it "should fail with 404 and hint the closest args on unknown proc args" $
get "/rpc/sayhello?nam=Peter" `shouldRespondWith`
[json| {
"hint":"Perhaps you meant to call the function test.sayhello(name)",
"message":"Could not find the function test.sayhello(nam) in the schema cache",
"code":"PGRST202",
"details":"Searched for the function test.sayhello with parameter nam, but no matches were found in the schema cache."} |]
{ matchStatus = 404
, matchHeaders = [matchContentTypeJson]
}
it "should not ignore unknown args and fail with 404" $ it "should not ignore unknown args and fail with 404" $
get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith` get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`
[json| { [json| {
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.", "hint":"Perhaps you meant to call the function test.add_them(a, b)",
"message":"Could not find the test.add_them(a, b, smthelse) function in the schema cache", "message":"Could not find the function test.add_them(a, b, smthelse) in the schema cache",
"code":"PGRST202", "code":"PGRST202",
"details":null} |] "details":"Searched for the function test.add_them with parameters a, b, smthelse, but no matches were found in the schema cache."} |]
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
@@ -141,10 +163,10 @@ spec actualPgVersion =
[json|{}|] [json|{}|]
`shouldRespondWith` `shouldRespondWith`
[json| { [json| {
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.", "hint":null,
"message":"Could not find the test.sayhello function with a single json or jsonb parameter in the schema cache", "message":"Could not find the function test.sayhello in the schema cache",
"code":"PGRST202", "code":"PGRST202",
"details":null} |] "details":"Searched for the function test.sayhello with a single json/jsonb parameter, but no matches were found in the schema cache."} |]
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
@@ -152,19 +174,19 @@ spec actualPgVersion =
it "should fail with 404 for overloaded functions with unknown args" $ do it "should fail with 404 for overloaded functions with unknown args" $ do
get "/rpc/overloaded?wrong_arg=value" `shouldRespondWith` get "/rpc/overloaded?wrong_arg=value" `shouldRespondWith`
[json| { [json| {
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.", "hint":null,
"message":"Could not find the test.overloaded(wrong_arg) function in the schema cache", "message":"Could not find the function test.overloaded(wrong_arg) in the schema cache",
"code":"PGRST202", "code":"PGRST202",
"details":null} |] "details":"Searched for the function test.overloaded with parameter wrong_arg, but no matches were found in the schema cache."} |]
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
get "/rpc/overloaded?a=1&b=2&wrong_arg=value" `shouldRespondWith` get "/rpc/overloaded?a=1&b=2&wrong_arg=value" `shouldRespondWith`
[json| { [json| {
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.", "hint":"Perhaps you meant to call the function test.overloaded(a, b, c)",
"message":"Could not find the test.overloaded(a, b, wrong_arg) function in the schema cache", "message":"Could not find the function test.overloaded(a, b, wrong_arg) in the schema cache",
"code":"PGRST202", "code":"PGRST202",
"details":null} |] "details":"Searched for the function test.overloaded with parameters a, b, wrong_arg, but no matches were found in the schema cache."} |]
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
@@ -246,13 +268,17 @@ spec actualPgVersion =
`shouldRespondWith` `shouldRespondWith`
[json|{"id": 2, "articleStars": [{"userId": 3}]}|] [json|{"id": 2, "articleStars": [{"userId": 3}]}|]
it "can embed an M2M relationship table" $ it "can embed an M2M relationship table" $ do
get "/rpc/getallusers?select=name,tasks(name)&id=gt.1" get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"
`shouldRespondWith` [json|[ `shouldRespondWith` [json|[
{"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]}, {"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},
{"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]} {"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}
]|] ]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
-- https://github.com/PostgREST/postgrest/issues/2565
get "/rpc/get_yards?select=groups(*)"
`shouldRespondWith` [json|[]|]
{ matchHeaders = [matchContentTypeJson] }
it "can embed an M2M relationship table that has a parent relationship table" $ it "can embed an M2M relationship table that has a parent relationship table" $
get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1" get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"
@@ -1247,10 +1273,10 @@ spec actualPgVersion =
[json|{"x": 1, "y": 2}|] [json|{"x": 1, "y": 2}|]
`shouldRespondWith` `shouldRespondWith`
[json|{ [json|{
"hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.", "hint": "Perhaps you meant to call the function test.unnamed_text_param",
"message": "Could not find the test.unnamed_int_param(x, y) function or the test.unnamed_int_param function with a single unnamed json or jsonb parameter in the schema cache", "message": "Could not find the function test.unnamed_int_param(x, y) in the schema cache",
"code":"PGRST202", "code":"PGRST202",
"details":null "details":"Searched for the function test.unnamed_int_param with parameters x, y or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache."
}|] }|]
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [ matchContentTypeJson ] , matchHeaders = [ matchContentTypeJson ]
@@ -1262,10 +1288,10 @@ spec actualPgVersion =
[str|a simple text|] [str|a simple text|]
`shouldRespondWith` `shouldRespondWith`
[json|{ [json|{
"hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.", "hint": null,
"message": "Could not find the test.unnamed_int_param function with a single unnamed text parameter in the schema cache", "message": "Could not find the function test.unnamed_int_param in the schema cache",
"code":"PGRST202", "code":"PGRST202",
"details":null "details":"Searched for the function test.unnamed_int_param with a single unnamed text parameter, but no matches were found in the schema cache."
}|] }|]
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [ matchContentTypeJson ] , matchHeaders = [ matchContentTypeJson ]
@@ -1277,10 +1303,10 @@ spec actualPgVersion =
[str|a simple text|] [str|a simple text|]
`shouldRespondWith` `shouldRespondWith`
[json|{ [json|{
"hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.", "hint": null,
"message": "Could not find the test.unnamed_int_param function with a single unnamed xml parameter in the schema cache", "message": "Could not find the function test.unnamed_int_param in the schema cache",
"code":"PGRST202", "code":"PGRST202",
"details":null "details":"Searched for the function test.unnamed_int_param with a single unnamed xml parameter, but no matches were found in the schema cache."
}|] }|]
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [ matchContentTypeJson ] , matchHeaders = [ matchContentTypeJson ]
@@ -1293,10 +1319,10 @@ spec actualPgVersion =
file file
`shouldRespondWith` `shouldRespondWith`
[json|{ [json|{
"hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.", "hint": null,
"message": "Could not find the test.unnamed_int_param function with a single unnamed bytea parameter in the schema cache", "message": "Could not find the function test.unnamed_int_param in the schema cache",
"code":"PGRST202", "code":"PGRST202",
"details":null "details":"Searched for the function test.unnamed_int_param with a single unnamed bytea parameter, but no matches were found in the schema cache."
}|] }|]
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [ matchContentTypeJson ] , matchHeaders = [ matchContentTypeJson ]
@@ -1353,10 +1379,10 @@ spec actualPgVersion =
"a,b\n1,2\n4,6\n100,200" "a,b\n1,2\n4,6\n100,200"
`shouldRespondWith` `shouldRespondWith`
[json| { [json| {
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.", "hint":"Perhaps you meant to call the function test.overloaded_unnamed_param(x, y)",
"message":"Could not find the test.overloaded_unnamed_param(a, b) function in the schema cache", "message":"Could not find the function test.overloaded_unnamed_param(a, b) in the schema cache",
"code":"PGRST202", "code":"PGRST202",
"details":null "details":"Searched for the function test.overloaded_unnamed_param with parameters a, b, but no matches were found in the schema cache."
}|] }|]
{ matchStatus = 404 { matchStatus = 404
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
+9 -9
View File
@@ -28,12 +28,12 @@ spec = do
`shouldRespondWith` 404 `shouldRespondWith` 404
context "on an empty table" $ context "on an empty table" $
it "indicates no records found to update by returning 404" $ it "succeeds with status code 204" $
request methodPatch "/empty_table" [] request methodPatch "/empty_table" []
[json| { "extra":20 } |] [json| { "extra":20 } |]
`shouldRespondWith` `shouldRespondWith`
"" ""
{ matchStatus = 404, { matchStatus = 204,
matchHeaders = [matchHeaderAbsent hContentType] matchHeaders = [matchHeaderAbsent hContentType]
} }
@@ -71,14 +71,14 @@ spec = do
[("Prefer", "return=representation")] [json| { "id":999999 } |] [("Prefer", "return=representation")] [json| { "id":999999 } |]
`shouldRespondWith` "[]" `shouldRespondWith` "[]"
{ {
matchStatus = 404, matchStatus = 200,
matchHeaders = [] matchHeaders = []
} }
it "gives a 404 when no rows updated" $ it "returns status code 200 when no rows updated" $
request methodPatch "/items?id=eq.99999999" [] request methodPatch "/items?id=eq.99999999" []
[json| { "id": 42 } |] [json| { "id": 42 } |]
`shouldRespondWith` 404 `shouldRespondWith` 204
it "returns updated object as array when return=rep" $ it "returns updated object as array when return=rep" $
request methodPatch "/items?id=eq.2" request methodPatch "/items?id=eq.2"
@@ -137,13 +137,13 @@ spec = do
matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"] matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
} }
it "indicates no records updated by returning 404" $ it "returns empty array when no rows updated and return=rep" $
request methodPatch request methodPatch
"/items?always_true=eq.false" "/items?always_true=eq.false"
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
[json| { id: 100 } |] [json| { id: 100 } |]
`shouldRespondWith` "[]" `shouldRespondWith` "[]"
{ matchStatus = 404, { matchStatus = 200,
matchHeaders = [] matchHeaders = []
} }
@@ -304,9 +304,9 @@ spec = do
`shouldRespondWith` `shouldRespondWith`
[json|[{"id": 1, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|] [json|[{"id": 1, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
it "ignores json keys and gives 404 if no record updated" $ it "ignores json keys and gives 200 if no record updated" $
request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")] request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404 [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 200
context "tables with self reference foreign keys" $ do context "tables with self reference foreign keys" $ do
it "embeds children after update" $ it "embeds children after update" $
+14 -14
View File
@@ -12,7 +12,7 @@ import Test.Hspec
import PostgREST.App (postgrest) import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..), LogLevel (..)) import PostgREST.Config (AppConfig (..), LogLevel (..))
import PostgREST.Config.Database (queryPgVersion) import PostgREST.Config.Database (queryPgVersion)
import PostgREST.DbStructure (queryDbStructure) import PostgREST.SchemaCache (querySchemaCache)
import Protolude hiding (toList, toS) import Protolude hiding (toList, toS)
import Protolude.Conv (toS) import Protolude.Conv (toS)
import SpecHelper import SpecHelper
@@ -64,36 +64,36 @@ import qualified Feature.RpcPreRequestGucsSpec
main :: IO () main :: IO ()
main = do main = do
pool <- P.acquire (3, 10, toUtf8 $ configDbUri testCfg) pool <- P.acquire 3 Nothing $ toUtf8 $ configDbUri testCfg
actualPgVersion <- either (panic . show) id <$> P.use pool queryPgVersion actualPgVersion <- either (panic . show) id <$> P.use pool queryPgVersion
baseDbStructure <- baseSchemaCache <-
loadDbStructure pool loadSchemaCache pool
(configDbSchemas testCfg) (configDbSchemas testCfg)
(configDbExtraSearchPath testCfg) (configDbExtraSearchPath testCfg)
let let
-- For tests that run with the same refDbStructure -- For tests that run with the same refSchemaCache
app config = do app config = do
appState <- AppState.initWithPool pool config appState <- AppState.initWithPool pool config
AppState.putPgVersion appState actualPgVersion AppState.putPgVersion appState actualPgVersion
AppState.putDbStructure appState (Just baseDbStructure) AppState.putSchemaCache appState (Just baseSchemaCache)
when (isJust $ configDbRootSpec config) $ when (isJust $ configDbRootSpec config) $
AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure AppState.putJsonDbS appState $ toS $ JSON.encode baseSchemaCache
return ((), postgrest LogCrit appState $ pure ()) return ((), postgrest LogCrit appState $ pure ())
-- For tests that run with a different DbStructure(depends on configSchemas) -- For tests that run with a different SchemaCache(depends on configSchemas)
appDbs config = do appDbs config = do
customDbStructure <- customSchemaCache <-
loadDbStructure pool loadSchemaCache pool
(configDbSchemas config) (configDbSchemas config)
(configDbExtraSearchPath config) (configDbExtraSearchPath config)
appState <- AppState.initWithPool pool config appState <- AppState.initWithPool pool config
AppState.putPgVersion appState actualPgVersion AppState.putPgVersion appState actualPgVersion
AppState.putDbStructure appState (Just customDbStructure) AppState.putSchemaCache appState (Just customSchemaCache)
when (isJust $ configDbRootSpec config) $ when (isJust $ configDbRootSpec config) $
AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure AppState.putJsonDbS appState $ toS $ JSON.encode baseSchemaCache
return ((), postgrest LogCrit appState $ pure ()) return ((), postgrest LogCrit appState $ pure ())
let withApp = app testCfg let withApp = app testCfg
@@ -259,5 +259,5 @@ main = do
describe "Feature.RollbackForcedSpec" Feature.RollbackSpec.forced describe "Feature.RollbackForcedSpec" Feature.RollbackSpec.forced
where where
loadDbStructure pool schemas extraSearchPath = loadSchemaCache pool schemas extraSearchPath =
either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ queryDbStructure (toList schemas) extraSearchPath True) either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ querySchemaCache (toList schemas) extraSearchPath True)
+10 -10
View File
@@ -14,23 +14,23 @@ import Text.Heredoc
import Protolude hiding (get, toS) import Protolude hiding (get, toS)
import PostgREST.Query.QueryBuilder (requestToCallProcQuery) import PostgREST.Plan.CallPlan
import PostgREST.Request.Types import PostgREST.Query.QueryBuilder (callPlanToQuery)
import PostgREST.DbStructure.Identifiers import PostgREST.SchemaCache.Identifiers
import PostgREST.DbStructure.Proc import PostgREST.SchemaCache.Proc
import Test.Hspec import Test.Hspec
main :: IO () main :: IO ()
main = do main = do
pool <- P.acquire (3, 10, "postgresql://") pool <- P.acquire 3 Nothing "postgresql://"
hspec $ describe "QueryCost" $ hspec $ describe "QueryCost" $
context "call proc query" $ do context "call proc query" $ do
it "should not exceed cost when calling setof composite proc" $ do it "should not exceed cost when calling setof composite proc" $ do
cost <- exec pool $ cost <- exec pool $
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below") callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
(KeyParams [ProcParam "id" "int" True False]) (KeyParams [ProcParam "id" "int" True False])
(Just [str| {"id": 3} |]) False False []) (Just [str| {"id": 3} |]) False False [])
liftIO $ liftIO $
@@ -38,13 +38,13 @@ main = do
it "should not exceed cost when calling setof composite proc with empty params" $ do it "should not exceed cost when calling setof composite proc with empty params" $ do
cost <- exec pool $ cost <- exec pool $
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") (KeyParams []) Nothing False False []) callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") (KeyParams []) Nothing False False [])
liftIO $ liftIO $
cost `shouldSatisfy` (< Just 30) cost `shouldSatisfy` (< Just 30)
it "should not exceed cost when calling scalar proc" $ do it "should not exceed cost when calling scalar proc" $ do
cost <- exec pool $ cost <- exec pool $
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them") callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
(KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False]) (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
(Just [str| {"a": 3, "b": 4} |]) True False []) (Just [str| {"a": 3, "b": 4} |]) True False [])
liftIO $ liftIO $
@@ -53,7 +53,7 @@ main = do
context "params=multiple-objects" $ do context "params=multiple-objects" $ do
it "should not exceed cost when calling setof composite proc" $ do it "should not exceed cost when calling setof composite proc" $ do
cost <- exec pool $ cost <- exec pool $
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below") callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
(KeyParams [ProcParam "id" "int" True False]) (KeyParams [ProcParam "id" "int" True False])
(Just [str| [{"id": 1}, {"id": 4}] |]) False True []) (Just [str| [{"id": 1}, {"id": 4}] |]) False True [])
liftIO $ do liftIO $ do
@@ -63,7 +63,7 @@ main = do
it "should not exceed cost when calling scalar proc" $ do it "should not exceed cost when calling scalar proc" $ do
cost <- exec pool $ cost <- exec pool $
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them") callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
(KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False]) (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
(Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False []) (Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False [])
liftIO $ liftIO $
+2 -2
View File
@@ -26,8 +26,8 @@ import PostgREST.Config (AppConfig (..),
LogLevel (..), LogLevel (..),
OpenAPIMode (..), OpenAPIMode (..),
parseSecret) parseSecret)
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
import PostgREST.MediaType (MediaType (..)) import PostgREST.MediaType (MediaType (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import Protolude hiding (get, toS) import Protolude hiding (get, toS)
import Protolude.Conv (toS) import Protolude.Conv (toS)
@@ -79,7 +79,7 @@ baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in
, configDbMaxRows = Nothing , configDbMaxRows = Nothing
, configDbPlanEnabled = False , configDbPlanEnabled = False
, configDbPoolSize = 10 , configDbPoolSize = 10
, configDbPoolTimeout = 10 , configDbPoolAcquisitionTimeout = Nothing
, configDbPreRequest = Just $ QualifiedIdentifier "test" "switch_role" , configDbPreRequest = Just $ QualifiedIdentifier "test" "switch_role"
, configDbPreparedStatements = True , configDbPreparedStatements = True
, configDbRootSpec = Nothing , configDbRootSpec = Nothing
+3 -1
View File
@@ -792,8 +792,10 @@ INSERT INTO shop_bles(id, name, coords, shop_id, range_area) VALUES(1, 'Beacon-1
INSERT INTO shop_bles(id, name, coords, shop_id, range_area) VALUES(2, 'Beacon-2', 'SRID=4326;POINT(-71.10044 42.373695)', 1, INSERT INTO shop_bles(id, name, coords, shop_id, range_area) VALUES(2, 'Beacon-2', 'SRID=4326;POINT(-71.10044 42.373695)', 1,
extensions.ST_GeomFromGeoJSON('{"type": "Polygon", "coordinates": [ [ [ -71.10034391283989, 42.37385299961788 ], [ -71.10036939382553, 42.373756895982865 ], [ -71.1002916097641, 42.373745997623224 ], [ -71.1002641171217, 42.37384408279195 ], [ -71.10034391283989, 42.37385299961788 ] ] ]}')); extensions.ST_GeomFromGeoJSON('{"type": "Polygon", "coordinates": [ [ [ -71.10034391283989, 42.37385299961788 ], [ -71.10036939382553, 42.373756895982865 ], [ -71.1002916097641, 42.373745997623224 ], [ -71.1002641171217, 42.37384408279195 ], [ -71.10034391283989, 42.37385299961788 ] ] ]}'));
TRUNCATE TABLE "SPECIAL ""@/\#~_-".languages CASCADE;
INSERT INTO "SPECIAL ""@/\#~_-".languages (id, name) VALUES (1, 'English'), (2, 'Spanish');
TRUNCATE TABLE "SPECIAL ""@/\#~_-".names CASCADE; TRUNCATE TABLE "SPECIAL ""@/\#~_-".names CASCADE;
INSERT INTO "SPECIAL ""@/\#~_-".names (id, name) VALUES (1, 'John'), (2, 'Mary'); INSERT INTO "SPECIAL ""@/\#~_-".names (id, name, language_id) VALUES (1, 'John', 1), (2, 'Mary', 1), (3, 'José', 2);
TRUNCATE TABLE do$llar$s CASCADE; TRUNCATE TABLE do$llar$s CASCADE;
INSERT INTO do$llar$s (a$num$) VALUES (100), (200), (300); INSERT INTO do$llar$s (a$num$) VALUES (100), (200), (300);
+18 -225
View File
@@ -1,216 +1,35 @@
-- Privileges for anonymous -- Privileges for anonymous
GRANT USAGE ON SCHEMA GRANT USAGE ON SCHEMA
postgrest "EXTRA ""@/\#~_-"
, test , "SPECIAL ""@/\#~_-"
, jwt
, public
, "تست" , "تست"
, extensions , extensions
, jwt
, postgrest
, public
, test
, v1 , v1
, v2 , v2
, "SPECIAL ""@/\#~_-"
, "EXTRA ""@/\#~_-"
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
-- Schema test objects -- Schema test objects
SET search_path = test, "تست", pg_catalog; SET search_path = test, "تست", pg_catalog;
GRANT ALL ON TABLE GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA
items "SPECIAL ""@/\#~_-"
, items2 , "تست"
, items3
, "articleStars"
, articles
, arrays
, auto_incrementing_pk
, clients
, comments
, complex_items
, compound_pk
, compound_pk_view
, deferrable_unique_constraint
, empty_table
, fav_numbers
, has_count_column
, has_fk
, insertable_view_with_join
, json_table
, materialized_view
, menagerie
, no_pk
, nullable_integer
, projects
, projects_view
, projects_view_alt
, test_null_pk_competitors_sponsors
, simple_pk
, simple_pk2
, tasks
, filtered_tasks
, tsearch
, users
, users_projects
, users_tasks
, files
, touched_files
, "Escap3e;"
, "ghostBusters"
, "withUnique"
, "clashing_column"
, "موارد"
, addresses
, orders
, public.public_consumers
, public.public_orders
, consumers_view
, consumers_view_view
, consumers_extra_view
, orders_view
, images
, images_base64
, w_or_wo_comma_names
, items_with_different_col_types
, entities
, child_entities
, grandchild_entities
, ranges
, being
, descendant
, being_part
, part
, leak
, perf_articles
, employees
, tiobe_pls
, single_unique
, compound_unique
, only_pk
, family_tree
, managers
, organizations
, authors
, publishers
, books
, forties_books
, fifties_books
, sixties_books
, person
, message
, person_detail
, space
, zone
, projects_dump
, "UnitTest"
, json_arr
, jsonb_test
, authors_books_number
, authors_have_book_in_decade
, authors_have_book_in_decade2
, forties_and_fifties_books
, odd_years_publications
, foos
, bars
, materialized_projects
, contract
, player_view
, contract_view
, ltree_sample
, isn_sample
, projects_count_grouped_by
, "Server Today"
, pgrst_reserved_chars
, authors_w_entities
, openapi_types
, openapi_defaults
, getallprojects_view
, get_projects_above_view
, web_content
, pages
, referrals
, big_projects
, sites
, jobs
, main_jobs
, whatev_projects
, whatev_sites
, whatev_jobs
, agents
, departments
, schedules
, activities
, unit_workdays
, unit_workdays_fst_shift
, stuff
, loc_test
, v1.parents
, v2.parents
, v2.another_table
, v1.children
, v2.children
, screens
, labels
, label_screen
, actors
, films
, personnages
, end_1
, end_2
, schauspieler
, filme
, rollen
, products
, suppliers
, products_suppliers
, trade_unions
, suppliers_trade_unions
, client
, clientinfo
, contact
, chores
, limited_update_items
, limited_update_items_cpk
, limited_update_items_no_pk
, limited_update_items_view
, limited_update_items_wnonuniq_view
, limited_delete_items
, limited_delete_items_cpk
, limited_delete_items_no_pk
, limited_delete_items_view
, plate
, well
, limited_delete_items_wnonuniq_view
, limited_delete_items_cpk_view
, limited_update_items_cpk_view
, xmltest
, oid_test
, job
, series
, adaptation_notifications
, series_popularity
, test , test
, view_test , v1
, shops , v2
, shop_bles
, "SPECIAL ""@/\#~_-".names
, do$llar$s
, safe_update_items
, safe_delete_items
, unsafe_update_items
, unsafe_delete_items
, videogames
, designers
, students
, students_info
, students_view
, students_info_view
, country
, capital
, first
, second
, first_1
, second_1
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
REVOKE ALL PRIVILEGES ON TABLE
app_users
, authors_only
, insertonly
, limited_article_stars
FROM postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
GRANT USAGE ON SEQUENCE GRANT USAGE ON SEQUENCE
@@ -238,29 +57,3 @@ REVOKE EXECUTE ON FUNCTION privileged_hello(text) FROM PUBLIC; -- All functions
GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author; GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author;
GRANT USAGE ON SCHEMA test TO postgrest_test_default_role; GRANT USAGE ON SCHEMA test TO postgrest_test_default_role;
DO $do$BEGIN
IF (SELECT current_setting('server_version_num')::INT >= 100000) THEN
GRANT ALL ON TABLE test.car_models TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_models_2021 TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_models_default TO postgrest_test_anonymous;
END IF;
IF (SELECT current_setting('server_version_num')::INT >= 110000) THEN
GRANT ALL ON TABLE test.car_brands TO postgrest_test_anonymous;
END IF;
IF (SELECT current_setting('server_version_num')::INT >= 120000) THEN
GRANT ALL ON TABLE test.car_model_sales TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_model_sales_202101 TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_model_sales_default TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_racers TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_dealers TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_dealers_springfield TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_dealers_default TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_models_car_dealers TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_models_car_dealers_10to20 TO postgrest_test_anonymous;
GRANT ALL ON TABLE test.car_models_car_dealers_default TO postgrest_test_anonymous;
END IF;
END$do$;
+265 -5
View File
@@ -272,6 +272,56 @@ $_$An RPC function
Just a test for RPC function arguments$_$; Just a test for RPC function arguments$_$;
CREATE FUNCTION varied_arguments_openapi(
double double precision,
"varchar" character varying,
"boolean" boolean,
date date,
money money,
enum enum_menagerie_type,
text_arr text[],
int_arr int[],
bool_arr boolean[],
char_arr char[],
varchar_arr varchar[],
bigint_arr bigint[],
numeric_arr numeric[],
json_arr json[],
jsonb_arr jsonb[],
"integer" integer default 42,
json json default '{}',
jsonb jsonb default '{}'
) RETURNS json
LANGUAGE sql
IMMUTABLE
AS $_$
SELECT json_build_object(
'double', double,
'varchar', "varchar",
'boolean', "boolean",
'date', date,
'money', money,
'enum', enum,
'text_arr', text_arr,
'int_arr', int_arr,
'bool_arr', bool_arr,
'char_arr', char_arr,
'varchar_arr', varchar_arr,
'bigint_arr', bigint_arr,
'numeric_arr', numeric_arr,
'json_arr', json_arr,
'jsonb_arr', jsonb_arr,
'integer', "integer",
'json', json,
'jsonb', jsonb
);
$_$;
COMMENT ON FUNCTION varied_arguments_openapi(double precision, character varying, boolean, date, money, enum_menagerie_type, text[], int[], boolean[], char[], varchar[], bigint[], numeric[], json[], jsonb[], integer, json, jsonb) IS
$_$An RPC function
Just a test for RPC function arguments$_$;
CREATE FUNCTION json_argument(arg json) RETURNS text CREATE FUNCTION json_argument(arg json) RETURNS text
LANGUAGE sql LANGUAGE sql
@@ -1153,6 +1203,8 @@ create table child_entities (
parent_id integer references entities(id) parent_id integer references entities(id)
); );
create view child_entities_view as table child_entities;
create table grandchild_entities ( create table grandchild_entities (
id integer primary key, id integer primary key,
name text, name text,
@@ -1176,6 +1228,10 @@ comment on table child_entities is 'child_entities comment';
comment on column child_entities.id is 'child_entities id comment'; comment on column child_entities.id is 'child_entities id comment';
comment on column child_entities.name is 'child_entities name comment. Can be longer than sixty-three characters long'; comment on column child_entities.name is 'child_entities name comment. Can be longer than sixty-three characters long';
comment on view child_entities_view is 'child_entities_view comment';
comment on column child_entities_view.id is 'child_entities_view id comment';
comment on column child_entities_view.name is 'child_entities_view name comment. Can be longer than sixty-three characters long';
comment on table grandchild_entities is comment on table grandchild_entities is
$$grandchild_entities summary $$grandchild_entities summary
@@ -1808,7 +1864,16 @@ CREATE TABLE test.openapi_types(
"a_real" real, "a_real" real,
"a_double_precision" double precision, "a_double_precision" double precision,
"a_json" json, "a_json" json,
"a_jsonb" jsonb "a_jsonb" jsonb,
"a_text_arr" text[],
"a_int_arr" int[],
"a_bool_arr" boolean[],
"a_char_arr" char[],
"a_varchar_arr" varchar[],
"a_bigint_arr" bigint[],
"a_numeric_arr" numeric[],
"a_json_arr" json[],
"a_jsonb_arr" jsonb[]
); );
CREATE TABLE test.openapi_defaults( CREATE TABLE test.openapi_defaults(
@@ -2478,9 +2543,6 @@ CREATE AGGREGATE test.unsupported_agg (*) (
STYPE = int STYPE = int
); );
create view no_pk_view as
select * from no_pk;
create table limited_update_items( create table limited_update_items(
id int primary key id int primary key
, name text , name text
@@ -2660,11 +2722,21 @@ create function get_shop(id int) returns shops as $$
select * from shops where id = $1; select * from shops where id = $1;
$$ language sql; $$ language sql;
CREATE TABLE "SPECIAL ""@/\#~_-".names( CREATE TABLE "SPECIAL ""@/\#~_-".languages(
id INT PRIMARY KEY, id INT PRIMARY KEY,
name TEXT name TEXT
); );
CREATE TABLE "SPECIAL ""@/\#~_-".names(
id INT PRIMARY KEY,
name TEXT,
language_id INT REFERENCES "SPECIAL ""@/\#~_-".languages(id)
);
CREATE FUNCTION "SPECIAL ""@/\#~_-".computed_languages("SPECIAL ""@/\#~_-".names) RETURNS SETOF "SPECIAL ""@/\#~_-".languages ROWS 1 AS $$
SELECT * FROM "SPECIAL ""@/\#~_-".languages where id = $1.language_id;
$$ LANGUAGE sql;
CREATE FUNCTION "EXTRA ""@/\#~_-".get_val_special(val text) RETURNS text AS $$ CREATE FUNCTION "EXTRA ""@/\#~_-".get_val_special(val text) RETURNS text AS $$
SELECT val; SELECT val;
$$ LANGUAGE sql; $$ LANGUAGE sql;
@@ -2724,6 +2796,10 @@ CREATE FUNCTION test.computed_designers(test.videogames) RETURNS SETOF test.desi
SELECT * FROM test.designers WHERE id = $1.designer_id; SELECT * FROM test.designers WHERE id = $1.designer_id;
$$ LANGUAGE sql STABLE ROWS 1; $$ LANGUAGE sql STABLE ROWS 1;
CREATE FUNCTION test.computed_designers_noset(test.videogames) RETURNS test.designers AS $$
SELECT * FROM test.designers WHERE id = $1.designer_id;
$$ LANGUAGE sql STABLE;
CREATE FUNCTION test.computed_videogames(test.designers) RETURNS SETOF test.videogames AS $$ CREATE FUNCTION test.computed_videogames(test.designers) RETURNS SETOF test.videogames AS $$
SELECT * FROM test.videogames WHERE designer_id = $1.id; SELECT * FROM test.videogames WHERE designer_id = $1.id;
$$ LANGUAGE sql STABLE; $$ LANGUAGE sql STABLE;
@@ -2826,3 +2902,187 @@ $$ LANGUAGE sql STABLE ROWS 1;
CREATE FUNCTION test.first_1(test.second_1) RETURNS SETOF test.first_1 AS $$ CREATE FUNCTION test.first_1(test.second_1) RETURNS SETOF test.first_1 AS $$
SELECT * FROM test.first_1 WHERE second_id_1 = $1.id; SELECT * FROM test.first_1 WHERE second_id_1 = $1.id;
$$ LANGUAGE sql STABLE ROWS 1; $$ LANGUAGE sql STABLE ROWS 1;
create table fee (
fee_id int primary key
);
create table baz (
baz_id int primary key
);
create table janedoe (
janedoe_id int primary key,
baz_id int references baz(baz_id)
);
create table johnsmith (
johnsmith_id int primary key,
fee_id int references fee(fee_id),
baz_id int references baz(baz_id)
);
create or replace function jsbaz(fee) returns setof baz as $$
select b.*
from baz b
join johnsmith js on js.baz_id = b.baz_id
where js.fee_id = $1.fee_id
$$ stable language sql;
-- issue https://github.com/PostgREST/postgrest/issues/2518
create table a (
primary key (c1, c2),
c1 int,
c2 bool
);
create table b (
c2 bool,
c1 int,
foreign key (c1, c2) references a
);
create view test.v1 as table test.a;
create view test.v2 as table test.b;
-- issue https://github.com/PostgREST/postgrest/issues/2458
create table with_pk1 (pk1 int primary key);
create table with_pk2 (pk2 int primary key);
create view with_multiple_pks as
select * from with_pk1, with_pk2;
create function with_multiple_pks_insert() returns trigger
language plpgsql as $$
begin
insert into with_pk1 values (new.pk1) on conflict do nothing;
insert into with_pk2 values (new.pk2) on conflict do nothing;
return new;
end
$$;
create trigger ins instead of insert on with_multiple_pks
for each row execute procedure with_multiple_pks_insert();
-- issue https://github.com/PostgREST/postgrest/issues/2283
create view self_recursive_view as table projects;
create or replace view self_recursive_view as table self_recursive_view;
CREATE FUNCTION test.computed_clients(test.projects) RETURNS SETOF test.clients ROWS 1 AS $$
SELECT * FROM test.clients WHERE id = $1.client_id;
$$ LANGUAGE sql STABLE;
CREATE FUNCTION test.computed_projects(test.clients) RETURNS SETOF test.projects ROWS 1 AS $$
SELECT * FROM test.projects WHERE client_id = $1.id;
$$ LANGUAGE sql STABLE;
-- issue https://github.com/PostgREST/postgrest/issues/2459
create table public.i2459_simple_t1 (
id int primary key
);
create table public.i2459_simple_t2 (
t1_id int references public.i2459_simple_t1
);
create view i2459_simple_v1 as table public.i2459_simple_t1;
create view i2459_simple_v2 as
select t1_id as t1_id1, t1_id as t1_id2 from public.i2459_simple_t2;
create table public.i2459_composite_t1 (
primary key (a,b),
a int,
b int
);
create table public.i2459_composite_t2 (
t1_a int,
t1_b int,
constraint i2459_composite_t2_t1_a_t1_b_fkey foreign key (t1_a, t1_b) references public.i2459_composite_t1
);
create view i2459_composite_v1 as table public.i2459_composite_t1;
create view i2459_composite_v2 as
select t1_a as t1_a1,
t1_b as t1_b1,
t1_a as t1_a2,
t1_b as t1_b2
from public.i2459_composite_t2;
create table public.i2459_self_t (
id int primary key,
parent int references public.i2459_self_t,
type text
);
create view i2459_self_v1 as
select parent.parent as grandparent,
child.parent,
child.id
from public.i2459_self_t as parent
join public.i2459_self_t as child
on child.parent = parent.id
where child.type = 'A';
create view i2459_self_v2 as
select parent.parent as grandparent,
child.parent,
child.id
from public.i2459_self_t as parent
join public.i2459_self_t as child
on child.parent = parent.id
where child.type = 'B';
-- issue https://github.com/PostgREST/postgrest/issues/2548
CREATE TABLE public.ta (
a1 INT PRIMARY KEY,
a2 INT,
UNIQUE (a1, a2)
);
CREATE TABLE public.tb (
b1 INT REFERENCES public.ta (a1),
b2 INT,
FOREIGN KEY (b1, b2) REFERENCES public.ta (a1, a2)
);
CREATE VIEW test.va AS SELECT a1 FROM public.ta;
CREATE VIEW test.vb AS SELECT b1 FROM public.tb;
CREATE TABLE test.groups (
name text PRIMARY KEY
);
CREATE TABLE test.yards (
id bigint PRIMARY KEY
);
CREATE TABLE test.group_yard (
id bigint NOT NULL,
group_id text NOT NULL REFERENCES test.groups(name),
yard_id bigint NOT NULL REFERENCES test.yards(id),
PRIMARY KEY (id, group_id, yard_id)
);
CREATE FUNCTION test.get_yards() RETURNS SETOF test.yards
LANGUAGE sql
AS $$
select * from test.yards;
$$;
-- view's name is alphabetically before projects
create view test.alpha_projects as
select c.id, p.name as pro_name, c.name as cli_name
from projects p join clients c on p.client_id = c.id;
-- view's name is alphabetically after projects
create view test.zeta_projects as
select c.id, p.name as pro_name, c.name as cli_name
from projects p join clients c on p.client_id = c.id;