Compare commits

...
510 Commits
Author SHA1 Message Date
steve-chavez c820efb64a bump version to 11.2.0 2023-08-10 08:51:58 -05:00
steve-chavez e30bf53afa deprecate: target embedding disambiguation 2023-08-10 08:14:23 -05:00
steve-chavez 5ce020d5bc fix: impersonated role applying superuser settings 2023-08-09 20:05:22 -05:00
Taimoor ZaeemandGitHub fbf9bf21c2 feat: add optional nulls=stripped parameter for mediatypes applicatio… (#2894) 2023-08-09 18:49:22 -05:00
Taimoor ZaeemandSteve Chavez d490bf09fd feat: add Preference-Applied header in response for Prefer: return=representation/minimal/headers-only 2023-08-05 02:21:50 -05:00
Laurence IslaandGitHub aa53623aac fix: error when a function returns RECORD or SETOF RECORD 2023-08-03 20:09:55 -05:00
steve-chavez 0fce7ca361 refactor: Response rm Paths_postgrest dependency
This allows using doctests on the Response.hs module
2023-08-02 16:28:48 -05:00
Taimoor ZaeemandGitHub d0a71de2da chore: improve details field of the singular error response (#2876) 2023-08-01 19:37:54 -05:00
Laurence IslaandGitHub 40c2bcd4a1 fix: paramaters of type character and bit not ignoring length
- Fixes the error "value too long for type character(1)" when the char length of the parameter was bigger than one.
2023-07-31 18:36:37 -05:00
dependabot[bot]andGitHub 0dc67bed0b build(deps): bump LouisBrunner/checks-action from 1.6.1 to 1.6.2 (#2883) 2023-07-31 00:17:36 -05:00
steve-chavez 2d00d7d248 fix: null filtering on embed when column=relation 2023-07-27 22:43:13 -05:00
Taimoor ZaeemandGitHub 2977d09779 chore: maintenance to the Nix development and build guide 2023-07-26 20:28:59 -05:00
Laurence IslaandGitHub 630e0a1691 fix: character and bit columns with fixed length not inserting/updating properly
Fixes the error "value too long for type character(1)" when the char length of the column was bigger than one.
2023-07-21 11:30:01 -05:00
steve-chavez 28d5278d62 fix: use index on jsonb/jsonb arrow filter/order 2023-07-12 14:51:21 -05:00
steve-chavez e332f038ef fix: HEAD unnecessarily executing aggregates 2023-07-10 15:05:51 -05:00
steve-chavez 905fcb05cc refactor: add ResultAggregate type 2023-07-10 15:05:51 -05:00
Laurence IslaandGitHub 774d015eb5 feat: add the --version CLI option to print the version information 2023-07-07 18:19:02 -05:00
Laurence IslaandGitHub e752224f14 feat: show PostgREST version in the logs 2023-07-07 10:28:27 -05:00
steve-chavez add10bd10c test: doctest for addNullEmbedFilters 2023-07-06 16:52:46 -05:00
steve-chavez 52d3026133 refactor: remove unneeded FilterNullEmbed 2023-07-06 16:52:46 -05:00
Laurence IslaandGitHub 5cdafb23e7 ci: fix ARM docker containers not stopping immediately 2023-07-06 11:20:32 -05:00
steve-chavez 20b06efabe chore: show on ReadPlan and MediaType 2023-07-05 17:35:54 -05:00
Laurence IslaandGitHub 7508230760 feat: allow to verify the PostgREST version through SQL 2023-07-03 18:31:32 -05:00
steve-chavez a17dd41d6b fix: Prefer: missing=default with DOMAIN defaults 2023-06-30 17:46:16 -05:00
Alexander LjungbergandSteve Chavez 0a1564ba5a feat: data representations allow custom parsing and formatting of API fields.
See PR #2523. Most notable code changes:

- Load data representation casts into schema cache.
- Data representations for reads, filters, inserts, updates, views, over joins.
- `CoercibleField` represents name references in queries where coercion may be needed.
- `ResolverContext` help facilitate field resolution during planning.
- Planner 'resolves' names in the API query and pairs them with any implicit conversions to be used in the query builder stage.
- Tests for all of the above.
- More consistent naming (TypedX -> CoercibleX).

New: unit tests for more data representation use cases; helpful as examples as well.

New: update CHANGELOG with data representations feature description.

Fixed failing idempotence test.

New: replace date formatter test with one that does something.

Fixup: inadvertent CHANGELOG change after rebase.

Cleanup: `tfName` -> `cfName` and related.

Document what IRType means.

Formatting.

New: use a subquery to interpret `IN` literals requiring data rep transformation.

- With the previous method, very long queries such as `ANY (ARRAY[test.color('000100'), test.color('CAFE12'), test.color('01E240'), ...` could be generated. Consider the case where the parser function name is 45 characters and there's a hundred literals. That's 4.5kB of SQL just for the function name alone!
- New version uses `unnest`: `ANY (SELECT test.color(unnest('{000100,CAFE12,01E240,...}'::text[]))` to produce a much shorter query.
- This is likely to be more performant and either way much more readable and debuggable in the logs.
2023-06-29 15:01:58 -05:00
steve-chavez 078c6ec08c refactor: remove SqlFragment and use SQL.Snippet 2023-06-22 21:46:16 -05:00
Laurence IslaandGitHub 83cf15fb7e ci: specify GHC_VERSION for ARM build on CI config
* change cabal version from recommended to latest
2023-06-21 16:54:15 -05:00
steve-chavez 40dc46ed2e fix: compilation on Ubuntu, GHC 9.0.2 compat 2023-06-21 16:22:30 -05:00
steve-chavez d9261fa674 ci: check against GHC 9.0.2 2023-06-21 16:22:30 -05:00
steve-chavez 856d450775 refactor: stricter plan media type 2023-06-20 19:12:17 -05:00
Laurence IslaandGitHub c1a8661ab3 ci: Update dependabot to look for dependencies in nested folder
- Bump cachix/install-nix-action from 21 to 22

- Add actions/setup-nix to dependabot dependencies
2023-06-19 12:33:42 -05:00
steve-chavez aa15f4782e refactor: remove MTPlanAttrs to doctest 2023-06-18 15:27:25 -05:00
steve-chavez 77cd9387d4 Revert "refactor: plan media type"
This reverts commit 3cd3a3f8c6.
2023-06-18 15:27:25 -05:00
steve-chavez 3cd3a3f8c6 refactor: plan media type 2023-06-17 23:00:32 -05:00
steve-chavez 78821a8fe7 refactor: clarify content negotiation 2023-06-16 17:36:44 -05:00
steve-chavez 5c372df487 refactor: asJsonF/asJsonSingleF Routine param 2023-06-16 17:36:44 -05:00
steve-chavez fad47324c3 fix: OPTIONS not accepting all available media 2023-06-13 22:00:21 -05:00
dependabot[bot]andGitHub 11a9849152 build(deps): bump LouisBrunner/checks-action from 1.6.0 to 1.6.1 (#2817) 2023-06-12 10:34:48 -05:00
steve-chavez 1f13e43abe bump version to 11.1.0 2023-06-07 19:03:31 -05:00
steve-chavez fac797c766 changelog: clarify 2781 2023-06-07 18:28:01 -05:00
steve-chavez 07cb0b582e feat: recover on pg_terminate_backend 2023-06-06 14:41:37 -05:00
steve-chavez d54a2f48de refactor: move checkIsFatal to AppState 2023-06-06 14:41:37 -05:00
steve-chavez bcce7b1c53 fix: stop retrying on "no password supplied" 2023-06-06 14:41:37 -05:00
steve-chavez 8d1961ce07 refactor: cache the isolation level 2023-06-06 14:41:37 -05:00
steve-chavez 9a19dff83e refactor: move debounceLogAcquisitionTimeout
Move it to AppState
2023-06-06 14:41:37 -05:00
steve-chavez 54b9a0b8b3 test: fix hello function for pg 9.6 2023-06-02 17:45:25 -05:00
steve-chavez 9a3d453bf4 test: remove big_schema.sql from io tests
It was unused(test skipped) and made the scache reload slower, causing
test failure on CI.
2023-06-02 17:45:25 -05:00
steve-chavez 4f6c466031 nix: postgrest-run uses similar config to tests
Otherwise manual tests can be deceiving
2023-06-02 17:45:25 -05:00
Steve ChavezandGitHub a852b766eb Fix dropping schema cache reload notifications
* test: bad schema reload
* refactor: DRY using the "extra" lib
* refactor: move worker funtions inside AppState
* Also rename Workers.hs to Admin.hs
2023-06-02 12:22:17 -05:00
steve-chavez 14be3fb671 feat: add pre-config function
Allows using the in-database configuration without SUPERUSER
2023-05-28 10:21:13 -05:00
steve-chavez 8a3686d86b refactor: whitelist in-db config settings 2023-05-28 10:21:13 -05:00
steve-chavez 009250006e feat: add db-pool-timeout alias
For db-pool-max-idletime
2023-05-22 18:34:18 -03:00
steve-chavez 38ad8c04e1 nix: withTmpDb set search_path to test
It gets repetitive to set the search_path every time you go in the test
db with psql.
2023-05-22 13:46:24 -03:00
dependabot[bot]andGitHub 54cbf147e3 build(deps): bump codecov/codecov-action from 3.1.3 to 3.1.4 (#2794) 2023-05-22 11:34:18 -05:00
RobertandGitHub f9f0f79fa9 feat: use hasql-pool-0.10, add db-pool-max-idletime (#2786)
- new option db-pool-max-idletime limits the maximal idle time of a
  postgresql connection, defaults to 30 seconds
- this essentially restores the db-pool-timeout option from versions
  <=10, with more specific name
2023-05-16 23:00:40 +02:00
steve-chavez a867d79c42 nix: withTmpDb psql notice can't mutate the db
Use the postgres role instead of the authenticator on the notice
2023-05-05 20:03:09 -03:00
steve-chavez 4197d2f739 bump version to 11.0.1 2023-04-27 21:30:40 -05:00
steve-chavez c10ba8e214 fix: skew of 30 seconds for JWT validation 2023-04-27 21:06:06 -05:00
steve-chavez 887948d259 fix: missing=default error msg on generated column 2023-04-27 19:27:09 -05:00
steve-chavez c63786733a fix: no schema privilege for schema cache
This was due to the usage of pg_get_serial_sequence
2023-04-27 19:27:09 -05:00
steve-chavez 4fe696dd96 nix: PGRST_DB_ANON_ROLE default for postgrest-run
Makes manually testing a feature easier
2023-04-27 19:27:09 -05:00
steve-chavez 67936b343f test: prove that authenticator is not a superuser 2023-04-27 19:27:09 -05:00
steve-chavez b0e395f495 nix: no SUPERUSER for connection role
Change :USER to :PGUSER in SQL scripts
2023-04-27 19:27:09 -05:00
dependabot[bot]andGitHub 3b55a27ef3 build(deps): bump codecov/codecov-action from 3.1.2 to 3.1.3 (#2765) 2023-04-24 11:21:49 -05:00
dependabot[bot]andGitHub 43da81c30c build(deps): bump codecov/codecov-action from 3.1.1 to 3.1.2 (#2757) 2023-04-18 12:25:50 -05:00
steve-chavez dd2f5511d8 bump version to 11.0.0 2023-04-16 14:22:38 -05:00
steve-chavez 49c349c846 chore: CHANGELOG to include release 10.2.0 2023-04-16 12:18:10 -05:00
steve-chavez aaf77902f6 feat: isolation level for roles/functions 2023-04-15 18:05:04 -05:00
steve-chavez 4c555cbd5d refactor: authRole to ByteString 2023-04-15 18:05:04 -05:00
steve-chavez 3e53796120 correct missing=default with GENERATED BY column 2023-04-14 22:12:34 -05:00
Laurence IslaandGitHub 0a2b7064c7 fix: PATCH requests not recognizing embedded filters
- Already fixed in #2618
- Adds tests and CHANGELOG
2023-04-14 16:42:55 -05:00
steve-chavez ce378e6b3a refactor: clarify Scalar type
It now contains the type of the scalar. This way we can discriminate the
void type in a more obvious way.
2023-04-13 18:17:43 -05:00
steve-chavez feadf59bb3 refactor: rename Proc module to Routine
Assuming that functions=procedures is wrong since pg11, which introduced
real stored procedures.
2023-04-13 18:17:43 -05:00
Laurence IslaandGitHub ad7d80a430 ci: clean files from arm server only on build failure/cancel for a version release 2023-04-13 16:29:26 -05:00
steve-chavez e572d1d1a2 feat: configurable role settings 2023-04-10 14:27:08 -05:00
steve-chavez c06237cc56 fix: db settings and pg version query not prepared 2023-04-10 14:27:08 -05:00
steve-chavez c656a870f4 bump version to 10.2.0.20230407 2023-04-07 14:02:14 -05:00
steve-chavez 1b625cb77a feat: any/all modifiers for operators
Only for the eq,like,ilike,gt,gte,lt,lte,match,imatch operators
2023-04-07 13:10:57 -05:00
Robert Vollmert 394bd22148 feat: use hasql-pool-0.9, add db-pool-max-lifetime (fixes #2638)
- db-pool-acquisition-timeout is no longer optional, defaults to 10s
- new option db-pool-max-lifetime limits the maximal lifetime of a
  postgresql connection, defaults to 30m
2023-04-06 22:40:22 +02:00
Robert Vollmert 963416ae29 test: stabilize log probe in test_pool_acquisition_timeout
There's two lines of log output, and their order is not deterministic.
2023-04-06 22:40:22 +02:00
Laurence IslaandGitHub d3b10e7b2a feat: Allow filtering by IS DISTINCT FROM using the isdistinct operator 2023-04-06 13:02:03 -05:00
Steve ChavezandGitHub acf62320ef RPC returning table alias now works for pg 11/12 (#2737)
The new LATERAL query used for calling the function, introduced on
https://github.com/PostgREST/postgrest/pull/2677, failed on functions
that returned a domain like `CREATE DOMAIN projects_domain AS projects`.

Work around that by changing the query conditionally, by
obtaining a bool that represents the composite alias on the SchemaCache
and only do this on pg 11 and 12.
2023-04-05 15:34:41 -05:00
steve-chavez 16f2849724 refactor: wrap proc logic in asJsonF
* add explicit logic for returning setof scalars
2023-04-04 19:55:10 -05:00
steve-chavez d945e8c06a refactor: remove Maybe from RetType for Proc type 2023-04-04 19:55:10 -05:00
steve-chavez bc1fb67df0 drop: Prefer: params=multiple-objects on RPC
BREAKING CHANGE

A function with a JSON array or object parameter should be used instead
2023-04-04 19:55:10 -05:00
Wolfgang Walther 1442e02f5f chore: Allow seed-cachix job to keep running when part of the matrix fail 2023-04-04 18:52:52 +02:00
Wolfgang WaltherandWolfgang Walther 68d2d834ba nix: Update nixpkgs to 2023-03-25 2023-04-04 18:51:34 +02:00
Steve ChavezandGitHub eb777cf823 Update BACKERS.md 2023-04-03 11:09:19 -05:00
Laurence IslaandGitHub 1aed55be68 Ignore the Range header when the method is different than GET
fix: bug when using Range header on PATCH/DELETE
  - Fix the "message": "syntax error at or near \"RETURNING\"" error
  - Fix doing a limited update/delete when an order query parameter was present

breaking: The Range header is now only considered on GET requests and is ignored for any other method
  - Other methods should use the `limit/offset` query parameters for sub-ranges
  - PUT requests no longer return an error when this header is present
2023-04-03 10:40:25 -05:00
steve-chavez 032f07f3f0 refactor: remove character varying casting 2023-03-29 13:17:15 -05:00
steve-chavez 7629eff51d undefined-keys=apply-defaults to missing=default 2023-03-29 05:33:38 -05:00
steve-chavez 46fd856fc6 test: tidy inlining plan test 2023-03-29 05:33:38 -05:00
Laurence IslaandGitHub caaa9a3944 ci: Update ARM build script to use GHC 9.2.4 2023-03-24 20:23:43 -05:00
Steve ChavezandGitHub 439a96c578 feat: undefined json keys as defaults w/ Prefer:undefined-keys 2023-03-22 02:44:14 -05:00
Robert Vollmert e731241b97 test: optionally build postgrest with cabal in postgrest-loadtest
By default, postgrest-with-pgrst builds postgrest as a nix package,
which means that source changes cause a full rebuild. With this
change, running the loadtest as

PGRST_BUILD_CABAL=1 postgrest-loadtest

rebuilds directly using cabal, like postgrest-build. Note that
results between nix and cabal builds aren't necessarily comparable
due to differing build parameters.
2023-03-20 15:00:11 +01:00
Robert Vollmert 216dc833fd test: add optional latency to postgrest-loadtest
This pipes data between client/postgrest and between
postgrest/database through a proxy that adds delay
(github.com/robx/slocat).
2023-03-20 15:00:11 +01:00
Robert Vollmert a8e02f766b perf: Allow preparing pre-request SQL statement
This will prepare the pre-request statement (if prepared statements
are enabled) with a possible minor performance benefit -- the load
test is not entirely conclusive.
2023-03-20 13:40:43 +01:00
steve-chavez cae1c67b00 refactor: add iPreferences to ApiRequest
It avoids adding a new iPrefer.. to the ApiRequest every time a
preference is added.
2023-03-09 14:40:32 -05:00
Steve ChavezandGitHub b05ea14122 nix: add notices for postgrest-with-* commands (#2697)
For knowing where to connect and how to get logs
2023-03-07 12:42:41 -05:00
Steve ChavezandGitHub 666114f81d refactor: remove SchemaCache from ApiRequest (#2695) 2023-03-06 13:22:35 -05:00
Steve ChavezandGitHub 3e99995e6a feat: make db-root-spec stable (#2694) 2023-03-01 12:23:19 -05:00
steve-chavez 8213c58452 ci: pin Nix version to avoid error
To 2.13.3
2023-02-28 17:30:54 -05:00
steve-chavez ee036f8397 feat: add server-trace-header config for tracing 2023-02-28 17:30:54 -05:00
steve-chavez 5b6421d03a test: fix sporadic failure on PlanSpec 2023-02-28 17:30:54 -05:00
dependabot[bot]andSteve Chavez ba00cb98f4 build(deps): bump actions/upload-artifact from 2.3.1 to 3.1.2
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2.3.1 to 3.1.2.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v2.3.1...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-27 12:00:46 -05:00
dependabot[bot]andSteve Chavez 6184921edc build(deps): bump actions/checkout from 2.4.0 to 3.3.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 2.4.0 to 3.3.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2.4.0...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-27 10:59:38 -05:00
Robert Vollmert 9e0ea113fb refactor: split pre-request function out of setPgLocals 2023-02-27 15:12:32 +01:00
steve-chavez 0d5d209bfb perf: use LATERAL instead of CTE for json body
* add pgbench test for INSERT and RPC
* use LATERAL for RPC and INSERT
* add tests for inlining
2023-02-25 15:01:06 -05:00
steve-chavez 253f2bf537 ci: remove GHC 8.10.7 2023-02-25 14:19:52 -05:00
steve-chavez 2a2889020a nix: fix PGTZ on postgrest-with-* commands 2023-02-20 17:23:11 -05:00
Steve ChavezandGitHub 6a79de67ce fix: log to stderr on AcquisitionTimeoutUsageError (#2667)
* refactor: remove uneeded type on checkIsFatal
* dry with a logPgrstError function
2023-02-17 17:38:31 -05:00
steve-chavez c49932d3a8 nix: UTC by default for postgrest-with-postgresql 2023-02-14 21:30:45 -05:00
RobertandGitHub 95d71281d6 refactor: fix inaccurate variable name (#2662) 2023-02-14 17:09:48 +01:00
steve-chavez a1e2fe308f bump version to 10.2.0.20230209 2023-02-09 17:18:31 -05:00
steve-chavez a9d66d1dac ci: fix ghcup 2023-02-09 16:46:27 -05:00
steve-chavez f4135bb3ff nix: correct postgrest-release pre 2023-02-09 16:46:27 -05:00
steve-chavez d7868235d8 refactor: add getRanges func to ApiRequest 2023-02-09 16:46:27 -05:00
Laurence IslaandGitHub 60f4446fd8 Change inaccurate error codes to new ones
- Code for column is not found is PGRST204
- Code for timed out when acquiring connection to db is PGRST003
2023-02-09 14:57:51 -05:00
Laurence IslaandGitHub c893ac15dc Add the missing 'get' path item for RPCs to the OpenAPI output 2023-02-09 12:04:41 -05:00
dependabot[bot]andSteve Chavez ecada119f8 build(deps): bump LouisBrunner/checks-action from 1.5.0 to 1.6.0
Bumps [LouisBrunner/checks-action](https://github.com/LouisBrunner/checks-action) from 1.5.0 to 1.6.0.
- [Release notes](https://github.com/LouisBrunner/checks-action/releases)
- [Commits](https://github.com/LouisBrunner/checks-action/compare/v1.5.0...v1.6.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>
2023-02-06 09:21:31 -05:00
steve-chavez 57e32ca0a3 bump version to 10.2.0.20230203 2023-02-03 21:16:43 -05:00
Laurence IslaandGitHub eb3f7c696a Fix release date for v10.1.2 in CHANGELOG 2023-02-02 14:50:42 -05:00
Laurence IslaandSteve Chavez 09249f97ab Update CHANGELOG to include release 10.1.2 2023-02-02 14:40:34 -05:00
Laurence IslaandGitHub 754282c385 Add missing fixes to the changelog 2023-02-01 22:15:43 -05:00
steve-chavez 52ba757ebe nix: postgrest-release bumps pre minor 2023-01-31 19:11:45 -05:00
steve-chavez 7aadaa44e8 refactor: remove Target from infoResponse 2023-01-30 00:23:24 -05:00
steve-chavez 0139cd8261 refactor: move txMode to Plan 2023-01-30 00:23:24 -05:00
steve-chavez 7874bee879 fix: NOTIFY pgrst not reoading the catalog cache 2023-01-28 10:30:29 -05:00
RobertandGitHub 12ad7d0585 test: fix result path for load test (#2628) 2023-01-25 08:56:52 +01:00
AnthonyFisiandGitHub 9065ed65fa feat: customizable OpenAPI title 2023-01-21 19:34:55 -05:00
Tuan LeandSteve Chavez 8aa79086d2 fix: consider authentication failure as a fatal error 2023-01-20 13:51:28 -05:00
Laurence IslaandGitHub 775c006806 fix: FK pointing to VIEW instead of TABLE in OpenAPI output 2023-01-20 09:37:15 -05:00
steve-chavez be6c30a0fb refactor: dry RPC param parsing 2023-01-18 12:26:13 -05:00
RobertandGitHub 8f80cd1469 Update nixpkgs, dependencies (#2612)
* relax upper bounds on HTTP, hspec, lens-aeson, optparse-applicative (fixes #2580)
* upgrade stackage snapshot to latest LTS, with GHC 9.2.5
* bump nixpkgs to 2023-01-12
* fix complaints due to updated linters
2023-01-16 17:12:54 +01:00
RobertandGitHub 5fd6b3956e ci: Add workflow to seed cachix (#2615)
Fixes #2609.
2023-01-16 16:56:48 +01:00
RobertandGitHub f353711ed2 ci: build with nix on macos (and push to cachix) (#2610)
The idea is to ensure the macos nix build works, and to
build a cache in cachix to help local development on macos.
2023-01-12 11:45:10 +01:00
Robert Vollmert 0a56d6ce88 ci: authenticate GitHub API request (fixes #2607) 2023-01-10 18:45:50 +01:00
Alexander LjungbergandGitHub 43ad6d6aa0 feat: validate ?columns mutation targets based on schema cache (#2542)
This returns an error for trying to update or insert into invalid columns, without hitting the database. This change also switches from `json_populate_recordset` for these operations `json_to_recordset` which should make no functional difference except allowing future flexibility.
2023-01-07 22:08:30 -05:00
RobertandGitHub 5a0f83ecb8 bump postgresql-libpq (#2599)
For https://github.com/PostgREST/postgresql-libpq/pull/2.
2022-12-30 15:40:17 +01:00
Laurence IslaandGitHub 4ddd5b4a76 Add upsert headers for POST requests to the OpenAPI output 2022-12-27 16:16:41 -05:00
Laurence IslaandGitHub 1f69757835 fix: Add required OpenAPI items object when the paramater is an array 2022-12-20 12:30:28 -05:00
steve-chavez e0df95844b bump version to 10.1.1.20221215 2022-12-15 23:06:32 -05:00
Laurence IslaandGitHub 5a660e0cbe Add suggestions with fuzzy text search when no relationship is found (#2583) 2022-12-15 18:33:15 -05:00
steve-chavez 566c6fe53f correct bad null filter restriction
When embedding using the column name.

Also make related order similarly strict to avoid the same kind of issues.
2022-12-15 18:02:25 -05:00
steve-chavez d93bb95d83 bump version to 10.1.1.20221212 2022-12-12 17:34:46 -05:00
steve-chavez 3773cce246 feat: null filters on embedded resources 2022-12-12 17:14:54 -05:00
steve-chavez 52c011f896 refactor: remove Bool from readPlanToQuery 2022-12-12 17:14:54 -05:00
steve-chavez 81cd9d4b15 refactor: delete unused TestTypes 2022-12-12 13:31:02 -05:00
steve-chavez 781fa592ca refactor: delete QueryCost, instead use PlanSpec 2022-12-12 13:31:02 -05:00
steve-chavez 1065021348 refactor: add planCost and planHdr for tests 2022-12-12 13:31:02 -05:00
Laurence IslaandGitHub aecc53d8f9 fix: clarify error messages for functions
Move explanation on single unnamed parameters to the error details
2022-12-09 14:34:38 -05:00
steve-chavez a5465e20d0 correct spread embed to '...' 2022-12-05 19:38:32 -05:00
Laurence IslaandGitHub 9e567216e9 feat: hint function names/parameters on error 2022-12-01 17:17:45 -05:00
Steve ChavezandGitHub 5e9dba5292 feat: Allow embedding without selecting any column (#2574) 2022-11-25 18:37:57 -05:00
steve-chavez f7009635d6 test: move failed order parsing to doctest 2022-11-24 09:03:35 -05:00
steve-chavez e5c77385ae test: move failed or/and parsing to doctest 2022-11-24 09:03:35 -05:00
steve-chavez 3103060f4b test: move failed json parsing to doctest 2022-11-24 09:03:35 -05:00
steve-chavez 315b01ebf7 test: spread embed includes junction cols 2022-11-18 18:11:14 -05:00
steve-chavez 25f65065f4 test: spread embed disambiguates recursive m2m 2022-11-18 18:11:14 -05:00
steve-chavez 60c0c11c1d test: spread embed disambiguates 2 fks on junction 2022-11-18 18:11:14 -05:00
steve-chavez cb99270a8f fix: bad M2M embed on RPC 2022-11-18 17:49:24 -05:00
steve-chavez cca0b5ae66 messages: better error for related order 2022-11-16 21:26:16 -05:00
steve-chavez 2aa0e091bb feat: add spread embeds 2022-11-16 21:26:16 -05:00
steve-chavez 78d45b4e32 feat: add related orders 2022-11-08 17:28:50 -05:00
steve-chavez ef42d1c87f test: add doctest for pOrderTerm 2022-11-08 17:28:50 -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
steve-chavez a41380b965 bump version to 10.0.0 2022-08-18 23:35:41 -05:00
steve-chavez b2a2504201 changelog: clarify and stamp date for v10 2022-08-18 23:07:24 -05:00
steve-chavez c45e85c5a6 feat: add one to one relationship for embedding
BREAKING CHANGE: For the cases where one to one relationships are
detected, json objects will be returned instead of json arrays of length
1.

If you wish to override this behavior, you can use computed
relationships to return arrays again.
2022-08-18 22:36:47 -05:00
Laurence IslaandSteve Chavez 2afe13fa89 Move feature to fix in the changelog 2022-08-18 20:15:57 -05:00
steve-chavez 36c24d9cbe refactor: clean allM2ORels query
* remove unnecessary lateral usage
* use alias for pg_constraint
2022-08-18 15:08:14 -05:00
steve-chavez d6ec171bcb feat: add computed relationships
* work for select, mutations, rpc
* overrides detected relationships
2022-08-17 22:50:06 -05:00
steve-chavez 06c9e246f4 refactor: simplify addJoinConditions
* remove unnecessary Either
* remove implicitJoins from ReadQuery
* remove unnecessary foldr in addJoinConditions
* move aliasing to addRels
2022-08-17 22:50:06 -05:00
Laurence IslaandGitHub b2b92e75cc Make text the default format for the execution plan 2022-08-17 17:58:02 -05:00
GabrielandGitHub 506929c586 fix: Make views pk and fk schema query compatible with postgres v15 2022-08-17 14:20:34 -05:00
Matt VotavaandGitHub f75e77cf60 Nix: allow building from pure-eval mode (#2427)
* default.nix: allow import with pure-eval enabled

* Update hercules-ci/gitignore

* shell.nix: fix import of default.nix
2022-08-15 20:44:02 -05:00
Laurence IslaandGitHub ba3ba9fd5b refactor: simplify tests where mutations need to be verified in the db 2022-08-15 12:06:54 -05:00
Laurence IslaandGitHub 7de8d5446a Revert bulk update patch
- Revert patch #2311
- Keep the refactor done to qsFiltersRoot
- Keep the refactor done to the items tables
- Add tests that now work with pg-safeupdate as a result
2022-08-12 10:00:33 -05:00
Laurence IslaandGitHub 4ac47df528 tests: Add tests for the pg-safeupdate library 2022-08-10 11:50:57 -05:00
Robert Vollmert 57bc3d805c nix: bump nixpkgs version
- move from GHC 9.2.3 to GHC 9.2.4
- drop upstreamed openssl runtime dependency patch
2022-08-10 06:46:43 +02:00
steve-chavez 3648986aa8 refactor: correlated subquery for o2m query
Improves the query costs a bit as shown in the tests and reduces code.
2022-08-06 21:42:53 -05:00
steve-chavez c050b61db8 tests: add basic resource embedding costs
Adds costs thresholds on the PlanSpec for the o2m,m2o and m2m queries.
2022-08-06 21:42:53 -05:00
Robert Vollmert 92d00749a3 refactor: move pool release on shutdown out of signal handler
This changes behaviour somewhat in that:
- We now consistently release the pool on shutdown, even on non-Unix
  platforms, and including for CmdDumpConfig.
- We release the pool *after* interrupting `App.run`, which will
  rather cause more than fewer connection to be closed properly.
  (Previously any in-use connections would not have been caught by
  `releasePool`, though *maybe* the `UserInterrupt` handling in
  the web handler ends up closing the connections properly already
  anyway).

(The main aim of the change is to make it clearer when and why the
pool is released.)
2022-08-03 19:02:33 +02:00
Robert Vollmert c5849ecbe0 misc: comment typo fix 2022-08-03 19:02:33 +02:00
Robert Vollmert fea38a4caf misc: style fix 2022-08-03 19:02:33 +02:00
steve-chavez d24f0c8216 bump version to 9.0.1.20220802 2022-08-02 21:13:35 -05:00
Robert Vollmert 35a114a81b tests: add failing test showing reconfiguration doesn't take effect (#2401) 2022-08-02 19:03:45 +02:00
Robert Vollmert ed988100c6 tests: add a test to verify pool size, and parallel request handling
The main point for this test is to verify the test setup for follow-up
tests of pool behaviour.
2022-08-02 19:03:45 +02:00
Robert Vollmert b6a93aae1f tests: add a Thread class that catches exceptions 2022-08-02 19:03:45 +02:00
Robert Vollmert 70cae5beed tests: move pool settings to defaultenv from run
This allows overriding them if necessary.
2022-08-02 19:03:45 +02:00
Robert Vollmert 0a24a83fa9 tests: decouple config tests from test-specific postgrest settings 2022-08-02 19:03:45 +02:00
Robert Vollmert c8739aa8a3 fix: fix some race conditions running connection worker
Previously, it was quite possible to have two connection workers
running, or to get into a state where a failed connection worker
is still considered running preventing new connection workers
from starting.
2022-08-02 09:51:19 +02:00
Robert Vollmert d4950c6460 refactor: drop getPool 2022-08-02 09:24:14 +02:00
Robert Vollmert 1d15060c63 refactor: AppState.usePool for App
This is a bit ugly, it would be a bit nicer to just pass
'AppState.usePool appState' but then the types get messy.
Or we could introduce our own 'Pool' wrapper type.
2022-08-02 09:24:14 +02:00
Robert Vollmert 401b98c5b4 refactor: introduce AppState.usePool 2022-08-02 09:24:14 +02:00
Robert Vollmert d53949e4a4 refactor: queryDbSettings doesn't need the pool 2022-08-02 09:24:14 +02:00
Robert Vollmert 421163dc75 refactor: use AppState.releasePool everywhere
This prepares for wrapping the pool in a reference.
2022-08-02 09:24:14 +02:00
Robert Vollmert eae169ca67 refactor: move interrupt out of releasePool helper 2022-08-02 09:24:14 +02:00
Robert Vollmert ec23067e3e refactor: simplify main, address reReadConfig TODO 2022-08-02 09:24:14 +02:00
Robert Vollmert bfacec3a67 ci: check python file formatting
Previously, postgrest-style would reformat python files, but their
formatting wasn't enforced.
2022-08-02 09:24:14 +02:00
Robert Vollmert e7b69cb99c tests: fix python formatting 2022-08-02 09:24:14 +02:00
steve-chavez fd7c23f7a1 Add "for" param for the vnd.pgrst.plan media type 2022-08-01 17:41:23 -05:00
steve-chavez 84e03a16dd fix: columns w/ dollar sign($) don't need quoting 2022-07-30 19:50:09 -05:00
Robert Vollmert d556cea8ce tests: add tests for statement_timeout
The new tests verify that:
- statement_timeout on the authenticator role works to cancel slow statements
- changes to statement_timeout take effect on SIGUSR1

This reuses the old "limited_authenticator" role and adds some plumbing to
allow reliably changing the statement timeout even if the current role is
not functional due to a low statement timeout, and to make tests that modify
the role independent from each other.

- introduce module-wide metapostgrest fixture to have an out-of-band way to
  manipulate the database, without having to spin up extra postgrest instances
  per test
- reset statement_timeout at the start of the respective tests
2022-07-29 21:46:57 +02:00
Steve ChavezandGitHub 8911afd079 feat: Allow getting the EXPLAIN plan of a request 2022-07-27 19:33:34 -05:00
steve-chavez d2df289696 bump version to 9.0.1.20220717 2022-07-17 18:44:13 -05:00
steve-chavez 2eb7c803e3 feat: support OPTIONS on RPC and root path 2022-07-17 18:40:14 -05:00
steve-chavez 71a5748718 refactor: DRY pytest no pool connection logic 2022-07-17 14:41:51 -05:00
steve-chavez 4ab5e63e58 fix: no empty transaction on OPTIONS request 2022-07-17 14:41:51 -05:00
steve-chavez c445105d55 correct query on rpc count=exact
corrects bad logic on e4006da(perf: remove toSourceRel)
2022-07-16 17:18:25 -05:00
steve-chavez 2905753194 test: no empty tx for invalid JWT 2022-07-15 22:23:33 -05:00
steve-chavez df49f482dc test: no empty tx started for invalid path/method 2022-07-15 22:23:33 -05:00
Robert Vollmert 23e4c7fe6e nix: bump nixpkgs version 2022-07-16 00:14:25 +02:00
Laurence Isla 5ac4c29a9e bump version to 9.0.1.20220714 2022-07-14 15:44:31 -05:00
Laurence IslaandGitHub e0ba6b6d1c Add security definitions to the OpenAPI output 2022-07-13 22:47:09 -05:00
Laurence IslaandGitHub bdf1cbe111 fix: inaccurate result count when an inner embed is selected after a normal embed in the query string 2022-07-12 22:29:42 -05:00
Robert Vollmert 9252ec2509 ci: verify that static executable is static 2022-07-12 23:25:06 +02:00
steve-chavez 8230128ff6 fix: no empty tx on bad HTTP method 2022-07-11 18:21:36 -05:00
steve-chavez 6d7bf9faa9 fix: no empty tx on Not Found error 2022-07-11 18:21:36 -05:00
steve-chavez 28183a667c refactor: rm raw media types unnecessary decoding
Just convert them to MTOther instead
2022-07-11 18:21:36 -05:00
Robert Vollmert 1eda7db0fb stack: build with latest LTS release, GHC 9.0
Also bump FreeBSD release.
2022-07-09 02:30:53 +02:00
Steve ChavezandGitHub 3cdc6d15d7 test: add bulk insert to loadtest (#2358) 2022-07-06 13:50:58 -05:00
steve-chavez 0708f46df3 refactor: pass media type to Statements module 2022-07-06 13:47:59 -05:00
steve-chavez 7a7ceaf39a refactor: rename ContentType to MediaType
The core type was wrongly named as the header
2022-07-06 13:47:59 -05:00
steve-chavez 3e83bef9c4 bump version to 9.0.1.20220630 2022-06-30 13:53:33 -05:00
Laurence IslaandGitHub 169547aee6 Return Location header only when using Prefer: return=header-only 2022-06-24 12:53:27 -05:00
Laurence IslaandGitHub f7745e1569 fix: Allow schema names with special characters in the search path
Fixes regression where the search path did not recognize schemas with uppercase, spaces and other special characters in their names.
2022-06-23 18:54:43 -05:00
steve-chavez 870f7a39b0 Add PostGIS GeoJSON support
Works for GET, POST, PATCH, PUT, DELETE, RPC.
2022-06-22 22:16:44 -05:00
steve-chavez 86c40e8df9 test: don't run spec tests with stack
We cannot handle postgis and other postgresql dependencies with
stack.

Also delete test/with_tmp_db since it's no longer used.
2022-06-22 22:16:44 -05:00
steve-chavez d87b622d64 nix: update tests README 2022-06-20 15:55:22 -05:00
Laurence IslaandGitHub 7088e9d510 nix: Add command to remove old hie files in postgrest-clean 2022-06-16 19:16:15 -05:00
RobertandGitHub fe8b32d077 Merge pull request #2324 from robx/ghc92
Update GHC to 9.2.2 (fixes #2288)
2022-06-16 18:45:10 +02:00
Robert Vollmert 478c48cc84 nix: define GHC 9.2.2's Cabal version for static build 2022-06-16 10:39:03 +02:00
Robert Vollmert c9c64fd71f nix: update hsie for GHC 9.2 2022-06-16 10:39:03 +02:00
Robert Vollmert 93210f9380 nix: upgrade weeder to 2.4.0 (GHC 9.2 compatible) 2022-06-16 10:39:02 +02:00
Robert Vollmert c25473e001 nix: upgrade ptr to 0.16.8.2 (GHC 9.2 compatible) 2022-06-16 10:39:02 +02:00
Robert Vollmert f3cbab6f82 nix: use GHC 9.2.2 2022-06-16 10:39:02 +02:00
Robert Vollmert 3d65d66b1b tests: fix incomplete pattern warnings (GHC 9.2) 2022-06-16 10:39:02 +02:00
Robert Vollmert f582798276 ReadQuery: split out to fix record field warning
The use of `where_` in DbRequestBuilder issues a warning since
GHC 9.2 as per
https://github.com/ghc-proposals/ghc-proposals/blob/master/proposals/0366-no-ambiguous-field-access.rst

To disambiguate that, move the type to a separate module and use
the record field names qualified. For symmetry, MutateQuery also
gets its own module.
2022-06-16 10:38:59 +02:00
Robert Vollmert ba87a60a47 rangeParse: fix incomplete pattern match warning
The warning is new in GHC 9.2 compared to 8.10.
2022-06-16 10:37:05 +02:00
Robert Vollmert 356bff9cbd cabal: relax upper bounds to allow GHC 9.2 2022-06-16 10:37:05 +02:00
Steve ChavezandGitHub 86574c89a8 Bulk update (#2311)
* refactor: remove EmbedPath type from qsFiltersRoot

* Rename reset items function for reusability
2022-06-15 20:30:31 -05:00
Robert Vollmert 734f8e6df2 nix: fix static build
- the isExecutable patch was broken, remove it
- the ncurses fix was broken, we still need `enableStatic`
- the original error was caused by the new ghc-bignum
  package, which isn't a "proper" Haskell package; we filter
  this out explicitly now
2022-06-16 01:07:28 +02:00
steve-chavez df7d71db32 fix: Increase the db-pool-timeout to 1 hour 2022-06-15 13:19:44 -05:00
Robert Vollmert 0c5d2e553e nix: build postgrest with nix in loadtest (fixes #2308)
This allows running the load test against main even if the
dependencies change.
2022-06-15 00:41:38 +02:00
Robert Vollmert 36ed7e2fb7 src, nix: hlint update
This pulls in an updated hlint version from nixpkgs, and fixes some
new eta-reduction warnings. (I don't particularly think this is more
readable, but I suppose being consistently hlint-clean is worth it.)
2022-06-14 19:13:55 +02:00
RobertandGitHub bac63f339d Merge pull request #2292 from robx/aeson2
Upgrade aeson to 2.0.*
2022-06-14 07:43:08 +02:00
Robert Vollmert 8d8074d500 nix: keep old hlint
This keeps the pre-nixpkgs update hlint version, so we can defer
addressing the warnings to a follow-up PR.
2022-06-13 13:25:54 +02:00
Robert Vollmert 63ebf6e92b nix: hack static-haskell-nix for updated nixpkgs
This patches static-haskell-nix to work for building postgrest
with updated nixpkgs (from ~202203):

- The ncurses 'enableStatic' argument doesn't exist anymore.
  We use the vanilla package instead, which seems to work fine.
- The 'isExecutable' check fails with a strange error related
  to trying to override 'mkDerivation'.
  We patch 'isExecutable' to check explicitly whether we're building
  postgrest. ('isExecutable' is used to determine whether to build
  a package statically.)
2022-06-13 13:25:54 +02:00
Robert Vollmert d7f54e76f4 nix: haskell overlay update 2022-06-13 13:25:54 +02:00
Robert Vollmert 34d0f34620 nix: bashCompletion -> bash-completion 2022-06-13 13:25:54 +02:00
Robert Vollmert 14c882a566 nix: fetch PostgreSQL 9.6 via postgresql legacy overlay 2022-06-13 13:25:54 +02:00
Wolfgang WaltherandRobert Vollmert ff56c3adf4 nix: port openssl patch to updated nixpkgs 2022-06-13 13:25:54 +02:00
Robert Vollmert 12967f8643 nix: nix version bump 2022-06-13 13:25:54 +02:00
Robert Vollmert b056735535 stack: update stack.yaml 2022-06-13 13:25:54 +02:00
Robert Vollmert f5afa419f1 cabal, tests: require wai-logger 2.4.0, adapt tests
wai-logger version 2.4.0 fixes log output to not say 'unknownSocket'
for unix sockets.
2022-06-13 13:25:54 +02:00
Robert Vollmert 7531c5490d cabal: allow hasql-1.5, hasql-dynamic-statements-0.3.* 2022-06-13 13:25:54 +02:00
Robert Vollmert bb62e559d6 cabal: require jose >= 0.8.5.1 (aeson-2 compat)
jose version before 0.8.5.1 lacked an upper bound on
aeson-2, causing build failures with aeson-2 present.
2022-06-13 13:25:54 +02:00
Robert Vollmert e42bf30aeb cabal: allow swagger 2.8 2022-06-13 13:25:54 +02:00
Robert Vollmert 2b8ffc8e61 src: update for changed map type in aeson-2
This means that we're now using Data.Map.Strict instead of
Data.HashMap.Strict for JSON objects in general, and specifically
for claims maps and CSV rows.

This addresses certain hash flooding vulnerabilities, but may
have performance downsides.

Compare e.g. https://frasertweedale.github.io/blog-fp/posts/2021-10-12-aeson-hash-flooding-protection.html
2022-06-13 13:25:54 +02:00
Robert Vollmert 48a3d1d516 cabal: update aeson to 2.0.3 2022-06-13 13:16:56 +02:00
Robert Vollmert 5e6987b1d8 src: consistently import HashMap as HM, Map as M
With both HashMap and Map imported as M in different modules,
linter rules prevented ever importing both modules in one place.
2022-06-13 13:16:50 +02:00
Robert Vollmert 4dfcb59f73 nix: more explicit logging in load test
* Write vegeta output to file explicitly.
  The previous version using `tee` captured the standard
  output of the `withPgrst` helper, too, causing weird
  errors when those had unexpected output.

* Log and fail the postgrest build explicitly.
  In situations such as changing nix dependencies, the
  `cabal build` run could fail, but this wasn't visible
  in CI.

With these changes, the load test now fails with a nice
error message when the build fails.
2022-06-09 14:56:50 +02:00
Laurence IslaandGitHub 620da2e776 Update CHANGELOG to include release v9.0.1 (#2307) 2022-06-08 23:00:19 -05:00
Laurence IslaandGitHub f9688c3553 Upgrade protolude to 0.3.1 2022-06-08 11:41:39 -05:00
Robert Vollmert 4ff2469f57 nix: don't write to /nix/store from hsie build (fixes #2304)
This is a kind of hacky fix to the kind of hacky hsie derivation.
2022-06-07 21:36:38 +02:00
Robert Vollmert e3252a6e9e nix: test script without glibc locales
glibcLocales is not available e.g. on darwin. This change allows
running various dev tools on darwin, and other theoretical
non-glibc systems.
2022-06-07 21:36:38 +02:00
Robert Vollmert a9035bba2f nix: use the matching weeder
weeder needs to be compiled with the same GHC that we build postgrest
with.

Before this change, we were relying on the fact that we happened
to set ${compiler} to the same ghc8107 as used by nixpkgs for
haskellPackages, now we explicitly take it from the correct
haskell package set.
2022-06-07 21:36:38 +02:00
Robert Vollmert 4c8c3ccfb2 nix: pass through hspec arguments to postgrest-test-spec
This allows e.g. running

  postgrest-test-spec --match "some failing test"
2022-06-07 21:36:38 +02:00
Robert Vollmert 09f3e09c96 tests: don't specify openapi root result
Whereas the test used to specify a body of

  [{"qiName":"referrals","qiSchema":"test"},"private"]

we see both

  [{"qiName":"authors_w_entities","qiSchema":"test"},"test"]

and

  [{"qiName":"organizations","qiSchema":"test"},"test"]

on the GHC 9.2 upgrade PR.

This changes the test to no longer expect a particular body.

Compare:
- https://github.com/PostgREST/postgrest/pull/2292#issuecomment-1146160792
- https://github.com/PostgREST/postgrest/issues/1698
2022-06-07 16:56:22 +02:00
Franz-Josef FärberandSteve Chavez 807364bd7b RPC POST for function w/single unnamed XML param 2022-06-03 11:52:57 -05:00
steve-chavez d14f745406 bump version to 9.0.0.20220531 2022-05-31 18:19:02 -05:00
steve-chavez 1cdc9169c7 fix: using CPU while idle 2022-05-31 12:17:36 -05:00
steve-chavez 9aa400c8f3 fix: disable parallel GC for perf on high-core CPU 2022-05-31 12:17:36 -05:00
Laurence IslaandGitHub e4cdb8c491 ci: run on branches starting with "rel-" 2022-05-30 22:38:05 -05:00
Laurence IslaandSteve Chavez c812a3fe3e ci: Run ARM builds on releases 2022-05-16 18:10:36 -05:00
steve-chavez 1ab11de96c bump version to 9.0.0.20220516 2022-05-16 14:36:04 -05:00
steve-chavez f7b173163c fix: ignore views on col/fk as target
Also refactor self relationship findRel logic
2022-05-16 12:22:24 -05:00
steve-chavez 2be63b36d6 refactor: self relationship
* Add test for self relationship in view
2022-05-09 21:31:19 -05:00
steve-chavez c60380b5fc perf: RelationshipsMap with foreign schema as key 2022-05-09 21:31:19 -05:00
steve-chavez d88b16e5ab fix: allow cast on types with underscores/numbers
e.g. select=oid_array::_int4
2022-05-09 13:02:33 -05:00
Laurence IslaandGitHub 867f2569a0 ci: Fix Windows build using Stack
Added PostgreSQL binaries to the PATH and removed the use of msys2-keyring
2022-05-05 00:06:00 -05:00
Franz-Josef FärberandSteve Chavez 5a3fbd7111 Allow returning XML from single-column queries 2022-05-04 10:27:28 -05:00
Laurence IslaandGitHub a2349d90c0 Reorganize error groups
* Grouped Hasql errors into one error and renamed it to InternalError
* Move all the General errors to ApiRequest errors
2022-05-02 21:16:31 -05:00
steve-chavez 980f27a6d1 bump version to 9.0.0.20220502 2022-05-02 16:06:51 -05:00
steve-chavez ce96c88ba0 test: increase memory tests memory by 1M 2022-05-02 11:12:02 -05:00
steve-chavez 50b275d3e2 refactor: move PUT checks to ApiRequest 2022-05-02 11:12:02 -05:00
steve-chavez e90391b7ac limited update/delete requires explicit order
* limited update/delete now works on views with explicit order
* no default order, enforce order presence
* apply row count to ensure limited mutations
* move requiring order to ApiRequest
2022-05-02 11:12:02 -05:00
Laurence IslaandGitHub 5ad8800773 feat: Allow limit=0 in query params to return an empty array 2022-04-28 17:14:29 -05:00
Franz-Josef FärberandSteve Chavez d2aa50be52 Allow returning XML from RPCs 2022-04-26 17:37:59 -05:00
Laurence IslaandGitHub a8477b7822 Add tests for the ~ ltree operator (#2267) 2022-04-26 22:48:15 +02:00
steve-chavez 115dae7484 perf: Relationship list to a hash map 2022-04-25 21:41:51 -05:00
steve-chavez e4006da9bc perf: remove toSourceRel
toSourceRel is an expensive operation since it iterates over all the
relationships detected. We now use an alias on the CTE so embedding can
work without toSourceRel.
2022-04-25 21:41:51 -05:00
steve-chavez a47c59baca fix: restrict generated m2m relationships
Only add m2m relationship based on real junctions:
a table has FKs to two other tables and the FK columns
are part of the PK columns.

Also add some stats to big_schema.sql
2022-04-25 12:35:16 -05:00
dependabot[bot]andSteve Chavez b191431530 build(deps): bump codecov/codecov-action from 3.0.0 to 3.1.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.0.0 to 3.1.0.
- [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.0.0...v3.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-25 12:24:38 -05:00
Laurence IslaandGitHub 9bdb4e5c4f ci: remove armv7 architectures for ARM builds
Now only builds aarch64 on the remote server without Docker
2022-04-23 20:30:07 +02:00
steve-chavez 07d619981e perf: don't lookup Table pkCols unnecessarily
handleCreate only needs it on some headers, udpate and delete don't need
pkcols.
2022-04-22 19:04:16 -05:00
steve-chavez 778b8d074d refactor: merge Column list into Table
* Remove Table from Column
* Correct OpenAPI test where the schema was appearing for the enum unnecessarily
* Increase memory-tests consumption by 2M

Seems the Hasql decoder consumes more memory now that the allTables
results also have [Column] inside.
2022-04-22 19:04:16 -05:00
steve-chavez 902e4e8921 refactor: clean allColumns query
* Add OpenAPI test to ensure enum labels are present
* Remove unnecessary fk/pk columns cte
* Remove unnecessary step for parsing enum labels
2022-04-22 11:46:45 -05:00
steve-chavez d2719420f4 refactor: PKcols in table, ViewKeyDependency type
* Get PKcols inside tables - done with SQL for tables and
  with an additional step in Haskell for views.

  This fixes an fk column being considered as a pk column on views
  and corrects the test added on
  https://github.com/PostgREST/postgrest/pull/1875/files/1d549768580310e18aac4ffa6dbd01c5b77934a7#r853674126

* classify view key dependencies in SQL

* remove Column from Relationship

* Merge cols/fcols in Relationship and
  ensure allM2ORels and allViewsKeyDependencies fk columns
  are ordered - done by attnum in SQL

* Cardinality now contains relColumns instead of Relationship -
  this simplifies getJoinConditions.
2022-04-21 14:44:45 -05:00
steve-chavez 057e8d63bf test: add big schema openapi test
The io test is marked as pending for now, because the stack size it
requires is too big.
2022-04-20 20:05:11 -05:00
steve-chavez f4e171aa2b refactor: ignore partitions in allM2ORels
Also clarify foreign key filter
2022-04-19 17:23:12 -05:00
steve-chavez 7f1507b9fb refactor: remove Table from Relationship
Just having the QualifiedIdentifier gets us closer to having
Relationship as a Table attribute since it avoids a cyclic dependency

* remove unnecessary findTable
* modify RootSpec test
2022-04-18 15:48:03 -05:00
steve-chavez cdcc175abf refactor: table list to table map 2022-04-18 15:48:03 -05:00
steve-chavez f995799e8f refactor: remove fkey from allPrimaryKeys query
The query had the unnecessary 'f' in `c.contype in ('p', 'u', 'f')`

Also comment and clarify the query by using explicit JOINs
2022-04-18 15:48:03 -05:00
steve-chavez 44e2b2fbb4 refactor: DRY allTables/accessibleTables query 2022-04-18 15:48:03 -05:00
steve-chavez 2eaf81334e nix: local history for nix-shell 2022-04-18 15:48:03 -05:00
steve-chavez c399b1f5f8 nix: log all statements in temp db 2022-04-18 15:48:03 -05:00
Laurence IslaandGitHub bc7d54c7d2 Add CI for ARM architectures (#2127) 2022-04-18 17:40:32 +02:00
Laurence IslaandGitHub 7589102c6c Fix misleading disambiguation error where relationship looks like valid syntax
* Add columns for the m2m relationship
2022-04-12 00:02:19 +02:00
Michael Kane JunckerandWolfgang Walther 007f49a8bc feat(query): add basic regular expression operator support - refs #2236 2022-04-11 08:33:16 +02:00
dependabot[bot]andWolfgang Walther a768bcbf20 build(deps): bump codecov/codecov-action from 2.1.0 to 3.0.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2.1.0 to 3.0.0.
- [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/v2.1.0...v3.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-11 08:23:26 +02:00
dependabot[bot]andWolfgang Walther cb145271ac build(deps): bump actions/cache from 2.1.7 to 3
Bumps [actions/cache](https://github.com/actions/cache) from 2.1.7 to 3.
- [Release notes](https://github.com/actions/cache/releases)
- [Commits](https://github.com/actions/cache/compare/v2.1.7...v3)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 07:45:07 +02:00
Laurence IslaandGitHub 9124dfd427 Bypass FreeBSD builds due to timeouts 2022-03-26 15:14:54 -05:00
Laurence Isla 8e86177486 bump version to 9.0.0.20220326 2022-03-26 12:26:47 -05:00
Laurence IslaandGitHub fe898c27ae Fix incorrectly placed feature in CHANGELOG
Move incorrectly placed feature for PostgREST errors from v9.0.0 to Upcoming
2022-03-26 11:08:37 -05:00
steve-chavez f3f9030c48 disallow limit/offset on views 2022-03-26 15:29:13 +01:00
steve-chavez 73dc2692b1 refactor: add findTable function 2022-03-26 15:29:13 +01:00
steve-chavez 87ffe39746 fix: ignore max-rows on POST/PATCH/PUT/DELETE 2022-03-26 15:29:13 +01:00
steve-chavez 331e88ea39 feat: add limited delete 2022-03-26 15:29:13 +01:00
steve-chavez f4becf99ad feat: add limited update 2022-03-26 15:29:13 +01:00
Ezequiel AlvarezandGitHub 799daa7556 fix: json/jsonb columns should not have type "string" in OpenAPI spec (#2203)
* Switch to no type for json/jsonb
2022-03-26 15:16:35 +01:00
Steve ChavezandGitHub a3c1d9977f Correct live/ready checks to consider special host values (#2182) 2022-03-11 15:16:45 +01:00
dependabot[bot]andGitHub c803c4d0b6 build(deps): bump actions/checkout from 2.4.0 to 3 (#2186) 2022-03-08 01:56:58 +01:00
dependabot[bot]andGitHub 8aef09574c build(deps): bump actions/upload-artifact from 2.3.1 to 3 (#2187) 2022-03-08 01:46:30 +01:00
dependabot[bot]andGitHub efcc93ad89 build(deps): bump actions/download-artifact from 2.1.0 to 3 (#2185) 2022-03-07 22:54:47 +01:00
Laurence IslaandGitHub d6834e8bf8 Add 'PGRST' error code to differentiate from PostgreSQL errors 2022-03-04 15:57:38 -05:00
Laurence IslaandGitHub 99d0b805df fix: Using GET with certain Content-Type headers now correctly calls the no parameter function if it exists
Using GET with text/plain or application/octet-stream as Content-Type headers no longer returns 404 Not Found when a function with no parameters exists
2022-02-28 13:23:55 -05:00
Laurence IslaandGitHub 69070f341a fix: Remove admin-server-port from in-db configuration 2022-02-23 17:26:01 -05:00
steve-chavez dcf7ade5bc bump version to 9.0.0.20220211 2022-02-11 14:47:33 -05:00
Steve ChavezandGitHub 9021c96baa fix: Keep working when EMFILE is reached (#2158)
Done by upgrading to warp 3.3.19
2022-02-09 17:27:29 -05:00
Wolfgang WaltherandWolfgang Walther 41a4396147 fix: Remove uncallable functions from schema cache and OpenAPI output
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-02-08 09:08:27 +01:00
Wolfgang WaltherandWolfgang Walther d9f7f6f509 refactor: Remove pg_catalog prefix from schema cache queries
Since the search path is voided at the beginning, there is no need to qualify those.

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-02-08 09:08:27 +01:00
Wolfgang WaltherandWolfgang Walther bd62008e1c fix: Remove aggregates, procedures and window functions from schema cache and OpenAPI output
Aggregates and Window functions can't be called as RPCs in a useful way.

Procedures are not supported right now, but might be added later.

Resolves #2101

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-02-05 17:48:22 +01:00
Wolfgang WaltherandWolfgang Walther ded8981368 fix: Take PG version into account in --dump-schema
The PG version is only read by the Connection Worker, which is not used in the case dump-schema. Now, the pg version is read in the schema cache queries directly, avoiding this problem in all cases.

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-02-05 17:48:22 +01:00
Wolfgang WaltherandWolfgang Walther 52d628f1ed fix: Return 204 No Content without Content-Type for RPCs returning VOID
Resolves #2001

BREAKING CHANGE: Previously, those RPCs would return "null" as a body with Content-Type: application/json.
2022-02-05 09:28:28 +01:00
Wolfgang Walther 9ca9a54d21 bump version to 9.0.0.20220204 2022-02-04 08:34:51 +01:00
Wolfgang Walther bf689565a5 fix broken link in docker hub description, resolves #2148
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-02-04 08:34:35 +01:00
Wolfgang WaltherandWolfgang Walther 9f1b5c0a81 feat: Support accessing array items and fields of composite types through json operators
This is supported in select=, in filters and in order=.

Resolves #1543

Resolves #2075

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-02-03 08:13:38 +01:00
Wolfgang WaltherandWolfgang Walther 87bd8e72cf fix changelog
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-02-03 08:13:38 +01:00
Wolfgang WaltherandWolfgang Walther 8934190d43 nix: Add postgrest-release tool
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-30 19:52:35 +01:00
Wolfgang Walther c894b543b0 bump version to 9.0.0.20220130 2022-01-30 16:15:27 +01:00
Wolfgang WaltherandWolfgang Walther 58f76f3d6d fix: Return 405 Method Not Allowed for unsupported verbs
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-28 19:26:33 +01:00
Wolfgang WaltherandWolfgang Walther 8980b09419 refactor: Sort error types
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-28 19:26:33 +01:00
Wolfgang WaltherandWolfgang Walther 927ff6f1e5 test: Split spec tests into multiple subfolders
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-28 19:26:33 +01:00
Wolfgang WaltherandWolfgang Walther 8a722e2cfe refactor: Replace elem with pattern matching to highlight missing code coverage
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-28 19:26:33 +01:00
Wolfgang WaltherandWolfgang Walther 271c329677 nix: Fix postgrest-git-hooks causing merge conflicts when staging files partially
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-28 19:26:33 +01:00
Wolfgang WaltherandWolfgang Walther c63420f0e7 nix: Fix postgrest-git-hooks disable not working
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-28 19:26:33 +01:00
Wolfgang WaltherandWolfgang Walther 2fe2c9104b ci: Fix weeder not stopping CI on error
Removes dead code that was unnoticed

Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-28 17:12:32 +01:00
Wolfgang WaltherandWolfgang Walther 0ea36fc039 ci: Fix windows build failing because of outdated keyring
Signed-off-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-28 17:12:32 +01:00
Wolfgang WaltherandWolfgang Walther fc7113e52e fix: Remove trigger functions from schema cache and OpenAPI output
Trigger functions can't be called directly from SQL and can't be called
via the /rpc prefix either - it makes no sense to expose them in the
OpenAPI output. And we don't need to cache them in the schema cache
either.

Best practice would be to keep the trigger functions in a non-exposed
schema anyway.
2022-01-28 16:45:36 +01:00
Steve ChavezandGitHub c7cadcd776 Fix embedded filter silently getting ignored (#2133)
When the embedded resource is not included in the request
2022-01-24 17:52:27 -05:00
Wolfgang WaltherandWolfgang Walther fae1e168a7 Fix memory tests timing out after c3ade07 2022-01-24 14:56:06 +01:00
Wolfgang WaltherandWolfgang Walther d2791ea0e5 Sort example config alphabetically 2022-01-24 14:56:06 +01:00
Wolfgang WaltherandWolfgang Walther f4637c64c9 Remove db-root-spec from example config file
We are not going to support this officially right now.
2022-01-24 14:56:06 +01:00
Wolfgang WaltherandWolfgang Walther 1b9a30cff4 Make db-uri, db-schemas and db-anon-role optional in example config file 2022-01-24 14:56:06 +01:00
Wolfgang WaltherandWolfgang Walther 5a627e76b2 format comments in example config consistently 2022-01-24 14:56:06 +01:00
Wolfgang Walther d9e016be8b feat: Allow running postgrest without configuration
Resolves #1823
2022-01-22 15:59:26 +01:00
Wolfgang Walther c3ade07ad6 feat: Make db-anon-role optional
Without db-anon-role, PostgREST will block any anonymous access without hitting the database.

Resolves #1689, Ref #1823
2022-01-22 15:59:26 +01:00
Wolfgang Walther 05b5ecd23b Reformatting of some test-cases and removal of superfluous comments. 2022-01-22 15:13:21 +01:00
Wolfgang Walther dbbde6aae1 feat: Make db-schemas optional
Resolves #1769, Ref #1823
2022-01-22 15:13:21 +01:00
Wolfgang Walther 9ed30c0ba4 feat: Make db-uri optional
The default is now "postgresql://" which falls back to LIBPQ environment variables.

Resolves #1991, Ref #1823
2022-01-22 15:13:19 +01:00
Wolfgang Walther ec09b87940 test: Make io tests use environment variables instead of config files where possible.
Load jwt secret from file instead of stdin in tests, because stdin is not supported when reloading the config. Resolves #2126.
2022-01-22 15:10:25 +01:00
Wolfgang WaltherandWolfgang Walther 6b5370f145 ci: Add -with-rtsopts=-KxK option to prevent introducing space leaks
Resolves #387
2022-01-10 22:09:32 +01:00
Wolfgang WaltherandWolfgang Walther a25ad42644 Align columns in postgrest.cabal 2022-01-10 22:09:32 +01:00
Wolfgang WaltherandWolfgang Walther 995215c6a5 fix: Read database configuration properly when = is present in value
Resolves #2120
2022-01-10 09:24:49 +01:00
Wolfgang WaltherandWolfgang Walther 4b841dba61 refactor: Reformat db-config query 2022-01-10 09:24:49 +01:00
Wolfgang WaltherandWolfgang Walther 9639cd5751 feat: Add current user to apache-style request logging. 2022-01-07 20:26:33 +01:00
3c17f97c87 refactor: Make JWT authorization a middleware
This follows the style of wai-middleware-auth package and
makes the JWT parsing a middleware.

Co-authored-by: Wolfgang Walther <walther@technowledgy.de>
2022-01-07 20:26:33 +01:00
Wolfgang WaltherandWolfgang Walther d97bf3b864 refactor: Move each Wai Middleware to a separate file 2022-01-07 20:26:33 +01:00
Steve ChavezandGitHub 65f27653dc cov: empty coverage overlay (#2119)
The overlay depended on the version number, which caused errors when upgrading.

Also update cabal description.
2022-01-07 13:06:03 -05:00
steve-chavez a134d8a493 pre-release: Bump to 9.0.0.20220107
Previous pre-release had a wrong date
2022-01-07 11:06:03 -05:00
steve-chavez 3ded567f6d pre-release: Bump to 9.0.0.20210107 2022-01-07 11:01:59 -05:00
Wolfgang WaltherandWolfgang Walther 8b63d928ae test: Reorganize test/ folder into one subdirectory for each test type 2022-01-07 13:10:36 +01:00
Wolfgang WaltherandWolfgang Walther 8060fe3559 Remove unused dependencies from postgrest.cabal 2022-01-07 12:07:48 +01:00
Wolfgang WaltherandWolfgang Walther df5d9400fe test: Make io tests use admin/ready to wait for startup 2022-01-07 12:07:09 +01:00
Wolfgang Walther 6bc965e2a0 run postgrest-style 2022-01-07 11:39:41 +01:00
monacoremoandWolfgang Walther bfbec8fa36 refactor: Carve out QueryParams module 2022-01-07 09:32:33 +01:00
steve-chavezandWolfgang Walther 51ee072f84 fix: clarify error for failed schema cache load 2022-01-07 09:31:30 +01:00
steve-chavezandWolfgang Walther aa82d2e277 Consider schema cache state on the ready check
* empty schema cache when it fails loading so the ready check can pick
  its new state
2022-01-07 09:31:30 +01:00
Wolfgang WaltherandWolfgang Walther e13d912a79 cov: Add coverage overlay for impossible case in corsPolicy 2022-01-05 11:01:54 +01:00
Wolfgang WaltherandWolfgang Walther 826ae7459a refactor: simplify logger middleware 2022-01-05 11:01:54 +01:00
Wolfgang WaltherandWolfgang Walther cde9cd4ab7 cov: Remove unreachable static icon 2022-01-05 11:01:54 +01:00
Wolfgang WaltherandWolfgang Walther b0a0c4768d cov: Add tests for request logging 2022-01-05 11:01:54 +01:00
steve-chavez 4a9ca9bdae Add live endpoint to admin server
* refactor io tests
* add network package to windows
2022-01-04 20:12:52 -05:00
steve-chavez 65a3ae08f0 Change admin health endpoint to ready 2022-01-04 20:12:52 -05:00
steve-chavez bba6e96fd3 Connect to main app socket on health check 2022-01-04 20:12:52 -05:00
Wolfgang WaltherandWolfgang Walther cc900787de test: Remove unused unix-socket.config file
Unix sockets are the default in io tests and we are testing ip connections explicitly.
2022-01-04 09:36:22 +01:00
Wolfgang WaltherandWolfgang Walther 01e65f5221 test: Improve io test perfomance
Local test improves performance by 15%.
2022-01-04 09:36:22 +01:00
Wolfgang WaltherandWolfgang Walther 1e9031573c test: Move io-tests SQL fixtures to a separate file 2022-01-04 09:36:22 +01:00
Wolfgang WaltherandWolfgang Walther 81ebdff977 test: Improve log output for failing io-tests 2022-01-04 09:36:22 +01:00
Wolfgang WaltherandWolfgang Walther 9baad293e8 test: Refactor invalid_role_claim_key_notify_reload test to properly read all available input 2022-01-04 09:36:22 +01:00
Wolfgang WaltherandWolfgang Walther efc7a21427 test: Refactor test-cases for admin/health endpoint 2022-01-04 09:36:22 +01:00
Wolfgang WaltherandWolfgang Walther b9c497a3ab test: Use .encode() end .decode default values in io-tests
Those default to "utf-8" anyway.
2022-01-04 09:36:22 +01:00
Wolfgang WaltherandWolfgang Walther 5f43235370 nix: Fix nested postgrest-with-postgresql-x calls when -f is used 2022-01-04 09:36:22 +01:00
Wolfgang WaltherandWolfgang Walther 57ff89be64 Remove outdated heroku manifest 2022-01-01 15:57:58 +01:00
Wolfgang WaltherandWolfgang Walther 4f7ee6f36a cov: Improve jwt claims test-cases 2021-12-25 13:27:53 +01:00
Wolfgang WaltherandWolfgang Walther 14f3b25022 fix: Fix wrong CORS header Authentication -> Authorization
Also refactors defaultCorsPolicy and corsPolicy and cleans up CORS tests
2021-12-25 13:27:53 +01:00
Wolfgang WaltherandWolfgang Walther b99c8c897c fix: Make recursive view parsing work with XMLTABLE + DEFAULT 2021-12-24 19:01:20 +01:00
Steve ChavezandGitHub ac3655df1d feat: minimal health check (#2092) 2021-12-23 08:00:10 -05:00
Laurence IslaandGitHub c858d158aa Add hint to verify table and foreign key existence on a relationship error 2021-12-20 12:11:07 -05:00
dependabot[bot]andGitHub cc8a7ef0c2 build(deps): bump actions/upload-artifact from 2.3.0 to 2.3.1 (#2094) 2021-12-20 10:54:13 -05:00
Steve ChavezandGitHub c86e254a24 fix: is not working with upper/mixed case values (#2081) 2021-12-14 19:13:38 -05:00
dependabot[bot]andSteve Chavez 1c7ca51308 build(deps): bump actions/upload-artifact from 2.2.4 to 2.3.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2.2.4 to 2.3.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v2.2.4...v2.3.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-12-13 12:43:16 -05:00
dependabot[bot]andSteve Chavez 6a3f8ca828 build(deps): bump actions/download-artifact from 2.0.10 to 2.1.0
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 2.0.10 to 2.1.0.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v2.0.10...v2.1.0)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-12-13 12:42:53 -05:00
Wolfgang WaltherandWolfgang Walther 031de4d35e fix: Dump db-config option without quotes as it's a boolean 2021-12-11 08:12:59 +01:00
Wolfgang WaltherandWolfgang Walther 595c2a5257 nix(feat): Add postgrest-git-hooks to enable pre-commit and pre-push hooks 2021-12-06 10:40:23 +01:00
Wolfgang WaltherandRemo 74f6e5225c nix: Add weeder to postgrest-coverage
Removes a bit of dead code from SpecHelper.hs
2021-12-04 11:39:56 +01:00
Wolfgang WaltherandWolfgang Walther c581148f1b refactor: Make asJsonSingleF "safe", by accessing only the first element of a json_agg result 2021-12-01 09:19:47 +01:00
Wolfgang WaltherandWolfgang Walther 0e20b47875 fix: Return 204 No Content without Content-Type for PUT 2021-12-01 09:19:47 +01:00
Wolfgang WaltherandWolfgang Walther 1cb00d3c62 fix: Execute deferred constraint triggers when using Prefer: tx=rollback
Resolves #2020
2021-11-30 15:54:12 +01:00
Wolfgang WaltherandWolfgang Walther bbc07d3f40 fix: Link to latest docs in pre-release openapi output
Resolves #2018
2021-11-29 08:40:38 +01:00
Wolfgang WaltherandSteve Chavez 9c6597c351 ci: Use matrix strategy to test against postgres versions 2021-11-27 13:15:42 -05:00
Wolfgang WaltherandSteve Chavez defdcb85ef ci: Run IO tests against all postgres versions
Resolves #1820.
2021-11-27 13:15:42 -05:00
Wolfgang WaltherandSteve Chavez 94115ff134 ci: Disable update of dockerhub description on release
This is not allowed via personal access token right now and could be re-enabled in the future, once solved by docker hub.
2021-11-27 13:11:30 -05:00
Wolfgang WaltherandWolfgang Walther 5a8d9b1246 nix(feat): Add bash completion for git references 2021-11-27 10:57:29 +01:00
Wolfgang WaltherandWolfgang Walther 32ff9dfb48 nix(feat): Add postgrest-loadtest 2021-11-27 10:57:29 +01:00
Wolfgang WaltherandWolfgang Walther ab90bd24e0 nix(feat): Add postgrest-with-git and postgrest-with-pgrst tools 2021-11-27 10:57:29 +01:00
250 changed files with 31630 additions and 8747 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
freebsd_instance: freebsd_instance:
image_family: freebsd-13-0 image_family: freebsd-13-1
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
+4 -2
View File
@@ -11,8 +11,10 @@ inputs:
runs: runs:
using: composite using: composite
steps: steps:
- uses: cachix/install-nix-action@v16 - uses: cachix/install-nix-action@v22
- uses: cachix/cachix-action@v10 with:
install_url: https://releases.nixos.org/nix/nix-2.13.3/install
- uses: cachix/cachix-action@v12
with: with:
name: postgrest name: postgrest
authToken: ${{ inputs.authToken }} authToken: ${{ inputs.authToken }}
+5
View File
@@ -4,3 +4,8 @@ updates:
directory: / directory: /
schedule: schedule:
interval: weekly interval: weekly
- package-ecosystem: github-actions
directory: /.github/actions/setup-nix
schedule:
interval: weekly
+4 -3
View File
@@ -7,12 +7,13 @@ set -euo pipefail
# https://docs.github.com/en/rest/reference/checks#list-check-suites-for-a-git-reference # https://docs.github.com/en/rest/reference/checks#list-check-suites-for-a-git-reference
cirrus_artifact_name=bin cirrus_artifact_name=bin
gh_auth_header="Authorization: Bearer $GITHUB_TOKEN"
gh_accept_header="Accept: application/vnd.github.v3+json" gh_accept_header="Accept: application/vnd.github.v3+json"
get_gh_check_runs_url() { get_gh_check_runs_url() {
gh_checks_list_url="https://api.github.com/repos/$GITHUB_REPOSITORY/commits/$GITHUB_COMMIT/check-suites" gh_checks_list_url="https://api.github.com/repos/$GITHUB_REPOSITORY/commits/$GITHUB_COMMIT/check-suites"
>&2 echo "Getting list of check-suites from $gh_checks_list_url ..." >&2 echo "Getting list of check-suites from $gh_checks_list_url ..."
curl --fail -H "$gh_accept_header" "$gh_checks_list_url" \ curl --fail -H "$gh_auth_header" -H "$gh_accept_header" "$gh_checks_list_url" \
| jq -r '.check_suites[] | select(.app.slug == "cirrus-ci") | .check_runs_url' | jq -r '.check_suites[] | select(.app.slug == "cirrus-ci") | .check_runs_url'
} }
@@ -21,7 +22,7 @@ wait_for_cirrusci() {
>&2 echo "Waiting to CirrusCI run to complete (two hours maximum)..." >&2 echo "Waiting to CirrusCI run to complete (two hours maximum)..."
for _ in $(seq 1 120); do for _ in $(seq 1 120); do
echo "Checking for CirrusCI task status at $gh_check_runs_url ..." echo "Checking for CirrusCI task status at $gh_check_runs_url ..."
status=$(curl --fail "$gh_check_runs_url" | jq -r '.check_runs[] | .status') status=$(curl --fail -H "$gh_auth_header" "$gh_check_runs_url" | jq -r '.check_runs[] | .status')
if [ "$status" == "completed" ]; then if [ "$status" == "completed" ]; then
break break
else else
@@ -37,7 +38,7 @@ wait_for_cirrusci() {
get_cirrus_taskid() { get_cirrus_taskid() {
gh_check_runs_url="$(get_gh_check_runs_url)" gh_check_runs_url="$(get_gh_check_runs_url)"
>&2 echo "Getting the CirrusCI task id from $gh_check_runs_url ..." >&2 echo "Getting the CirrusCI task id from $gh_check_runs_url ..."
curl --fail -H "$gh_accept_header" "$gh_check_runs_url" \ curl --fail -H "$gh_auth_header" -H "$gh_accept_header" "$gh_check_runs_url" \
| jq -r '.check_runs[] | .external_id' | jq -r '.check_runs[] | .external_id'
} }
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
# This script builds PostgREST in a remote ARM server
[ -z "$1" ] && { echo "Missing 1st argument: PostgREST github commit SHA"; exit 1; }
[ -z "$2" ] && { echo "Missing 2nd argument: Build environment directory name"; exit 1; }
[ -z "$3" ] && { echo "Missing 3rd argument: GHC version"; exit 1; }
PGRST_GITHUB_COMMIT="$1"
SCRIPT_DIR="$2"
DOCKER_BUILD_DIR="$SCRIPT_DIR/docker-env"
# latest is a shortcut documented on https://www.haskell.org/ghcup/guide/#tags-and-shortcuts
CABAL_VERSION="latest"
GHC_VERSION="$3"
install_packages() {
sudo apt-get update -y
sudo apt-get upgrade -y
sudo apt-get install -y git build-essential curl libffi-dev libffi7 libgmp-dev libgmp10 libncurses-dev libncurses5 libtinfo5 llvm libnuma-dev zlib1g-dev libpq-dev jq gcc
sudo apt-get clean
}
install_ghcup() {
export BOOTSTRAP_HASKELL_NONINTERACTIVE=1
export BOOTSTRAP_HASKELL_MINIMAL=1
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
source ~/.ghcup/env
}
install_cabal() {
ghcup upgrade
ghcup install cabal $CABAL_VERSION
ghcup set cabal $CABAL_VERSION
}
install_ghc() {
ghcup upgrade
ghcup install ghc $GHC_VERSION
ghcup set ghc $GHC_VERSION
}
install_packages
# Add ghcup to the PATH for this session
[ -f ~/.ghcup/env ] && source ~/.ghcup/env
ghcup --version || install_ghcup
ghcup set cabal $CABAL_VERSION || install_cabal
ghcup set ghc $GHC_VERSION || install_ghc
cd ~/$SCRIPT_DIR
# Clone the repository and build the project
git clone https://github.com/PostgREST/postgrest.git
cd postgrest
git checkout $PGRST_GITHUB_COMMIT
cabal v2-update && cabal v2-build
# Copy the built binary to the Dockerfile directory
PGRST_BIN=$(cabal exec which postgrest | tail -1)
cp $PGRST_BIN ~/$DOCKER_BUILD_DIR
# Move and compress the built binary
mkdir -p ~/$SCRIPT_DIR/result
mv $PGRST_BIN ~/$SCRIPT_DIR/result
cd ~/$SCRIPT_DIR
tar -cJf result.tar.xz result
+18
View File
@@ -0,0 +1,18 @@
# PostgREST docker hub image
FROM ubuntu:focal AS postgrest
RUN apt-get update -y \
&& apt install -y --no-install-recommends libpq-dev zlib1g-dev jq gcc libnuma-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY postgrest /usr/bin/postgrest
EXPOSE 3000
USER 1000
# Use the array form to avoid running the command using bash, which does not handle `SIGTERM` properly.
# See https://docs.docker.com/compose/faq/#why-do-my-services-take-10-seconds-to-recreate-or-stop
CMD ["postgrest"]
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# This script publishes the Docker ARM images to Docker Hub.
[ -z "$1" ] && { echo "Missing 1st argument: PostgREST github commit SHA"; exit 1; }
[ -z "$2" ] && { echo "Missing 2nd argument: Docker repo"; exit 1; }
[ -z "$3" ] && { echo "Missing 3rd argument: Docker username"; exit 1; }
[ -z "$4" ] && { echo "Missing 4th argument: Docker password"; exit 1; }
[ -z "$5" ] && { echo "Missing 5th argument: Build environment directory name"; exit 1; }
[ -z "$6" ] && { echo "Missing 6th argument: PostgREST version"; exit 1; }
PGRST_GITHUB_COMMIT="$1"
DOCKER_REPO="$2"
DOCKER_USER="$3"
DOCKER_PASS="$4"
SCRIPT_DIR="$5"
PGRST_VERSION="v$6"
IS_PRERELEASE="$7"
DOCKER_BUILD_DIR="$SCRIPT_DIR/docker-env"
clean_env()
{
sudo docker logout
}
# Login to Docker
sudo docker logout
{ echo $DOCKER_PASS | sudo docker login -u $DOCKER_USER --password-stdin; } || { echo "Couldn't login to docker"; exit 1; }
trap clean_env sigint sigterm exit
# Move to the docker build environment
cd ~/$DOCKER_BUILD_DIR
# Push final images to Docker hub
# NOTE: This command publishes a separate ARM image because the builds cannot
# be added to the manifest if they are not in the registry beforehand.
# This image must be manually deleted from Docker Hub at the end of the process.
sudo docker buildx build --build-arg PGRST_GITHUB_COMMIT=$PGRST_GITHUB_COMMIT \
-t $DOCKER_REPO/postgrest:$PGRST_VERSION-arm \
--push .
# Add the arm images to the manifest
# NOTE: This assumes that there already is a `postgrest:<version>` image
# for the amd64 architecture pushed to Docker Hub
sudo docker buildx imagetools create --append -t $DOCKER_REPO/postgrest:$PGRST_VERSION $DOCKER_REPO/postgrest:$PGRST_VERSION-arm
[ -z $IS_PRERELEASE ] && sudo docker buildx imagetools create --append -t $DOCKER_REPO/postgrest:latest $DOCKER_REPO/postgrest:$PGRST_VERSION-arm
sudo docker logout
+78
View File
@@ -0,0 +1,78 @@
name: Cachix
# This workflow serves to
# - keep cachix up to date with the main branch
# - incrementally update cachix for large dependency
# updates, e.g. after running postgrest-nixpkgs-upgrade,
# which can cause the main CI workflow to time out
on:
workflow_dispatch:
push:
branches:
- main
- rel-*
tags:
- v*
jobs:
Seed-Cachix:
strategy:
fail-fast: false
matrix:
include:
- os: Linux
runs-on: ubuntu-latest
- os: MacOS
runs-on: macos-latest
name: Seed ${{ matrix.os }}
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v3
- name: Setup Nix Environment
uses: ./.github/actions/setup-nix
with:
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Install cachix tooling
run: |
nix-env -f default.nix -iA devTools.pushCachix.bin
postgrest-push-cachix
- name: Seed dynamic postgrest build
run: |
nix-build -A postgrestPackage
postgrest-push-cachix
- name: Seed style tools
run: |
nix-build -A style
postgrest-push-cachix
- name: Seed test tools
run: |
nix-build -A tests
postgrest-push-cachix
- name: Seed static toolchain
if: matrix.os == 'Linux'
run: |
nix-build -A packagesStatic.haskellPackages.hello
postgrest-push-cachix
- name: Seed static postgresql build (for libpq)
if: matrix.os == 'Linux'
run: |
nix-build -A packagesStatic.pkgs.postgresql
postgrest-push-cachix
- name: Seed static postgrest build
if: matrix.os == 'Linux'
run: |
nix-build -A postgrestStatic
postgrest-push-cachix
- name: Build and push everything to Cachix
run: |
nix-build
postgrest-push-cachix
+242 -80
View File
@@ -4,18 +4,20 @@ on:
push: push:
branches: branches:
- main - main
- rel-*
tags: tags:
- v* - v*
pull_request: pull_request:
branches: branches:
- main - main
- rel-*
jobs: jobs:
Lint-Style: Lint-Style:
name: Lint & check code style name: Lint & check code style
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
@@ -35,39 +37,19 @@ jobs:
# https://github.com/actions/runner/issues/241#issuecomment-842566950 # https://github.com/actions/runner/issues/241#issuecomment-842566950
shell: script -qec "bash --noprofile --norc -eo pipefail {0}" shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
tools: tests withTools tools: tests
- 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@v2.1.0 uses: codecov/codecov-action@v3.1.4
with: with:
files: ./coverage/codecov.json files: ./coverage/codecov.json
- name: Run the spec tests against PostgreSQL 13
if: always()
run: postgrest-with-postgresql-13 postgrest-test-spec
- name: Run the spec tests against PostgreSQL 12
if: always()
run: postgrest-with-postgresql-12 postgrest-test-spec
- name: Run the spec tests against PostgreSQL 11
if: always()
run: postgrest-with-postgresql-11 postgrest-test-spec
- name: Run the spec tests against PostgreSQL 10
if: always()
run: postgrest-with-postgresql-10 postgrest-test-spec
- name: Run the spec tests against PostgreSQL 9.6
if: always()
run: postgrest-with-postgresql-9.6 postgrest-test-spec
- name: Run query cost tests against all PostgreSQL versions
if: always()
run: postgrest-with-all postgrest-test-querycost
- name: Run doctests - name: Run doctests
if: always() if: always()
run: nix-shell --run postgrest-test-doctests run: nix-shell --run postgrest-test-doctests
@@ -77,11 +59,39 @@ jobs:
run: postgrest-test-spec-idempotence run: postgrest-test-spec-idempotence
Test-Pg-Nix:
strategy:
fail-fast: false
matrix:
pgVersion: [9.6, 10, 11, 12, 13, 14, 15]
name: Test PG ${{ matrix.pgVersion }} (Nix)
runs-on: ubuntu-latest
defaults:
run:
# Hack for enabling color output, see:
# https://github.com/actions/runner/issues/241#issuecomment-842566950
shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
steps:
- uses: actions/checkout@v3
- name: Setup Nix Environment
uses: ./.github/actions/setup-nix
with:
tools: tests withTools
- name: Run spec tests
if: always()
run: postgrest-with-postgresql-${{ matrix.pgVersion }} postgrest-test-spec
- name: Run IO tests
if: always()
run: postgrest-with-postgresql-${{ matrix.pgVersion }} -f test/io/fixtures.sql postgrest-test-io -vv
Test-Memory-Nix: Test-Memory-Nix:
name: Test memory (Nix) name: Test memory (Nix)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
@@ -90,20 +100,22 @@ jobs:
run: postgrest-test-memory run: postgrest-test-memory
Build-Nix: Build-Static-Nix:
name: Build Linux static (Nix) name: Build Linux static (Nix)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' tools: tests
- name: Build static executable - name: Build static executable
run: nix-build -A postgrestStatic run: nix-build -A postgrestStatic
- name: Check static executable
run: postgrest-check-static result/bin/postgrest
- name: Save built executable as artifact - name: Save built executable as artifact
uses: actions/upload-artifact@v2.2.4 uses: actions/upload-artifact@v3
with: with:
name: postgrest-linux-static-x64 name: postgrest-linux-static-x64
path: result/bin/postgrest path: result/bin/postgrest
@@ -112,18 +124,24 @@ jobs:
- name: Build Docker image - name: Build Docker image
run: nix-build -A docker.image --out-link postgrest-docker.tar.gz run: nix-build -A docker.image --out-link postgrest-docker.tar.gz
- name: Save built Docker image as artifact - name: Save built Docker image as artifact
uses: actions/upload-artifact@v2.2.4 uses: actions/upload-artifact@v3
with: with:
name: postgrest-docker-x64 name: postgrest-docker-x64
path: postgrest-docker.tar.gz path: postgrest-docker.tar.gz
if-no-files-found: error if-no-files-found: error
- name: Build and push everything to Cachix (main branch only)
if: ${{ github.ref == 'refs/heads/main' }} Build-Macos-Nix:
name: Build MacOS (Nix)
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Setup Nix Environment
uses: ./.github/actions/setup-nix
- name: Build everything
run: | run: |
nix-build nix-build
nix-env -f default.nix -iA devTools
postgrest-push-cachix
Build-Stack: Build-Stack:
@@ -131,22 +149,18 @@ 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
test: true
pgdir: /usr/lib/postgresql
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
.stack-work .stack-work
test: true
pgdir: /usr/local/Cellar/postgresql
artifact: postgrest-macos-x64 artifact: postgrest-macos-x64
- name: Windows - name: Windows
@@ -155,18 +169,15 @@ jobs:
~\AppData\Roaming\stack ~\AppData\Roaming\stack
~\AppData\Local\Programs\stack ~\AppData\Local\Programs\stack
.stack-work .stack-work
deps: | deps: Add-Content $env:GITHUB_PATH $env:PGBIN
stack exec -- pacman -S mingw64/mingw-w64-x86_64-postgresql --noconfirm
# We'd need to make test/with_tmp_db run on Windows first
# test: true
artifact: postgrest-windows-x64 artifact: postgrest-windows-x64
name: Build ${{ matrix.name }} (Stack) name: Build ${{ matrix.name }} (Stack)
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- name: Stack working files cache - name: Stack working files cache
uses: actions/cache@v2.1.7 uses: actions/cache@v3
with: with:
path: ${{ matrix.cache }} path: ${{ matrix.cache }}
key: ${{ runner.os }}-${{ hashFiles('stack.yaml.lock') }} key: ${{ runner.os }}-${{ hashFiles('stack.yaml.lock') }}
@@ -175,14 +186,8 @@ jobs:
run: ${{ matrix.deps }} run: ${{ matrix.deps }}
- name: Build with Stack - name: Build with Stack
run: stack build --local-bin-path result --copy-bins run: stack build --local-bin-path result --copy-bins
- name: Run Spec tests with Stack
if: ${{ matrix.test }}
run: |
postgresql_bin="$(find ${{ matrix.pgdir }} -maxdepth 2 -type d -name bin | head -n 1)"
echo "Using PostgreSQL binaries at $postgresql_bin ..."
PATH="$postgresql_bin:$PATH" test/with_tmp_db stack test
- name: Save built executable as artifact - name: Save built executable as artifact
uses: actions/upload-artifact@v2.2.4 uses: actions/upload-artifact@v3
with: with:
name: ${{ matrix.artifact }} name: ${{ matrix.artifact }}
path: | path: |
@@ -190,24 +195,117 @@ jobs:
result/postgrest.exe result/postgrest.exe
if-no-files-found: error if-no-files-found: error
Get-FreeBSD-CirrusCI: Get-FreeBSD-CirrusCI:
name: Get FreeBSD build from CirrusCI name: Get FreeBSD build from CirrusCI
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- name: Get FreeBSD executable from CirrusCI - name: Get FreeBSD executable from CirrusCI
env: env:
# GITHUB_SHA does weird things for pull request, so we roll our own: # GITHUB_SHA does weird things for pull request, so we roll our own:
GITHUB_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} GITHUB_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: .github/get_cirrusci_freebsd run: .github/get_cirrusci_freebsd
- name: Save executable as artifact - name: Save executable as artifact
uses: actions/upload-artifact@v2.2.4 uses: actions/upload-artifact@v3
with: with:
name: postgrest-freebsd-x64 name: postgrest-freebsd-x64
path: postgrest path: postgrest
if-no-files-found: error if-no-files-found: error
Build-Cabal:
strategy:
matrix:
ghc: ['9.0.2', '9.2.4']
fail-fast: false
name: Build Linux (Cabal, GHC ${{ matrix.ghc }})
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Workaround runner image issue
# https://github.com/actions/runner-images/issues/7061
run: sudo chown -R "$USER" /usr/local/.ghcup
- 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:
strategy:
matrix:
ghc: ['9.2.4']
fail-fast: false
name: Build aarch64 (Cabal, GHC ${{ matrix.ghc }})
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/rel-') }}
runs-on: ubuntu-latest
outputs:
remotepath: ${{ steps.Remote-Dir.outputs.remotepath }}
env:
GITHUB_COMMIT: ${{ github.sha }}
GHC_VERSION: ${{ matrix.ghc }}
steps:
- uses: actions/checkout@v3
- id: Remote-Dir
name: Unique directory name for the remote build
run: echo "remotepath=postgrest-build-$(uuidgen)" >> "$GITHUB_OUTPUT"
- name: Copy script files to the remote server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SSH_ARM_HOST }}
username: ubuntu
key: ${{ secrets.SSH_ARM_PRIVATE_KEY }}
fingerprint: ${{ secrets.SSH_ARM_FINGERPRINT }}
source: ".github/scripts/arm/*"
target: ${{ steps.Remote-Dir.outputs.remotepath }}
strip_components: 3
- name: Build ARM
uses: appleboy/ssh-action@master
env:
REMOTE_DIR: ${{ steps.Remote-Dir.outputs.remotepath }}
with:
host: ${{ secrets.SSH_ARM_HOST }}
username: ubuntu
key: ${{ secrets.SSH_ARM_PRIVATE_KEY }}
fingerprint: ${{ secrets.SSH_ARM_FINGERPRINT }}
command_timeout: 120m
script_stop: true
envs: GITHUB_COMMIT,REMOTE_DIR,GHC_VERSION
script: bash ~/$REMOTE_DIR/build.sh "$GITHUB_COMMIT" "$REMOTE_DIR" "GHC_VERSION"
- name: Download binaries from remote server
uses: nicklasfrahm/scp-action@main
with:
direction: download
host: ${{ secrets.SSH_ARM_HOST }}
username: ubuntu
key: ${{ secrets.SSH_ARM_PRIVATE_KEY }}
fingerprint: ${{ secrets.SSH_ARM_FINGERPRINT }}
source: "${{ steps.Remote-Dir.outputs.remotepath }}/result.tar.xz"
target: "result.tar.xz"
- name: Extract downloaded binaries
run: tar -xvf result.tar.xz && rm result.tar.xz
- name: Save aarch64 executable as artifact
uses: actions/upload-artifact@v3
with:
name: postgrest-ubuntu-aarch64
path: result/postgrest
if-no-files-found: error
Prepare-Release: Prepare-Release:
name: Prepare release name: Prepare release
@@ -216,15 +314,17 @@ jobs:
needs: needs:
- Lint-Style - Lint-Style
- Test-Nix - Test-Nix
- Test-Pg-Nix
- Test-Memory-Nix - Test-Memory-Nix
- Build-Nix - Build-Static-Nix
- Build-Stack - Build-Stack
- Get-FreeBSD-CirrusCI #- Get-FreeBSD-CirrusCI
- Build-Cabal-Arm
outputs: outputs:
version: ${{ steps.Identify-Version.outputs.version }} version: ${{ steps.Identify-Version.outputs.version }}
isprerelease: ${{ steps.Identify-Version.outputs.isprerelease }} isprerelease: ${{ steps.Identify-Version.outputs.isprerelease }}
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- id: Identify-Version - id: Identify-Version
name: Identify the version to be released name: Identify the version to be released
run: | run: |
@@ -236,14 +336,14 @@ jobs:
exit 1 exit 1
else else
echo "Version to be released is $cabal_version" echo "Version to be released is $cabal_version"
echo "::set-output name=version::$cabal_version" echo "version=$cabal_version" >> "$GITHUB_OUTPUT"
fi fi
if [[ "$cabal_version" != *.*.*.* ]]; then if [[ "$cabal_version" != *.*.*.* ]]; then
echo "Version is for a full release (version does not have four components)" echo "Version is for a full release (version does not have four components)"
else else
echo "Version is for a pre-release (version has four components, e.g., 1.1.1.1)" echo "Version is for a pre-release (version has four components, e.g., 1.1.1.1)"
echo "::set-output name=isprerelease::1" echo "isprerelease=1" >> "$GITHUB_OUTPUT"
fi fi
- name: Identify changes from CHANGELOG.md - name: Identify changes from CHANGELOG.md
run: | run: |
@@ -261,7 +361,7 @@ jobs:
echo "Relevant extract from CHANGELOG.md:" echo "Relevant extract from CHANGELOG.md:"
cat CHANGES.md cat CHANGES.md
- name: Save CHANGES.md as artifact - name: Save CHANGES.md as artifact
uses: actions/upload-artifact@v2.2.4 uses: actions/upload-artifact@v3
with: with:
name: release-changes name: release-changes
path: CHANGES.md path: CHANGES.md
@@ -277,9 +377,9 @@ jobs:
env: env:
VERSION: ${{ needs.Prepare-Release.outputs.version }} VERSION: ${{ needs.Prepare-Release.outputs.version }}
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v2.0.10 uses: actions/download-artifact@v3
with: with:
path: artifacts path: artifacts
- name: Create release bundle with archives for all builds - name: Create release bundle with archives for all builds
@@ -299,14 +399,18 @@ jobs:
tar cJvf "release-bundle/postgrest-v$VERSION-macos-x64.tar.xz" \ tar cJvf "release-bundle/postgrest-v$VERSION-macos-x64.tar.xz" \
-C artifacts/postgrest-macos-x64 postgrest -C artifacts/postgrest-macos-x64 postgrest
tar cJvf "release-bundle/postgrest-v$VERSION-freebsd-x64.tar.xz" \ # TODO: Fix timeouts for FreeBSD builds in Cirrus
-C artifacts/postgrest-freebsd-x64 postgrest #tar cJvf "release-bundle/postgrest-v$VERSION-freebsd-x64.tar.xz" \
# -C artifacts/postgrest-freebsd-x64 postgrest
tar cJvf "release-bundle/postgrest-v$VERSION-ubuntu-aarch64.tar.xz" \
-C artifacts/postgrest-ubuntu-aarch64 postgrest
zip "release-bundle/postgrest-v$VERSION-windows-x64.zip" \ zip "release-bundle/postgrest-v$VERSION-windows-x64.zip" \
artifacts/postgrest-windows-x64/postgrest.exe artifacts/postgrest-windows-x64/postgrest.exe
- name: Save release bundle - name: Save release bundle
uses: actions/upload-artifact@v2.2.4 uses: actions/upload-artifact@v3
with: with:
name: release-bundle name: release-bundle
path: release-bundle path: release-bundle
@@ -329,25 +433,28 @@ jobs:
Release-Docker: Release-Docker:
name: Release on Docker Hub name: Release on Docker Hub
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: Prepare-Release needs:
- Prepare-Release
env: env:
GITHUB_COMMIT: ${{ github.sha }}
DOCKER_REPO: postgrest DOCKER_REPO: postgrest
DOCKER_USER: stevechavez DOCKER_USER: stevechavez
DOCKER_PASS: ${{ secrets.DOCKER_PASS }}
VERSION: ${{ needs.Prepare-Release.outputs.version }} VERSION: ${{ needs.Prepare-Release.outputs.version }}
ISPRERELEASE: ${{ needs.Prepare-Release.outputs.isprerelease }} ISPRERELEASE: ${{ needs.Prepare-Release.outputs.isprerelease }}
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v3
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
tools: release tools: release
- name: Download Docker image - name: Download Docker image
uses: actions/download-artifact@v2.0.10 uses: actions/download-artifact@v3
with: with:
name: postgrest-docker-x64 name: postgrest-docker-x64
- name: Publish images on Docker Hub - name: Publish images on Docker Hub
run: | run: |
docker login -u "$DOCKER_USER" -p "${{ secrets.DOCKER_PASS }}" docker login -u "$DOCKER_USER" -p "$DOCKER_PASS"
docker load -i postgrest-docker.tar.gz docker load -i postgrest-docker.tar.gz
docker tag postgrest:latest "$DOCKER_REPO/postgrest:v$VERSION" docker tag postgrest:latest "$DOCKER_REPO/postgrest:v$VERSION"
@@ -361,13 +468,68 @@ jobs:
else else
echo "Skipping pushing to 'latest' tag for v$VERSION pre-release..." echo "Skipping pushing to 'latest' tag for v$VERSION pre-release..."
fi fi
- name: Update descriptions on Docker Hub # TODO: Enable dockerhub description update again, once a solution for the permission problem is found:
# https://github.com/docker/hub-feedback/issues/1927
# - name: Update descriptions on Docker Hub
# env:
# DOCKER_PASS: ${{ secrets.DOCKER_PASS }}
# run: |
# if [[ -z "$ISPRERELEASE" ]]; then
# echo "Updating description on Docker Hub..."
# postgrest-release-dockerhub-description
# else
# echo "Skipping updating description for pre-release..."
# fi
Release-Docker-Arm:
name: Release Arm Builds on Docker Hub
runs-on: ubuntu-latest
needs:
- Build-Cabal-Arm
- Prepare-Release
- Release-Docker
env: env:
GITHUB_COMMIT: ${{ github.sha }}
DOCKER_REPO: postgrest
DOCKER_USER: stevechavez
DOCKER_PASS: ${{ secrets.DOCKER_PASS }} DOCKER_PASS: ${{ secrets.DOCKER_PASS }}
run: | VERSION: ${{ needs.Prepare-Release.outputs.version }}
if [[ -z "$ISPRERELEASE" ]]; then ISPRERELEASE: ${{ needs.Prepare-Release.outputs.isprerelease }}
echo "Updating description on Docker Hub..." steps:
postgrest-release-dockerhub-description - uses: actions/checkout@v3
else - name: Publish images for ARM builds on Docker Hub
echo "Skipping updating description for pre-release..." uses: appleboy/ssh-action@master
fi env:
REMOTE_DIR: ${{ needs.Build-Cabal-Arm.outputs.remotepath }}
with:
host: ${{ secrets.SSH_ARM_HOST }}
username: ubuntu
key: ${{ secrets.SSH_ARM_PRIVATE_KEY }}
fingerprint: ${{ secrets.SSH_ARM_FINGERPRINT }}
script_stop: true
envs: GITHUB_COMMIT,DOCKER_REPO,DOCKER_USER,DOCKER_PASS,REMOTE_DIR,VERSION,ISPRERELEASE
script: bash ~/$REMOTE_DIR/docker-publish.sh "$GITHUB_COMMIT" "$DOCKER_REPO" "$DOCKER_USER" "$DOCKER_PASS" "$REMOTE_DIR" "$VERSION" "$ISPRERELEASE"
Clean-Arm-Server:
name: Remove copied files from server
needs:
- Build-Cabal-Arm
- Release-Docker-Arm
if: success() ||
needs.Build-Cabal-Arm.result == 'failure' ||
needs.Build-Cabal-Arm.result == 'cancelled' ||
(needs.Build-Cabal-Arm.result == 'success' && !startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
env:
REMOTE_DIR: ${{ needs.Build-Cabal-Arm.outputs.remotepath }}
steps:
- uses: actions/checkout@v3
- name: Remove uploaded files from server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SSH_ARM_HOST }}
username: ubuntu
key: ${{ secrets.SSH_ARM_PRIVATE_KEY }}
fingerprint: ${{ secrets.SSH_ARM_FINGERPRINT }}
envs: REMOTE_DIR
script: rm -rf $REMOTE_DIR
+34
View File
@@ -0,0 +1,34 @@
name: Loadtest
on:
push:
branches:
- main
tags:
- v*
pull_request:
branches:
- main
jobs:
Loadtest-Nix:
name: Loadtest (Nix)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Nix Environment
uses: ./.github/actions/setup-nix
with:
tools: loadtest
- name: Run loadtest
run: |
postgrest-loadtest-against main
postgrest-loadtest-report > loadtest/loadtest.md
- name: Upload report
uses: actions/upload-artifact@v3
with:
name: loadtest.md
path: loadtest/loadtest.md
if-no-files-found: error
+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.6.2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
sha: ${{ github.event.workflow_run.head_sha }} sha: ${{ github.event.workflow_run.head_sha }}
+2
View File
@@ -21,3 +21,5 @@ __pycache__
*.tix *.tix
coverage coverage
.hpc .hpc
loadtest
.history
+68
View File
@@ -0,0 +1,68 @@
# 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.
### 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.
This spawns threads which are used to execute concurrent jobs.
Jobs include connection recover and a listener for the PostgreSQL LISTEN command.
+2
View File
@@ -46,12 +46,14 @@ PostgREST ongoing development is only possible thanks to our Sponsors and Backer
## Lead Backers ## Lead Backers
- [Roboflow](https://github.com/roboflow)
- Evans Fernandes - Evans Fernandes
- [Jan Sommer](https://github.com/nerfpops) - [Jan Sommer](https://github.com/nerfpops)
- [Franz Gusenbauer](https://www.igutech.at/) - [Franz Gusenbauer](https://www.igutech.at/)
## Backers ## Backers
- Zac Miller
- Tsingson Qin - Tsingson Qin
- Michel Pelletier - Michel Pelletier
- Jay Hannah - Jay Hannah
+296
View File
@@ -5,10 +5,306 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased ## Unreleased
## [11.2.0] - 2023-08-10
### Added ### Added
- #2523, Data representations - @aljungberg
+ Allows for flexible API output formatting and input parsing on a per-column type basis using regular SQL functions configured in the database
+ Enables greater flexibility in the form and shape of your APIs, both for output and input, making PostgREST a more versatile general-purpose API server
+ Examples include base64 encode/decode your binary data (like a `bytea` column containing an image), choose whether to present a timestamp column as seconds since the Unix epoch or as an ISO 8601 string, or represent fixed precision decimals as strings, not doubles, to preserve precision
+ ...and accept the same in `POST/PUT/PATCH` by configuring the reverse transformation(s)
+ Other use-cases include custom representation of enums, arrays, nested objects, CSS hex colour strings, gzip compressed fields, metric to imperial conversions, and much more
+ Works when using the `select` parameter to select only a subset of columns, embedding through complex joins, renaming fields, with views and computed columns
+ Works when filtering on a formatted column without extra indexes by parsing to the canonical representation
+ Works for data `RETURNING` operations, such as requesting the full body in a POST/PUT/PATCH with `Prefer: return=representation`
+ Works for batch updates and inserts
+ Completely optional, define the functions in the database and they will be used automatically everywhere
+ Data representations preserve the ability to write to the original column and require no extra storage or complex triggers (compared to using `GENERATED ALWAYS` columns)
+ Note: data representations require Postgres 10 (Postgres 11 if using `IN` predicates); data representations are not implemented for RPC
- #2647, Allow to verify the PostgREST version in SQL: `select distinct application_name from pg_stat_activity`. - @laurenceisla
- #2856, Add the `--version` CLI option that prints the version information - @laurenceisla
- #1655, Improve `details` field of the singular error response - @taimoorzaeem
- #740, Add `Preference-Applied` in response for `Prefer: return=representation/headers-only/minimal` - @taimoorzaeem
- #1601, Add optional `nulls=stripped` parameter for mediatypes `application/vnd.pgrst.array+json` and `application/vnd.pgrst.object+json` - @taimoorzaeem
### Fixed ### Fixed
- #2821, Fix OPTIONS not accepting all available media types - @steve-chavez
- #2834, Fix compilation on Ubuntu by being compatible with GHC 9.0.2 - @steve-chavez
- #2840, Fix `Prefer: missing=default` with DOMAIN default values - @steve-chavez
- #2849, Fix HEAD unnecessarily executing aggregates - @steve-chavez
- #2594, Fix unused index on jsonb/jsonb arrow filter and order (``/bets?data->>contractId=eq.1`` and ``/bets?order=data->>contractId``) - @steve-chavez
- #2861, Fix character and bit columns with fixed length not inserting/updating properly - @laurenceisla
+ Fixes the error "value too long for type character(1)" when the char length of the column was bigger than one.
- #2862, Fix null filtering on embedded resource when using a column name equal to the relation name - @steve-chavez
- #1586, Fix function parameters of type character and bit not ignoring length - @laurenceisla
+ Fixes the error "value too long for type character(1)" when the char length of the parameter was bigger than one.
- #2881, Fix error when a function returns `RECORD` or `SET OF RECORD` - @laurenceisla
- #2896, Fix applying superuser settings for impersonated role - @steve-chavez
### Deprecated
- #2863, Deprecate resource embedding target disambiguation - @steve-chavez
+ The `/table?select=*,other!fk(*)` must be used to disambiguate
+ The server aids in choosing the `!fk` by sending a `hint` on the error whenever an ambiguous request happens.
## [11.1.0] - 2023-06-07
### Added
- #2786, Limit idle postgresql connection lifetime - @robx
+ New option `db-pool-max-idletime` (default 30s).
+ This is equivalent to the old option `db-pool-timeout` of PostgREST 10.0.0.
+ A config alias for `db-pool-timeout` is included.
- #2703, Add pre-config function - @steve-chavez
+ New config option `db-pre-config`(empty by default)
+ Allows using the in-database configuration without SUPERUSER
- #2781, When `db-channel-enabled` is false, start automatic connection recovery on a new request when pool connections are closed with `pg_terminate_backend` - @steve-chavez
+ Mitigates the lack of LISTEN/NOTIFY for schema cache reloading on read replicas.
### Fixed
- #2791, Fix dropping schema cache reload notifications - @steve-chavez
- #2801, Stop retrying connection when "no password supplied" - @steve-chavez
## [11.0.1] - 2023-04-27
### Fixed
- #2762, Fixes "permission denied for schema" error during schema cache load - @steve-chavez
- #2756, Fix bad error message on generated columns when using `Prefer: missing=default` - @steve-chavez
- #1139, Allow a 30 second skew for JWT validation - @steve-chavez
+ It used to be 1 second, which was too strict
## [11.0.0] - 2023-04-16
### Added
- #1414, Add related orders - @steve-chavez
+ On a many-to-one or one-to-one relationship, you can order a parent by a child column `/projects?select=*,clients(*)&order=clients(name).desc.nullsfirst`
- #1233, #1907, #2566, Allow spreading embedded resources - @steve-chavez
+ On a many-to-one or one-to-one relationship, you can unnest a json object with `/projects?select=*,...clients(client_name:name)`
+ Allows including the join table columns when resource embedding
+ Allows disambiguating a recursive m2m embed
+ Allows disambiguating an embed that has a many-to-many relationship using two foreign keys on a junction
- #2340, Allow embedding without selecting any column - @steve-chavez
- #2563, Allow `is.null` or `not.is.null` on an embedded resource - @steve-chavez
+ Offers a more flexible replacement for `!inner`, e.g. `/projects?select=*,clients(*)&clients=not.is.null`
+ Allows doing an anti join, e.g. `/projects?select=*,clients(*)&clients=is.null`
+ Allows using or across related tables conditions
- #1100, Customizable OpenAPI title - @AnthonyFisi
- #2506, Add `server-trace-header` for tracing HTTP requests. - @steve-chavez
+ When the client sends the request header specified in the config it will be included in the response headers.
- #2694, Make `db-root-spec` stable. - @steve-chavez
+ This can be used to override the OpenAPI spec with a custom database function
- #1567, On bulk inserts, missing values can get the column DEFAULT by using the `Prefer: missing=default` header - @steve-chavez
- #2501, Allow filtering by`IS DISTINCT FROM` using the `isdistinct` operator, e.g. `/people?alias=isdistinct.foo`
- #1569, Allow `any/all` modifiers on the `eq,like,ilike,gt,gte,lt,lte,match,imatch` operators, e.g. `/tbl?id=eq(any).{1,2,3}` - @steve-chavez
- This converts the input into an array type
- #2561, Configurable role settings - @steve-chavez
- Database roles that are members of the connection role get their settings applied, e.g. doing
`ALTER ROLE anon SET statement_timeout TO '5s'` will result in that `statement_timeout` getting applied for that role.
- Works when switching roles when a JWT is sent
- Settings can be reloaded with `NOTIFY pgrst, 'reload config'`.
- #2468, Configurable transaction isolation level with `default_transaction_isolation` - @steve-chavez
- Can be set per function `create function .. set default_transaction_isolation = 'repeatable read'`
- Or per role `alter role .. set default_transaction_isolation = 'serializable'`
### Fixed
- #2651, Add the missing `get` path item for RPCs to the OpenAPI output - @laurenceisla
- #2648, Fix inaccurate error codes with new ones - @laurenceisla
+ `PGRST204`: Column is not found
+ `PGRST003`: Timed out when acquiring connection to db
- #1652, Fix function call with arguments not inlining - @steve-chavez
- #2705, Fix bug when using the `Range` header on `PATCH/DELETE` - @laurenceisla
+ Fix the`"message": "syntax error at or near \"RETURNING\""` error
+ Fix doing a limited update/delete when an `order` query parameter was present
- #2742, Fix db settings and pg version queries not getting prepared - @steve-chavez
- #2618, Fix `PATCH` requests not recognizing embedded filters and using the top-level resource instead - @steve-chavez
### Changed
- #2705, The `Range` header is now only considered on `GET` requests and is ignored for any other method - @laurenceisla
+ Other methods should use the `limit/offset` query parameters for sub-ranges
+ `PUT` requests no longer return an error when this header is present (using `limit/offset` still triggers the error)
- #2733, Remove bulk RPC call with the `Prefer: params=multiple-objects` header. A function with a JSON array or object parameter should be used instead.
## [10.2.0] - 2023-04-12
### Added
- #2663, Limit maximal postgresql connection lifetime - @robx
+ New option `db-pool-max-lifetime` (default 30m)
+ `db-pool-acquisition-timeout` is no longer optional and defaults to 10s
+ Fixes postgresql resource leak with long-lived connections (#2638)
### Fixed
- #2667, Fix `db-pool-acquisition-timeout` not logging to stderr when the timeout is reached - @steve-chavez
## [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
- #2542, Return a clear error without hitting the database when trying to update or insert an unknown column with `?columns` - @aljungberg
## [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
### Added
- #1933, #2109, Add a minimal health check endpoint - @steve-chavez
+ For enabling this, the `admin-server-port` config must be set explictly
+ A `<host>:<admin_server_port>/live` endpoint is available for checking if postgrest is running on its port/socket. 200 OK = alive, 503 = dead.
+ A `<host>:<admin_server_port>/ready` endpoint is available for checking a correct internal state(the database connection plus the schema cache). 200 OK = ready, 503 = not ready.
- #1988, Add the current user to the request log on stdout - @DavidLindbom, @wolfgangwalther
- #1823, Add the ability to run postgrest without any configuration. - @wolfgangwalther
+ #1991, Add the ability to run without `db-uri` using libpq's PG environment variables to connect. - @wolfgangwalther
+ #1769, Add the ability to run without `db-schemas`, defaulting to `db-schemas=public`. - @wolfgangwalther
+ #1689, Add the ability to run without `db-anon-role` disabling anonymous access. - @wolfgangwalther
- #1543, Allow access to fields of composite types in select=, order= and filters through JSON operators -> and ->>. - @wolfgangwalther
- #2075, Allow access to array items in ?select=, ?order= and filters through JSON operators -> and ->>. - @wolfgangwalther
- #2156, #2211, Allow applying `limit/offset` to UPDATE/DELETE to only affect a subset of rows - @steve-chavez
+ It requires an explicit `order` on a unique column(s)
- #1917, Add error codes with the `"PGRST"` prefix to the error response body to differentiate PostgREST errors from PostgreSQL errors - @laurenceisla
- #1917, Normalize the error response body by always having the `detail` and `hint` error fields with a `null` value if they are empty - @laurenceisla
- #2176, Errors raised with `SQLSTATE` now include the message and the code in the response body - @laurenceisla
- #2236, Support POSIX regular expression operators for row filtering - @enote-kane
- #2202, Allow returning XML from RPCs - @fjf2002
- #2268, Allow returning XML from single-column queries - @fjf2002
- #2300, RPC POST for function w/single unnamed XML param #2300 - @fjf2002
- #1564, Allow geojson output by specifying the `Accept: application/geo+json` media type - @steve-chavez
+ Requires postgis >= 3.0
+ Works for GET, RPC, POST/PATCH/DELETE with `Prefer: return=representation`.
+ Resource embedding works and the embedded rows will go into the `properties` key
+ In case of multiple geometries in the same table, you can choose which one will go into the `geometry` key with the usual `?select` query parameter.
- #1082, Add security definitions to the OpenAPI output - @laurenceisla
- #2378, Support http OPTIONS method on RPC and root path - @steve-chavez
- #2354, Allow getting the EXPLAIN plan of a request by using the `Accept: application/vnd.pgrst.plan` header - @steve-chavez
+ Only allowed if the `db-plan-enabled` config is set to true
+ Can generate the plan for different media types using the `for` parameter: `Accept: application/vnd.pgrst.plan; for="application/vnd.pgrst.object"`
+ Different options for the plan can be used with the `options` parameter: `Accept: application/vnd.pgrst.plan; options=analyze|verbose|settings|buffers|wal`
+ The plan can be obtained in text or json by using different media type suffixes: `Accept: application/vnd.pgrst.plan+text` and `Accept: application/vnd.pgrst.plan+json`.
- #2144, Support computed relationships which allow extending and overriding relationships for resource embedding - @steve-chavez, @wolfgangwalther
- #1984, Detect one-to-one relationships for resource embedding - @steve-chavez
+ Detected when there's a foreign key with a unique constraint or when a foreign key is also a primary key
### Fixed
- #2058, Return 204 No Content without Content-Type for PUT - @wolfgangwalther
- #2107, Clarify error for failed schema cache load. - @steve-chavez
+ From `Database connection lost. Retrying the connection` to `Could not query the database for the schema cache. Retrying.`
- #1771, Fix silently ignoring filter on a non-existent embedded resource - @steve-chavez
- #2152, Remove functions, which are uncallable because of unnamend arguments from schema cache and OpenAPI output. - @wolfgangwalther
- #2145, Fix accessing json array fields with -> and ->> in ?select= and ?order=. - @wolfgangwalther
- #2155, Ignore `max-rows` on POST, PATCH, PUT and DELETE - @steve-chavez
- #2254, Fix inferring a foreign key column as a primary key column on views - @steve-chavez
- #2070, Restrict generated many-to-many relationships - @steve-chavez
+ Only adds many-to-many relationships when: a table has FKs to two other tables and these FK columns are part of the table's PK columns.
- #2278, Allow casting to types with underscores and numbers(e.g. `select=oid_array::_int4`) - @steve-chavez
- #2277, #2238, #1643, Prevent views from breaking one-to-many/many-to-one embeds when using column or FK as target - @steve-chavez
+ When using a column or FK as target for embedding(`/tbl?select=*,col-or-fk(*)`), only tables are now detected and views are not.
+ You can still use a column or an inferred FK on a view to embed a table(`/view?select=*,col-or-fk(*)`)
- #2317, Increase the `db-pool-timeout` to 1 hour to prevent frequent high connection latency - @steve-chavez
- #2341, The search path now correctly identifies schemas with uppercase and special characters in their names (regression) - @laurenceisla
- #2364, "404 Not Found" on nested routes and "405 Method Not Allowed" errors no longer start an empty database transaction - @steve-chavez
- #2342, Fix inaccurate result count when an inner embed was selected after a normal embed in the query string - @laurenceisla
- #2376, OPTIONS requests no longer start an empty database transaction - @steve-chavez
- #2395, Allow using columns with dollar sign($) without double quoting in filters and `select` - @steve-chavez
- #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
- #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
- #2001, Return 204 No Content without Content-Type for RPCs returning VOID - @wolfgangwalther
+ Previously, those RPCs would return "null" as a body with Content-Type: application/json.
- #2156, `limit/offset` now limits the affected rows on UPDATE/DELETE - @steve-chavez
+ Previously, `limit/offset` only limited the returned rows but not the actual updated rows
- #2155, `max-rows` is no longer applied on POST/PATCH/PUT/DELETE returned rows - @steve-chavez
+ This was misleading because the affected rows were not really affected by `max-rows`, only the returned rows were limited
- #2070, Restrict generated many-to-many relationships - @steve-chavez
+ A primary key that contains the foreign key columns is now needed for generating many-to-many relationships.
- #2277, Views now are not detected when embedding using the column or FK as target (`/view?select=*,column(*)`) - @steve-chavez
+ This embedding form was easily made ambiguous whenever a new view was added.
+ You can use computed relationships to keep this embedding form working
- #2312, Using `Prefer: return=representation` no longer returns a `Location` header - @laurenceisla
- #1984, For the cases where one to one relationships are detected, json objects will be returned instead of json arrays of length 1
+ If you wish to override this behavior, you can use computed relationships to return arrays again
## [9.0.1] - 2022-06-03
### Fixed
- #2165, Fix json/jsonb columns should not have type in OpenAPI spec - @clrnd
- #2020, Execute deferred constraint triggers when using `Prefer: tx=rollback` - @wolfgangwalther
- #2077, Fix `is` not working with upper or mixed case values like `NULL, TrUe, FaLsE` - @steve-chavez
- #2024, Fix schema cache loading when views with XMLTABLE and DEFAULT are present - @wolfgangwalther
- #1724, Fix wrong CORS header Authentication -> Authorization - @wolfgangwalther
- #2120, Fix reading database configuration properly when `=` is present in value - @wolfgangwalther
- #2135, Remove trigger functions from schema cache and OpenAPI output, because they can't be called directly anyway. - @wolfgangwalther
- #2101, Remove aggregates, procedures and window functions from the schema cache and OpenAPI output. - @wolfgangwalther
- #2153, Fix --dump-schema running with a wrong PG version. - @wolfgangwalther
- #2042, Keep working when EMFILE(Too many open files) is reached. - @steve-chavez
- #2147, Ignore `Content-Type` headers for `GET` requests when calling RPCs. - @laurenceisla
+ Previously, `GET` without parameters, but with `Content-Type: text/plain` or `Content-Type: application/octet-stream` would fail with `404 Not Found`, even if a function without arguments was available.
- #2239, Fix misleading disambiguation error where the content of the `relationship` key looks like valid syntax - @laurenceisla
- #2294, Disable parallel GC for better performance on higher core CPUs - @steve-chavez
- #1076, Fix using CPU while idle - @steve-chavez
## [9.0.0] - 2021-11-25 ## [9.0.0] - 2021-11-25
### Added ### Added
-3
View File
@@ -2,9 +2,6 @@
[![Donate](https://img.shields.io/badge/Donate-Patreon-orange.svg?colorB=F96854)](https://www.patreon.com/postgrest) [![Donate](https://img.shields.io/badge/Donate-Patreon-orange.svg?colorB=F96854)](https://www.patreon.com/postgrest)
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/postgrest) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/postgrest)
<a href="https://heroku.com/deploy?template=https://github.com/PostgREST/postgrest">
<img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy">
</a>
[![Join the chat at https://gitter.im/begriffs/postgrest](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/begriffs/postgrest) [![Join the chat at https://gitter.im/begriffs/postgrest](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/begriffs/postgrest)
[![Docs](https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat)](http://postgrest.org) [![Docs](https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat)](http://postgrest.org)
[![Docker Stars](https://img.shields.io/docker/pulls/postgrest/postgrest.svg)](https://hub.docker.com/r/postgrest/postgrest/) [![Docker Stars](https://img.shields.io/docker/pulls/postgrest/postgrest.svg)](https://hub.docker.com/r/postgrest/postgrest/)
-59
View File
@@ -1,59 +0,0 @@
{
"name": "PostgREST",
"description": "RESTful API for any PostgreSQL database.",
"logo": "https://avatars2.githubusercontent.com/u/15115011",
"repository": "https://github.com/PostgREST/postgrest",
"env": {
"BUILDPACK_URL": {
"description": "Heroku buildpack for deploying Haskell applications",
"value": "https://github.com/PostgREST/postgrest-heroku"
},
"POSTGREST_VER": {
"description": "Version of PostgREST to deploy",
"value": "8.0.0"
},
"DB_URI": {
"description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
"required": true
},
"DB_SCHEMA": {
"description": "The database schema to expose to REST clients. Tables, views and stored procedures in this schema will get API endpoints",
"required": true,
"value": "public"
},
"DB_ANON_ROLE": {
"description": "The database role to use when executing commands on behalf of unauthenticated clients",
"required": true
},
"DB_POOL": {
"description": "Number of connections to keep open in PostgRESTs database pool",
"required": false,
"value": "10"
},
"SERVER_PROXY_URI": {
"description": "Overrides the base URL used within the OpenAPI self-documentation hosted at the API root path",
"required": false
},
"JWT_SECRET": {
"description": "The secret used to decode JWT tokens clients provide for authentication",
"required": false
},
"SECRET_IS_BASE64": {
"description": "When this is set to true, the value derived from jwt-secret will be treated as a base64 encoded secret",
"required": false,
"value": "false"
},
"JWT_AUD": {
"description": "The audience that should be validated if the JWT token contains an aud claim",
"required": false
},
"MAX_ROWS": {
"description": "A hard limit to the number of rows PostgREST will fetch from a view, table, or stored procedure",
"required": false
},
"PRE_REQUEST": {
"description": "A schema-qualified stored procedure name to call right after switching roles for a client request",
"required": false
}
}
}
+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: 890a0a16cf57dd401420fdc6c7d576fb696003bc
+36 -20
View File
@@ -1,9 +1,11 @@
{ system ? builtins.currentSystem }:
let let
name = name =
"postgrest"; "postgrest";
compiler = compiler =
"ghc8107"; "ghc924";
# PostgREST source files, filtered based on the rules in the .gitignore files # PostgREST source files, filtered based on the rules in the .gitignore files
# and file extensions. We want to include as litte as possible, as the files # and file extensions. We want to include as litte as possible, as the files
@@ -34,24 +36,27 @@ 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; })
allOverlays.slocat
]; ];
# Evaluated expression of the Nixpkgs repository. # Evaluated expression of the Nixpkgs repository.
pkgs = pkgs =
import nixpkgs { inherit overlays; }; import nixpkgs { inherit overlays system; };
postgresqlVersions = postgresqlVersions =
[ [
{ name = "postgresql-14"; postgresql = pkgs.postgresql_14; } { name = "postgresql-15"; postgresql = pkgs.postgresql_15.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-13"; postgresql = pkgs.postgresql_13; } { name = "postgresql-14"; postgresql = pkgs.postgresql_14.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-12"; postgresql = pkgs.postgresql_12; } { name = "postgresql-13"; postgresql = pkgs.postgresql_13.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-11"; postgresql = pkgs.postgresql_11; } { name = "postgresql-12"; postgresql = pkgs.postgresql_12.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-10"; postgresql = pkgs.postgresql_10; } { name = "postgresql-11"; postgresql = pkgs.postgresql_11.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-9.6"; postgresql = pkgs.postgresql_9_6; } { name = "postgresql-10"; postgresql = pkgs.postgresql_10.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-9.6"; postgresql = pkgs.postgresql_9_6.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
]; ];
patches = patches =
@@ -61,10 +66,16 @@ let
postgrest = postgrest =
pkgs.haskell.packages."${compiler}".callCabal2nix name src { }; pkgs.haskell.packages."${compiler}".callCabal2nix name src { };
# Function that derives a fully static Haskell package based on # Functionality that derives a fully static Haskell package based on
# nh2/static-haskell-nix # nh2/static-haskell-nix
staticHaskellPackage = staticHaskellPackage =
import nix/static-haskell-package.nix { inherit nixpkgs compiler patches allOverlays; }; import nix/static-haskell-package.nix { inherit nixpkgs system compiler patches allOverlays; };
# Static executable.
postgrestStatic =
lib.justStaticExecutables (lib.dontCheck (staticHaskellPackage name src).package);
packagesStatic = (staticHaskellPackage name src).survey;
# Options passed to cabal in dev tools and tests # Options passed to cabal in dev tools and tests
devCabalOptions = devCabalOptions =
@@ -90,10 +101,6 @@ rec {
postgrestPackage = postgrestPackage =
lib.dontCheck postgrest; lib.dontCheck postgrest;
# Static executable.
postgrestStatic =
lib.justStaticExecutables (lib.dontCheck (staticHaskellPackage name src));
# Profiled dynamic executable. # Profiled dynamic executable.
postgrestProfiled = postgrestProfiled =
lib.enableExecutableProfiling ( lib.enableExecutableProfiling (
@@ -115,13 +122,16 @@ rec {
cabalTools = cabalTools =
pkgs.callPackage nix/tools/cabalTools.nix { inherit devCabalOptions postgrest; }; pkgs.callPackage nix/tools/cabalTools.nix { inherit devCabalOptions postgrest; };
withTools =
pkgs.callPackage nix/tools/withTools.nix { inherit cabalTools devCabalOptions postgresqlVersions postgrest; };
# Development tools. # Development tools.
devTools = devTools =
pkgs.callPackage nix/tools/devTools.nix { inherit tests style devCabalOptions hsie withTools; }; pkgs.callPackage nix/tools/devTools.nix { inherit tests style devCabalOptions hsie withTools; };
# Docker images and loading script. # Load testing tools.
docker = loadtest =
pkgs.callPackage nix/tools/docker { postgrest = postgrestStatic; }; pkgs.callPackage nix/tools/loadtest.nix { inherit withTools; };
# Script for running memory tests. # Script for running memory tests.
memory = memory =
@@ -145,8 +155,14 @@ rec {
inherit postgrest devCabalOptions withTools; inherit postgrest devCabalOptions withTools;
ghc = pkgs.haskell.compiler."${compiler}"; ghc = pkgs.haskell.compiler."${compiler}";
inherit (pkgs.haskell.packages."${compiler}") hpc-codecov; inherit (pkgs.haskell.packages."${compiler}") hpc-codecov;
inherit (pkgs.haskell.packages."${compiler}") weeder;
}; };
} // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux rec {
# Static executable.
inherit postgrestStatic;
inherit packagesStatic;
withTools = # Docker images and loading script.
pkgs.callPackage nix/tools/withTools.nix { inherit postgresqlVersions; }; docker =
pkgs.callPackage nix/tools/docker { postgrest = postgrestStatic; };
} }
+1 -6
View File
@@ -2,15 +2,11 @@
module Main (main) where module Main (main) where
import qualified Data.Map.Strict as M
import System.IO (BufferMode (..), hSetBuffering) import System.IO (BufferMode (..), hSetBuffering)
import qualified PostgREST.App as App import qualified PostgREST.App as App
import qualified PostgREST.CLI as CLI import qualified PostgREST.CLI as CLI
import PostgREST.Config (readPGRSTEnvironment)
import Protolude import Protolude
#ifndef mingw32_HOST_OS #ifndef mingw32_HOST_OS
@@ -20,8 +16,7 @@ import qualified PostgREST.Unix as Unix
main :: IO () main :: IO ()
main = do main = do
setBuffering setBuffering
hasPGRSTEnv <- not . M.null <$> readPGRSTEnvironment opts <- CLI.readCLIShowHelp
opts <- CLI.readCLIShowHelp hasPGRSTEnv
CLI.main installSignalHandlers runAppInSocket opts CLI.main installSignalHandlers runAppInSocket opts
installSignalHandlers :: App.SignalHandlerInstaller installSignalHandlers :: App.SignalHandlerInstaller
+60 -22
View File
@@ -5,24 +5,14 @@ for developing, testing and building PostgREST.
## Getting started with Nix ## Getting started with Nix
You'll need to [get Nix](https://nixos.org/download.html). The installer will You'll need to [get Nix](https://nixos.org/download.html). Follow the recommended installation for your operating system from the official download website.
create your Nix store in the `/nix/` directory, where all build artifacts and
their dependencies will be stored. It will also link the Nix executables like
`nix-env`, `nix-build` and `nix-shell` into your PATH. Nix will manage all
other PostgREST dependencies from here on out. To clean up older build
artifacts from the `/nix/store`, you can run `nix-collect-garbage`.
If you are on a system that does not support nix, for example Windows, you can
run the nix development environment in a docker container. Inside the `nix/`
directory run `docker-compose run --rm nix` to start the docker container. This
will set up the binary cache and launch `nix-shell` automatically.
## Building PostgREST ## Building PostgREST
To build PostgREST from your local checkout of the repository, run: To build PostgREST from your local checkout of the repository, run:
```bash ```bash
nix-build --attr postgrestPackage $ nix-build --attr postgrestPackage
``` ```
@@ -39,10 +29,10 @@ We recommend that you use the PostgREST binary cache on
```bash ```bash
# Install cachix: # Install cachix:
nix-env -iA cachix -f https://cachix.org/api/v1/install $ nix-env -iA cachix -f https://cachix.org/api/v1/install
# Set cachix up to use the PostgREST binary cache: # Set cachix up to use the PostgREST binary cache:
cachix use postgrest $ cachix use postgrest
``` ```
@@ -56,7 +46,7 @@ following command will put you into a new shell that has GHC and Cabal on the
PATH: PATH:
```bash ```bash
nix-shell $ nix-shell
``` ```
@@ -146,16 +136,16 @@ Note: Once inside nix-shell, the utilities work from any directory inside
the PostgREST repo. Paths are resolved relative to the repo root: the PostgREST repo. Paths are resolved relative to the repo root:
```bash ```bash
$ cd src [nix-shell]$ cd src
# Even though the current directory is ./src, the config path must still start # Even though the current directory is ./src, the config path must still start
# from the repo root: # from the repo root:
$ postgrest-run test/io-tests/configs/simple.conf [nix-shell]$ postgrest-run test/io/configs/simple.conf
``` ```
## Testing ## Testing
In nix-shell, you'll find utility scripts that make it very easy to run the In nix-shell, you'll find utility scripts that make it very easy to run our
Haskell test suite, including setting up all required dependencies and test suite, including setting up all required dependencies and
temporary test databases: temporary test databases:
```bash ```bash
@@ -177,12 +167,60 @@ run with `postgrest-test-io`. The test runner under the hood is
```bash ```bash
# Filter the tests to run by name, including all that contain 'config': # Filter the tests to run by name, including all that contain 'config':
postgrest-test-io -k config [nix-shell]$ postgrest-test-io -k config
# Run tests in parallel using xdist, specifying the number of processes: # Run tests in parallel using xdist, specifying the number of processes:
postgrest-test-io -n auto [nix-shell]$ postgrest-test-io -n auto
postgrest-test-io -n 8 [nix-shell]$ postgrest-test-io -n 8
```
The memory tests check that we don't surpass a memory threshold for big request bodies.
```bash
# Build the dependencies needed for the memory test
$ nix-shell --arg memory true
# Run the memory test
[nix-shell]$ postgrest-test-memory
```
The loadtests ensure that performance doesn't drop on a change. Underlyingly they use
[vegeta](https://github.com/tsenart/vegeta).
```bash
# Run the loadtests on the latest commit(HEAD)
[nix-shell]$ postgrest-loadtest
# You can loadtest comparing to a different branch
[nix-shell]$ postgrest-loadtest-against master
# You can simulate latency client/postgrest and postgrest/database
[nix-shell]$ PGRST_DELAY=5ms PGDELAY=5ms postgrest-loadtest
# You can build postgrest directly with cabal for faster iteration
[nix-shell]$ PGRST_BUILD_CABAL=1 postgrest-loadtest
# Produce a markdown report to be used on CI
[nix-shell]$ postgrest-loadtest-report
```
doctests for some of our modules are also available:
```bash
[nix-shell]$ postgrest-test-doctest
```
## Code coverage
Code coverage is available under the `postgrest-coverage` command. This will produce a `./coverage` directory that can be visualized with a simple http server.
```bash
# Will run all the tests and produce a coverage dir
[nix-shell]$ postgrest-coverage
# Visualize the output
[nix-shell]$ cd coverage
[nix-shell]$ python -mSimpleHTTPServer 8080
``` ```
## Linting and styling code ## Linting and styling code
+8 -8
View File
@@ -24,21 +24,22 @@ import qualified Data.Text as T
import qualified Data.Text.IO as T import qualified Data.Text.IO as T
import qualified Dot import qualified Dot
import qualified GHC import qualified GHC
import qualified GHC.Paths
import qualified Language.Haskell.GHC.ExactPrint.Parsers as ExactPrint import qualified Language.Haskell.GHC.ExactPrint.Parsers as ExactPrint
import qualified Options.Applicative as O import qualified Options.Applicative as O
import qualified System.FilePath as FP import qualified System.FilePath as FP
import Bag (bagToList)
import Data.Aeson.Encode.Pretty (encodePretty) import Data.Aeson.Encode.Pretty (encodePretty)
import Data.Function ((&)) import Data.Function ((&))
import Data.List (intercalate) import Data.List (intercalate)
import Data.Maybe (catMaybes, mapMaybe) import Data.Maybe (catMaybes, mapMaybe)
import Data.Text (Text) import Data.Text (Text)
import GHC.Data.Bag (bagToList)
import GHC.Generics (Generic) import GHC.Generics (Generic)
import GHC.Hs.Extension (GhcPs) import GHC.Hs.Extension (GhcPs)
import Module (moduleNameString) import GHC.Types.Name.Occurrence (occNameString)
import OccName (occNameString) import GHC.Types.Name.Reader (rdrNameOcc)
import RdrName (rdrNameOcc) import GHC.Unit.Module.Name (moduleNameString)
import System.Directory.Recursive (getFilesRecursive) import System.Directory.Recursive (getFilesRecursive)
import System.Exit (exitFailure) import System.Exit (exitFailure)
@@ -197,11 +198,11 @@ sourceSymbols source = do
return $ concatMap (importSymbols source filepath . GHC.unLoc) hsmodImports return $ concatMap (importSymbols source filepath . GHC.unLoc) hsmodImports
-- | Parse a Haskell module -- | Parse a Haskell module
parseModule :: String -> IO (GHC.HsModule GhcPs) parseModule :: FilePath -> IO GHC.HsModule
parseModule filepath = do parseModule filepath = do
result <- ExactPrint.parseModule filepath result <- ExactPrint.parseModule GHC.Paths.libdir filepath
case result of case result of
Right (_, hsmod) -> Right hsmod ->
return $ GHC.unLoc hsmod return $ GHC.unLoc hsmod
Left errs -> Left errs ->
fail $ "Errors with " <> show filepath <> ":\n " fail $ "Errors with " <> show filepath <> ":\n "
@@ -212,7 +213,6 @@ parseModule filepath = do
-- If the import is a wildcard, i.e. no symbols are selected for import, then -- If the import is a wildcard, i.e. no symbols are selected for import, then
-- only one item is returned. -- only one item is returned.
importSymbols :: FilePath -> FilePath -> GHC.ImportDecl GhcPs -> [ImportedSymbol] importSymbols :: FilePath -> FilePath -> GHC.ImportDecl GhcPs -> [ImportedSymbol]
importSymbols _ _ (GHC.XImportDecl _) = mempty
importSymbols source filepath GHC.ImportDecl{..} = importSymbols source filepath GHC.ImportDecl{..} =
case ideclHiding of case ideclHiding of
Just (hiding, syms) -> Just (hiding, syms) ->
+8 -3
View File
@@ -16,15 +16,20 @@ let
ghc = ghcWithPackages modules; ghc = ghcWithPackages modules;
hsie = hsie =
runCommand "haskellimports" { inherit name src; } runCommand "haskellimports" { inherit name src; }
"${ghc}/bin/ghc -O -Werror -Wall -package ghc $src -o $out"; ''
cd $TMP
cp $src $TMP/Main.hs
${ghc}/bin/ghc -O -Werror -Wall -package ghc Main.hs -o Main
cp Main $out
'';
bin = bin =
runCommand name { inherit hsie name; } runCommand name { inherit hsie name; }
'' ''
mkdir -p $out/bin mkdir -p $out/bin
ln -s $hsie $out/bin/$name ln -s $hsie $out/bin/$name
''; '';
bashCompletion = bash-completion =
runCommand "${name}-bash-completion" { inherit bin name; } runCommand "${name}-bash-completion" { inherit bin name; }
"$bin/bin/$name --bash-completion-script $bin/bin/$name > $out"; "$bin/bin/$name --bash-completion-script $bin/bin/$name > $out";
in in
hsie // { inherit bashCompletion bin; } hsie // { inherit bash-completion bin; }
+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 = "2021-11-02"; date = "2023-03-25";
rev = "7053541084bf5ce2921ef307e5585d39d7ba8b3f"; rev = "dbf5322e93bcc6cfc52268367a8ad21c09d76fea";
tarballHash = "1flhh5d4zy43x6060hvzjb5hi5cmc51ivc0nwmija9n8d35kcc4x"; tarballHash = "0lwk4v9dkvd28xpqch0b0jrac4xl9lwm6snrnzx8k5lby72kmkng";
} }
+3 -3
View File
@@ -1,11 +1,11 @@
# Creates an environment that exposes bashCompletion arguments from all checkedShellScripts # Creates an environment that exposes bash-completion arguments from all checkedShellScripts
{ buildEnv }: { buildEnv }:
{ name { name
, tools , tools
, extra ? { } , extra ? { }
}: }:
let let
bashCompletion = builtins.map (tool: tool.bashCompletion) tools; bash-completion = builtins.map (tool: tool.bash-completion) tools;
env = buildEnv { env = buildEnv {
inherit name; inherit name;
@@ -13,4 +13,4 @@ let
}; };
in in
env // { inherit bashCompletion; } // extra env // { inherit bash-completion; } // extra
@@ -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
@@ -14,19 +14,18 @@
{ name { name
, docs , docs
, args ? [ ] , args ? [ ]
, addCommandCompletion ? false , positionalCompletion ? ""
, inRootDir ? false , inRootDir ? false
, redirectTixFiles ? true , redirectTixFiles ? true
, withEnv ? null , withEnv ? null
, withPath ? [ ] , withPath ? [ ]
, withTmpDir ? false , withTmpDir ? false
}: text: }: text:
let
argsTemplate =
let let
# square brackets are a pain to escape - if even possible. just don't use them... # square brackets are a pain to escape - if even possible. just don't use them...
escapedDocs = builtins.replaceStrings [ "\n" ] [ " \\n" ] docs; escape = builtins.replaceStrings [ "\n" ] [ " \\n" ];
in
argsTemplate =
writeTextFile { writeTextFile {
inherit name; inherit name;
destination = "/${name}.m4"; # destination is needed to have the proper basename for completion destination = "/${name}.m4"; # destination is needed to have the proper basename for completion
@@ -37,7 +36,7 @@ let
# stripping the /nix/store/... path for nicer display # stripping the /nix/store/... path for nicer display
BASH_ARGV0="$(basename "$0")" BASH_ARGV0="$(basename "$0")"
# ARG_HELP([${name}], [${escapedDocs}]) # ARG_HELP([${name}], [${escape docs}])
${lib.strings.concatMapStrings (arg: "# " + arg) args} ${lib.strings.concatMapStrings (arg: "# " + arg) args}
# ARG_POSITIONAL_DOUBLEDASH() # ARG_POSITIONAL_DOUBLEDASH()
# ARG_DEFAULTS_POS() # ARG_DEFAULTS_POS()
@@ -59,14 +58,14 @@ let
sed '/_positionals_count + 1/a\\t\t\t\tset -- "''${@:1:1}" "--" "''${@:2}"' -i $out sed '/_positionals_count + 1/a\\t\t\t\tset -- "''${@:1:1}" "--" "''${@:2}"' -i $out
''; '';
bashCompletion = bash-completion =
runCommand "${name}-completion" { } ( runCommand "${name}-completion" { } (
'' ''
${argbash}/bin/argbash --type completion --strip all ${argsTemplate}/${name}.m4 > $out ${argbash}/bin/argbash --type completion --strip all ${argsTemplate}/${name}.m4 > $out
'' ''
+ lib.optionalString addCommandCompletion '' + lib.optionalString (positionalCompletion != "") ''
sed 's/COMPREPLY.*compgen -o bashdefault .*$/_command/' -i $out sed 's#COMPREPLY.*compgen -o bashdefault .*$#${escape positionalCompletion}#' -i $out
'' ''
); );
@@ -78,7 +77,7 @@ let
text = text =
'' ''
#!${bash_5}/bin/bash #!${bash}/bin/bash
source ${argsParser} source ${argsParser}
set -euo pipefail set -euo pipefail
'' ''
@@ -139,4 +138,4 @@ let
script = script =
runCommand name { inherit bin name; } "ln -s $bin/bin/$name $out"; runCommand name { inherit bin name; } "ln -s $bin/bin/$name $out";
in in
script // { inherit bin bashCompletion; } script // { inherit bin bash-completion; }
+2
View File
@@ -3,7 +3,9 @@
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;
slocat = import ./slocat.nix;
} }
+2 -2
View File
@@ -12,8 +12,8 @@ self: super:
gitignoreSrc = super.fetchFromGitHub { gitignoreSrc = super.fetchFromGitHub {
owner = "hercules-ci"; owner = "hercules-ci";
repo = "gitignore"; repo = "gitignore";
rev = "211907489e9f198594c0eb0ca9256a1949c9d412"; rev = "a20de23b925fd8264fd7fad6454652e142fd7f73";
sha256 = "06j7wpvj54khw0z10fjyi31kpafkr6hi1k0di13k1xp8kywvfyx8"; sha256 = "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=";
}; };
in in
(super.callPackage gitignoreSrc { }).gitignoreSource; (super.callPackage gitignoreSrc { }).gitignoreSource;
+37 -9
View File
@@ -13,21 +13,49 @@ let
# { # {
# pkg = "protolude"; # pkg = "protolude";
# ver = "0.3.0"; # ver = "0.3.0";
# sha256 = "0iwh4wsjhb7pms88lw1afhdal9f86nrrkkvv65f9wxbd1b159n72"; # sha256 = "<sha256>";
# } # }
# { }; # { };
# #
# To get the sha256: # To temporarily pin unreleased versions from GitHub:
# nix-prefetch-url --unpack https://hackage.haskell.org/package/protolude-0.3.0/protolude-0.3.0.tar.gz # <name> =
# prev.callCabal2nixWithOptions "<name>" (super.fetchFromGitHub {
# owner = "<owner>";
# repo = "<repo>";
# rev = "<commit>";
# sha256 = "<sha256>";
# }) "--subpath=<subpath>" {};
#
# To fill in the sha256:
# update-nix-fetchgit nix/overlays/haskell-packages.nix
hasql-dynamic-statements = postgresql-libpq = lib.dontCheck
lib.dontCheck (lib.unmarkBroken prev.hasql-dynamic-statements); (prev.callCabal2nix "postgresql-libpq"
(super.fetchFromGitHub {
owner = "PostgREST";
repo = "postgresql-libpq";
rev = "890a0a16cf57dd401420fdc6c7d576fb696003bc"; # master
sha256 = "1wmyhldk0k14y8whp1p4akrkqxf5snh8qsbm7fv5f7kz95nyffd0";
})
{ });
hasql-implicits = hasql-notifications = lib.dontCheck
lib.dontCheck (lib.unmarkBroken prev.hasql-implicits); (prev.callHackageDirect
{
pkg = "hasql-notifications";
ver = "0.2.0.5";
sha256 = "sha256-KV36zs/RTgJh/oBBPZaNiQshusmakWnjYTcdoNa4JLA=";
}
{ });
ptr = hasql-pool = lib.dontCheck
lib.dontCheck (lib.unmarkBroken prev.ptr); (prev.callHackageDirect
{
pkg = "hasql-pool";
ver = "0.10";
sha256 = "sha256-kHzoqtNV9BFWnn1h560JRqMooQRwxokVKgDRBexamNI=";
}
{ });
} // 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 ];
});
} }
+28 -14
View File
@@ -2,19 +2,33 @@ self: super:
# Overlay that adds legacy versions of PostgreSQL that are supported by # Overlay that adds legacy versions of PostgreSQL that are supported by
# PostgREST. # PostgREST.
{ {
# PostgreSQL 9.5 was removed from Nixpkgs with # PostgreSQL 9.6 was removed from Nixpkgs with
# https://github.com/NixOS/nixpkgs/commit/72ab382fb6b729b0d654f2c03f5eb25b39f11fbb # https://github.com/NixOS/nixpkgs/commit/757dd008b2f2926fc0f7688fa8189f930ea47521
# We pin its parent commit to get the last version that was available. # We pin its parent commit to get the last version that was available.
# postgresql_9_5 = postgresql_9_6 =
# let let
# rev = "55ac7d4580c9ab67848c98cb9519317a1cc399c8"; rev = "571cbf3d1db477058303cef8754fb85a14e90eb7";
# tarballHash = "02ffj9f8s1hwhmxj85nx04sv64qb6jm7w0122a1dz9n32fymgklj"; tarballHash = "0q74wn418i1bn5sssacmw8ykpmqvzr0s93sj6pbs3rf6bf134fkz";
# pinnedPkgs =
# pinnedPkgs = builtins.fetchTarball {
# builtins.fetchTarball { url = "https://github.com/nixos/nixpkgs/archive/${rev}.tar.gz";
# url = "https://github.com/nixos/nixpkgs/archive/${rev}.tar.gz"; sha256 = tarballHash;
# sha256 = tarballHash; };
# }; in
# in (import pinnedPkgs { }).pkgs.postgresql_9_6;
# (import pinnedPkgs { }).pkgs.postgresql_9_5;
# 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;
} }
+13
View File
@@ -0,0 +1,13 @@
final: prev:
{
slocat = prev.buildGoModule {
name = "slocat";
src = prev.fetchFromGitHub {
owner = "robx";
repo = "slocat";
rev = "52e7512c6029fd00483e41ccce260a3b4b9b3b64";
sha256 = "sha256-qn6luuh5wqREu3s8RfuMCP5PKdS2WdwPrujRYTpfzQ8=";
};
vendorSha256 = "sha256-pQpattmS9VmO3ZIQUFn66az8GSmB4IvYhTTCFn6SUmo=";
};
}
+8 -3
View File
@@ -18,7 +18,12 @@
done done
''; '';
# See: https://github.com/NixOS/nixpkgs/pull/87879 static-haskell-nix-ncurses =
nixpkgs-openssl-split-runtime-dependencies-of-static-builds = ./static-haskell-nix-ncurses.patch;
./nixpkgs-openssl-split-runtime-dependencies-of-static-builds.patch; static-haskell-nix-ghc-bignum =
./static-haskell-nix-ghc-bignum.patch;
static-haskell-nix-openssl =
./static-haskell-nix-openssl.patch;
postgresql-atexit =
./postgresql-atexit.patch;
} }
@@ -1,76 +0,0 @@
diff --git a/pkgs/development/libraries/openssl/default.nix b/pkgs/development/libraries/openssl/default.nix
index d4be8cc2428..3979698711f 100644
--- a/pkgs/development/libraries/openssl/default.nix
+++ b/pkgs/development/libraries/openssl/default.nix
@@ -50,9 +50,21 @@ let
substituteInPlace crypto/async/arch/async_posix.h \
--replace '!defined(__ANDROID__) && !defined(__OpenBSD__)' \
'!defined(__ANDROID__) && !defined(__OpenBSD__) && 0'
+ '' + optionalString static
+ # On static builds, the ENGINESDIR will be empty, but its path will be
+ # compiled into the library. In order to minimize the runtime dependencies
+ # of packages that statically link openssl, we move it into the OPENSSLDIR,
+ # which will be separated into the 'etc' output.
+ ''
+ substituteInPlace Configurations/unix-Makefile.tmpl \
+ --replace 'ENGINESDIR=$(libdir)/engines-{- $sover_dirname -}' \
+ 'ENGINESDIR=$(OPENSSLDIR)/engines-{- $sover_dirname -}'
'';
- outputs = [ "bin" "dev" "out" "man" ] ++ optional withDocs "doc";
+ outputs = [ "bin" "dev" "out" "man" ]
+ ++ optional withDocs "doc"
+ # Separate output for the runtime dependencies of the static build.
+ ++ optional static "etc";
setOutputFlags = false;
separateDebugInfo =
!stdenv.hostPlatform.isDarwin &&
@@ -101,7 +113,17 @@ let
configureFlags = [
"shared" # "shared" builds both shared and static libraries
"--libdir=lib"
- "--openssldir=etc/ssl"
+ (if !static then
+ "--openssldir=etc/ssl"
+ else
+ # Separate the OPENSSLDIR into its own output, as its path will be
+ # compiled into 'libcrypto.a'. This makes it a runtime dependency of
+ # any package that statically links openssl, so we want to keep that
+ # output minimal. We need to prepend '/.' to the path in order to make
+ # it appear absolute before variable expansion, the 'prefix' would be
+ # prepended to it otherwise.
+ "--openssldir=/.$(etc)/etc/ssl"
+ )
] ++ lib.optionals withCryptodev [
"-DHAVE_CRYPTODEV"
"-DUSE_CRYPTODEV_DIGESTS"
@@ -131,6 +153,9 @@ let
if [ -n "$(echo $out/lib/*.so $out/lib/*.dylib $out/lib/*.dll)" ]; then
rm "$out/lib/"*.a
fi
+
+ # 'etc' is a separate output on static builds only.
+ etc=$out
'' + lib.optionalString (!stdenv.hostPlatform.isWindows)
# Fix bin/c_rehash's perl interpreter line
#
@@ -152,14 +177,15 @@ let
mv $out/include $dev/
# remove dependency on Perl at runtime
- rm -r $out/etc/ssl/misc
+ rm -r $etc/etc/ssl/misc
- rmdir $out/etc/ssl/{certs,private}
+ rmdir $etc/etc/ssl/{certs,private}
'';
postFixup = lib.optionalString (!stdenv.hostPlatform.isWindows) ''
- # Check to make sure the main output doesn't depend on perl
- if grep -r '${buildPackages.perl}' $out; then
+ # Check to make sure the main output and the static runtime dependencies
+ # don't depend on perl
+ if grep -r '${buildPackages.perl}' $out $etc; then
echo "Found an erroneous dependency on perl ^^^" >&2
exit 1
fi
+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 70afbbc..28cb0e9 100644
--- a/survey/default.nix
+++ b/survey/default.nix
@@ -81,6 +81,7 @@ let
# `.override` and the likes).
isProperHaskellPackage = val:
lib.isDerivation val && # must pass lib.isDerivation
+ val.pname != "ghc-bignum" &&
val ? env; # must have an .env key
# Function that tells us if a given Haskell package has an executable.
@@ -0,0 +1,13 @@
diff --git a/survey/default.nix b/survey/default.nix
index 46d8066..a47f214 100644
--- a/survey/default.nix
+++ b/survey/default.nix
@@ -1519,7 +1519,7 @@ let
[
"--enable-executable-static" # requires `useFixedCabal`
# `enableShared` seems to be required to avoid `recompile with -fPIC` errors on some packages.
- "--extra-lib-dirs=${final.ncurses.override { enableStatic = true; enableShared = true; }}/lib"
+ "--extra-lib-dirs=${final.ncurses.override { enableStatic = true; }}/lib"
]
# TODO Figure out why this and the below libffi are necessary.
# `working` and `workingStackageExecutables` don't seem to need that,
@@ -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; });
+12 -14
View File
@@ -1,5 +1,5 @@
# Derive a fully static Haskell package based on musl instead of glibc. # Derive a fully static Haskell package based on musl instead of glibc.
{ nixpkgs, compiler, patches, allOverlays }: { nixpkgs, system, compiler, patches, allOverlays }:
name: src: name: src:
let let
@@ -17,14 +17,9 @@ let
patches.applyPatches "patched-static-haskell-nix" patches.applyPatches "patched-static-haskell-nix"
static-haskell-nix static-haskell-nix
[ [
# No patches currently required. patches.static-haskell-nix-ncurses
]; patches.static-haskell-nix-ghc-bignum
patches.static-haskell-nix-openssl
patchedNixpkgs =
patches.applyPatches "patched-nixpkgs"
nixpkgs
[
patches.nixpkgs-openssl-split-runtime-dependencies-of-static-builds
]; ];
extraOverrides = extraOverrides =
@@ -40,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
@@ -50,13 +45,13 @@ let
) )
]; ];
# Apply our overlay to the given pkgs. # Apply our overlay to nixpkgs.
normalPkgs = normalPkgs =
import patchedNixpkgs { inherit overlays; }; import nixpkgs { inherit overlays system; };
defaultCabalPackageVersionComingWithGhc = defaultCabalPackageVersionComingWithGhc =
{ {
ghc8107 = "Cabal_3_2_1_0"; ghc924 = "Cabal_3_6_3_0";
}."${compiler}"; }."${compiler}";
# The static-haskell-nix 'survey' derives a full static set of Haskell # The static-haskell-nix 'survey' derives a full static set of Haskell
@@ -64,4 +59,7 @@ let
survey = survey =
import "${patched-static-haskell-nix}/survey" { inherit normalPkgs compiler defaultCabalPackageVersionComingWithGhc; }; import "${patched-static-haskell-nix}/survey" { inherit normalPkgs compiler defaultCabalPackageVersionComingWithGhc; };
in in
survey.haskellPackages."${name}" {
inherit survey;
package = survey.haskellPackages."${name}";
}
+14 -2
View File
@@ -28,6 +28,8 @@ let
'' ''
# clean old coverage data, too # clean old coverage data, too
rm -rf .hpc coverage rm -rf .hpc coverage
# clean old hie files
find . -name "*.hie" -type f -delete
exec ${cabal-install}/bin/cabal v2-clean exec ${cabal-install}/bin/cabal v2-clean
''; '';
@@ -35,12 +37,22 @@ let
checkedShellScript checkedShellScript
{ {
name = "postgrest-run"; name = "postgrest-run";
docs = "Run PostgREST after buidling it interactively with cabal-install"; docs = "Run PostgREST after building it interactively with cabal-install";
args = [ "ARG_LEFTOVERS([PostgREST arguments])" ]; args =
[
"ARG_USE_ENV([PGRST_DB_ANON_ROLE], [postgrest_test_anonymous], [PostgREST anonymous role])"
"ARG_USE_ENV([PGRST_DB_POOL], [1], [PostgREST pool size])"
"ARG_USE_ENV([PGRST_DB_POOL_ACQUISITION_TIMEOUT], [1], [PostgREST pool size])"
"ARG_LEFTOVERS([PostgREST arguments])"
];
inRootDir = true; inRootDir = true;
withEnv = postgrest.env; withEnv = postgrest.env;
} }
'' ''
export PGRST_DB_ANON_ROLE
export PGRST_DB_POOL
export PGRST_DB_POOL_ACQUISITION_TIMEOUT
exec ${cabal-install}/bin/cabal v2-run ${devCabalOptions} --verbose=0 -- \ exec ${cabal-install}/bin/cabal v2-run ${devCabalOptions} --verbose=0 -- \
postgrest "''${_arg_leftovers[@]}" postgrest "''${_arg_leftovers[@]}"
''; '';
+161 -7
View File
@@ -4,7 +4,10 @@
, checkedShellScript , checkedShellScript
, devCabalOptions , devCabalOptions
, entr , entr
, git
, gnugrep
, graphviz , graphviz
, haskellPackages
, hsie , hsie
, nix , nix
, silver-searcher , silver-searcher
@@ -29,7 +32,7 @@ let
"ARG_POSITIONAL_SINGLE([command], [Command to run])" "ARG_POSITIONAL_SINGLE([command], [Command to run])"
"ARG_LEFTOVERS([command arguments])" "ARG_LEFTOVERS([command arguments])"
]; ];
addCommandCompletion = true; positionalCompletion = "_command";
redirectTixFiles = false; # will be done by sub-command redirectTixFiles = false; # will be done by sub-command
inRootDir = true; inRootDir = true;
} }
@@ -64,23 +67,172 @@ let
name = "postgrest-check"; name = "postgrest-check";
docs = docs =
'' ''
Run most checks that will also run on CI. Run most checks that will also run on CI, but only against the
latest PostgreSQL version.
This currently excludes the memory tests, as those are particularly This currently excludes the memory and spec-idempotence tests,
expensive. as those are particularly expensive.
''; '';
inRootDir = true; inRootDir = true;
} }
'' ''
${withTools}/bin/postgrest-with-all ${tests}/bin/postgrest-test-spec ${tests}/bin/postgrest-test-spec
${withTools}/bin/postgrest-with-all ${tests}/bin/postgrest-test-querycost
${tests}/bin/postgrest-test-doctests ${tests}/bin/postgrest-test-doctests
${tests}/bin/postgrest-test-spec-idempotence
${tests}/bin/postgrest-test-io ${tests}/bin/postgrest-test-io
${style}/bin/postgrest-lint ${style}/bin/postgrest-lint
${style}/bin/postgrest-style-check ${style}/bin/postgrest-style-check
''; '';
gitHooks =
let
name = "postgrest-git-hooks";
in
checkedShellScript
{
inherit name;
docs =
''
Enable or disable git pre-commit and pre-push hooks.
Basic is faster and will only run:
- pre-commit: postgrest-style
- pre-push: postgrest-lint
Full takes a lot more time and will run:
- pre-commit: postgrest-style && postgrest-lint
- pre-push: postgrest-check
Changes made by postgrest-style will be staged automatically.
Example usage:
postgrest-git-hooks disable
postgrest-git-hooks enable basic
postgrest-git-hooks enable full
The "run" operation and "--hook" argument are only used internally.
'';
args =
[
"ARG_POSITIONAL_SINGLE([operation], [Operation])"
"ARG_TYPE_GROUP_SET([OPERATION], [OPERATION], [operation], [disable,enable,run])"
"ARG_POSITIONAL_SINGLE([mode], [Mode], [basic])"
"ARG_TYPE_GROUP_SET([MODE], [MODE], [mode], [basic,full])"
"ARG_OPTIONAL_SINGLE([hook], , [Hook], [pre-commit])"
"ARG_TYPE_GROUP_SET([HOOK], [HOOK], [hook], [pre-commit,pre-push])"
];
positionalCompletion =
''
if test "$prev" == "${name}"; then
COMPREPLY=( $(compgen -W "enable disable" -- "$cur") )
elif test "$prev" == "enable" || test "$prev" == "disable"; then
COMPREPLY=( $(compgen -W "basic full" -- "$cur") )
fi
'';
inRootDir = true;
}
''
if [ run != "$_arg_operation" ]; then
# Remove all hooks first and ignore failures because the file might be missing.
# This assumes that we're only adding lines that include "postgrest-git-hooks"
# to the hook file.
sed -i -e '/postgrest-git-hooks/d' .git/hooks/pre-{commit,push} 2> /dev/null || true
if [ disable != "$_arg_operation" ]; then
# The nix-shell && + nix-shell || pattern makes sure we can run the hook
# in a pure nix-shell, where nix-shell itself is not available, too.
# The $(nix-shell --run "command -v ...") pattern ensures we only need to enable
# the hooks once and still run the latest of our hook scripts, even when we
# update them in the repo.
echo 'command -v nix-shell > /dev/null || postgrest-git-hooks --hook=pre-commit run' "$_arg_mode" \
>> .git/hooks/pre-commit
# shellcheck disable=SC2016
echo 'command -v nix-shell > /dev/null && $(nix-shell --quiet -Q --run "command -v postgrest-git-hooks") --hook=pre-commit run' "$_arg_mode" \
>> .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo 'command -v nix-shell > /dev/null || postgrest-git-hooks --hook=pre-push run' "$_arg_mode" \
>> .git/hooks/pre-push
# shellcheck disable=SC2016
echo 'command -v nix-shell > /dev/null && $(nix-shell --quiet -Q --run "command -v postgrest-git-hooks") --hook=pre-push run' "$_arg_mode" \
>> .git/hooks/pre-push
chmod +x .git/hooks/pre-push
fi
else
# When run from a git hook, the GIT_ environment variables conflict with our withGit helper.
# The following unsets all GIT_ variables.
unset "''${!GIT_@}"
# shellcheck disable=SC2317
function restore () {
ref="$(git stash list --format=format:%gD --grep "$1" -n1)"
# this will avoid merge conflicts when applying the stash
${git}/bin/git restore --source="$ref" .
# restore untracked files, too. could fail with no files
if [ "$(git show --numstat --format=oneline "$ref^3" | wc -l)" -gt 1 ]; then
${git}/bin/git restore --overlay --source="$ref^3" .
fi
${git}/bin/git stash drop "$ref"
}
case "$_arg_mode" in
basic)
case "$_arg_hook" in
pre-commit)
# To be able to automatically add only changes from postgrest-style to the staging area,
# we need to run postgrest-style twice. Otherwise we'd risk merge conflicts when popping
# the stash afterwards.
${style}/bin/postgrest-style
stash="postgrest-git-hooks-$RANDOM"
${git}/bin/git stash push --include-untracked --keep-index -m "$stash"
if [ "$(git stash list --grep $stash)" ]; then
# Only create the stash pop trap, if we actually created a stash.
# Otherwise stash pop will cause havoc.
trap 'restore "$stash"' EXIT
fi
${style}/bin/postgrest-style
${git}/bin/git add .
;;
pre-push)
# Create a clean working tree without any uncomitted changes.
${withTools.withGit} HEAD ${style}/bin/postgrest-lint
;;
esac
;;
full)
case "$_arg_hook" in
pre-commit)
# To be able to automatically add only changes from postgrest-style to the staging area,
# we need to run postgrest-style twice. Otherwise we'd risk merge conflicts when popping
# the stash afterwards.
${style}/bin/postgrest-style
stash="postgrest-git-hooks-$RANDOM"
${git}/bin/git stash push --include-untracked --keep-index -m "$stash"
if [ "$(git stash list --grep $stash)" ]; then
# Only create the stash pop trap, if we actually created a stash.
# Otherwise stash pop will cause havoc.
trap 'restore "$stash"' EXIT
fi
${style}/bin/postgrest-style
${git}/bin/git add .
${style}/bin/postgrest-lint
;;
pre-push)
# Create a clean working tree without any uncomitted changes.
${withTools.withGit} HEAD ${check}
;;
esac
;;
esac
fi
'';
dumpMinimalImports = dumpMinimalImports =
checkedShellScript checkedShellScript
{ {
@@ -146,9 +298,11 @@ buildToolbox
watch watch
pushCachix pushCachix
check check
gitHooks
dumpMinimalImports dumpMinimalImports
hsieMinimalImports hsieMinimalImports
hsieGraphModules hsieGraphModules
hsieGraphSymbols hsieGraphSymbols
]; ];
extra = { inherit pushCachix; };
} }
+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.
+181
View File
@@ -0,0 +1,181 @@
{ buildToolbox
, checkedShellScript
, jq
, python3Packages
, vegeta
, withTools
, writers
}:
let
runner =
checkedShellScript
{
name = "postgrest-loadtest-runner";
docs = "Run vegeta. Assume PostgREST to be running.";
args = [
"ARG_LEFTOVERS([additional vegeta arguments])"
"ARG_USE_ENV([PGRST_SERVER_UNIX_SOCKET], [], [Unix socket to connect to running PostgREST instance])"
];
}
''
# ARG_USE_ENV only adds defaults or docs for environment variables
# We manually implement a required check here
# See also: https://github.com/matejak/argbash/issues/80
: "''${PGRST_SERVER_UNIX_SOCKET:?PGRST_SERVER_UNIX_SOCKET is required}"
${vegeta}/bin/vegeta -cpus 1 attack \
-unix-socket "$PGRST_SERVER_UNIX_SOCKET" \
-max-workers 1 \
-workers 1 \
-rate 0 \
-duration 60s \
"''${_arg_leftovers[@]}"
'';
loadtest =
checkedShellScript
{
name = "postgrest-loadtest";
docs = "Run the vegeta loadtests with PostgREST.";
args = [
"ARG_OPTIONAL_SINGLE([output], [o], [Filename to dump json output to], [./loadtest/result.bin])"
"ARG_OPTIONAL_SINGLE([testdir], [t], [Directory to load tests and fixtures from], [./test/load])"
"ARG_LEFTOVERS([additional vegeta arguments])"
];
inRootDir = true;
}
''
# previously required settings to make this work with older branches
export PGRST_DB_ANON_ROLE="postgrest_test_anonymous"
export PGRST_DB_URI="postgresql://"
export PGRST_DB_SCHEMAS="test"
export PGRST_DB_CONFIG="false"
export PGRST_DB_POOL="1"
export PGRST_DB_TX_END="rollback-allow-override"
export PGRST_LOG_LEVEL="crit"
mkdir -p "$(dirname "$_arg_output")"
abs_output="$(realpath "$_arg_output")"
# shellcheck disable=SC2145
${withTools.withPg} --fixtures "$_arg_testdir"/fixtures.sql \
${withTools.withSlowPg} \
${withTools.withPgrst} \
${withTools.withSlowPgrst} \
sh -c "cd \"$_arg_testdir\" && ${runner} -targets targets.http -output \"$abs_output\" \"''${_arg_leftovers[@]}\""
${vegeta}/bin/vegeta report -type=text "$_arg_output"
'';
loadtestAgainst =
let
name = "postgrest-loadtest-against";
in
checkedShellScript
{
inherit name;
docs =
''
Run the vegeta loadtest twice:
- once on the <target> branch
- once in the current worktree
'';
args = [
"ARG_POSITIONAL_SINGLE([target], [Commit-ish reference to compare with])"
"ARG_LEFTOVERS([additional vegeta arguments])"
];
positionalCompletion =
''
if test "$prev" == "${name}"; then
__gitcomp_nl "$(__git_refs)"
fi
'';
inRootDir = true;
}
''
cat << EOF
Running loadtest on "$_arg_target"...
EOF
# Runs the test files from the current working tree
# to make sure both tests are run with the same files.
# Save the results in the current working tree, too,
# otherwise they'd be lost in the temporary working tree
# created by withTools.withGit.
${withTools.withGit} "$_arg_target" ${loadtest} --output "$PWD/loadtest/$_arg_target.bin" --testdir "$PWD/test/load" "''${_arg_leftovers[@]}"
cat << EOF
Done running on "$_arg_target".
EOF
cat << EOF
Running loadtest on HEAD...
EOF
${loadtest} --output "$PWD/loadtest/head.bin" --testdir "$PWD/test/load" "''${_arg_leftovers[@]}"
cat << EOF
Done running on HEAD.
EOF
'';
reporter =
checkedShellScript
{
name = "postgrest-loadtest-reporter";
docs = "Create a named json report for a single result file.";
args = [
"ARG_POSITIONAL_SINGLE([file], [Filename of result to create report for])"
"ARG_LEFTOVERS([additional vegeta arguments])"
];
inRootDir = true;
}
''
${vegeta}/bin/vegeta report -type=json "$_arg_file" \
| ${jq}/bin/jq --arg branch "$(basename "$_arg_file" .bin)" '. + {branch: $branch}'
'';
toMarkdown =
writers.writePython3 "postgrest-loadtest-to-markdown"
{
libraries = [ python3Packages.pandas python3Packages.tabulate ];
}
''
import sys
import pandas as pd
pd.read_json(sys.stdin) \
.set_index('param') \
.drop(['branch', 'earliest', 'end', 'latest']) \
.convert_dtypes() \
.to_markdown(sys.stdout, floatfmt='.0f')
'';
report =
checkedShellScript
{
name = "postgrest-loadtest-report";
docs = "Create a report of all loadtest reports as markdown.";
inRootDir = true;
}
''
find loadtest -type f -iname '*.bin' -exec ${reporter} {} \; \
| ${jq}/bin/jq '[leaf_paths as $path | {param: $path | join("."), (.branch): getpath($path)}]' \
| ${jq}/bin/jq --slurp 'flatten | group_by(.param) | map(add)' \
| ${toMarkdown}
'';
in
buildToolbox {
name = "postgrest-loadtest";
tools = [ loadtest loadtestAgainst report ];
}
+1 -1
View File
@@ -17,7 +17,7 @@ let
withPath = [ postgrestProfiled curl ]; withPath = [ postgrestProfiled curl ];
} }
'' ''
${withTools.latest} test/memory-tests.sh ${withTools.withPg} test/memory/memory-tests.sh
''; '';
in in
+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}.
+84 -1
View File
@@ -46,9 +46,92 @@ let
--data-urlencode description@${description} \ --data-urlencode description@${description} \
--data-urlencode full_description@${fullDescription} --data-urlencode full_description@${fullDescription}
''; '';
release =
checkedShellScript
{
name = "postgrest-release";
docs = "Patch postgrest.cabal, CHANGELOG.md, tag and push all in one go.";
args = [ "ARG_POSITIONAL_SINGLE([version], [Version to release], [pre])" ];
inRootDir = true;
}
''
trap "echo You need to be on the main branch to proceed. Exiting ..." ERR
[ "$(git rev-parse --abbrev-ref HEAD)" == "main" ]
trap "" ERR
trap "echo You have uncommitted changes in postgrest.cabal. Exiting ..." ERR
git diff --exit-code HEAD postgrest.cabal > /dev/null
trap "" ERR
current_version="$(grep -oP '^version:\s*\K.*' postgrest.cabal)"
# shellcheck disable=SC2034
IFS=. read -r major minor patch pre <<< "$current_version"
echo "Current version is $current_version"
today_date="$(date '+%Y%m%d')"
today_date_for_changelog="$(date '+%Y-%m-%d')"
bump_pre="$major.$minor.$patch.$today_date"
bump_pre_minor="$major.$((minor+1)).0.$today_date"
bump_patch="$major.$minor.$((patch+1))"
bump_minor="$major.$((minor+1)).0"
bump_major="$((major+1)).0.0"
PS3="Please select the new version: "
select new_version in "$bump_pre" "$bump_pre_minor" "$bump_patch" "$bump_minor" "$bump_major"; do
case "$REPLY" in
1|2|3|4|5)
echo "Selected $new_version"
break
;;
*)
echo "Invalid option $REPLY"
;;
esac
done
echo "Updating postgrest.cabal ..."
sed -i -E "s/^(version:\s+).*$/\1$new_version/" postgrest.cabal > /dev/null
echo "Committing ..."
git add postgrest.cabal > /dev/null
if [[ "$new_version" != "$bump_pre" && "$new_version" != "$bump_pre_minor" ]]; 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
echo "Tagging ..."
git tag "v$new_version" > /dev/null
trap "echo Remote not found. Please push manually ..." ERR
remote="$(git remote -v | grep PostgREST/postgrest | grep push | cut -f1)"
trap "" ERR
push="git push --atomic $remote main v$new_version"
echo "To push both the branch and the new tag, the following will be run:"
echo
echo "$push"
echo
read -r -p 'Proceed? (y/N) ' REPLY
case "$REPLY" in
y|Y)
$push
;;
*)
echo "Aborting ..."
;;
esac
'';
in in
buildToolbox buildToolbox
{ {
name = "postgrest-release"; name = "postgrest-release";
tools = [ dockerHubDescription ]; tools = [ dockerHubDescription release ];
} }
@@ -55,7 +55,7 @@ write from scratch.
# Usage # Usage
To learn how to use this container, see the [PostgREST Docker To learn how to use this container, see the [PostgREST Docker
documentation](https://postgrest.com/en/stable/install.html#docker). documentation](https://postgrest.org/en/stable/install.html#docker).
You can configure the PostgREST image by setting You can configure the PostgREST image by setting
[enviroment variables](https://postgrest.org/en/stable/configuration.html). [enviroment variables](https://postgrest.org/en/stable/configuration.html).
@@ -66,5 +66,5 @@ The image is built from scratch using
[Nix](https://nixos.org/nixpkgs/manual/#sec-pkgs-dockerTools) instead of a [Nix](https://nixos.org/nixpkgs/manual/#sec-pkgs-dockerTools) instead of a
`Dockerfile`, which yields a higly secure and optimized image. This is also why `Dockerfile`, which yields a higly secure and optimized image. This is also why
no commands are listed in the image history. See the [PostgREST no commands are listed in the image history. See the [PostgREST
respository](https://github.com/PostgREST/postgrest/tree/main/nix/docker) for respository](https://github.com/PostgREST/postgrest/tree/main/nix/tools/docker) for
details on the build process and how to inspect the image. details on the build process and how to inspect the image.
+4 -3
View File
@@ -44,7 +44,9 @@ let
'' ''
${style} ${style}
${git}/bin/git diff-index --exit-code HEAD -- '*.hs' '*.lhs' '*.nix' trap "echo postgrest-style-check failed. Run postgrest-style to fix issues automatically." ERR
${git}/bin/git diff-index --exit-code HEAD -- '*.hs' '*.lhs' '*.nix' '*.py'
''; '';
lint = lint =
@@ -67,8 +69,7 @@ let
echo "Linting bash scripts..." echo "Linting bash scripts..."
${shellcheck}/bin/shellcheck \ ${shellcheck}/bin/shellcheck \
.github/get_cirrusci_freebsd \ .github/get_cirrusci_freebsd \
.github/release \ .github/release
test/with_tmp_db
echo "Linting workflows..." echo "Linting workflows..."
${actionlint}/bin/actionlint ${actionlint}/bin/actionlint
+55 -35
View File
@@ -3,14 +3,17 @@
, checkedShellScript , checkedShellScript
, devCabalOptions , devCabalOptions
, ghc , ghc
, glibcLocales , glibcLocales ? null
, gnugrep , gnugrep
, haskell
, hpc-codecov , hpc-codecov
, hostPlatform
, jq , jq
, lib
, postgrest , postgrest
, python3 , python3
, runtimeShell , runtimeShell
, stdenv
, weeder
, withTools , withTools
, yq , yq
}: }:
@@ -19,24 +22,14 @@ let
checkedShellScript checkedShellScript
{ {
name = "postgrest-test-spec"; name = "postgrest-test-spec";
docs = "Run the Haskell test suite"; docs = "Run the Haskell test suite. Use --match PATTERN for running individual specs";
args = [ "ARG_LEFTOVERS([hspec arguments])" ];
inRootDir = true; inRootDir = true;
withEnv = postgrest.env; withEnv = postgrest.env;
} }
'' ''
${withTools.latest} ${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:spec ${withTools.withPg} ${cabal-install}/bin/cabal v2-run ${devCabalOptions} \
''; test:spec -- "''${_arg_leftovers[@]}"
testQuerycost =
checkedShellScript
{
name = "postgrest-test-querycost";
docs = "Run the Haskell test suite for query costs";
inRootDir = true;
withEnv = postgrest.env;
}
''
${withTools.latest} ${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:querycost
''; '';
testDoctests = testDoctests =
@@ -66,7 +59,7 @@ let
withEnv = postgrest.env; withEnv = postgrest.env;
} }
'' ''
${withTools.latest} ${runtimeShell} -c " \ ${withTools.withPg} ${runtimeShell} -c " \
${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:spec && \ ${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:spec && \
${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:spec" ${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:spec"
''; '';
@@ -75,7 +68,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
@@ -85,28 +78,28 @@ let
checkedShellScript checkedShellScript
{ {
name = "postgrest-test-io"; name = "postgrest-test-io";
docs = "Run the pytest-based IO tests."; docs = "Run the pytest-based IO tests. Add -k to run tests that match a given expression.";
args = [ "ARG_LEFTOVERS([pytest arguments])" ]; args = [ "ARG_LEFTOVERS([pytest arguments])" ];
inRootDir = true; inRootDir = true;
withEnv = postgrest.env; withEnv = postgrest.env;
} }
'' ''
${cabal-install}/bin/cabal v2-build ${devCabalOptions} ${cabal-install}/bin/cabal v2-build ${devCabalOptions}
${cabal-install}/bin/cabal v2-exec ${withTools.latest} \ ${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} -f test/io/fixtures.sql \
${ioTestPython}/bin/pytest -- -v test/io-tests "''${_arg_leftovers[@]}" ${ioTestPython}/bin/pytest -v test/io "''${_arg_leftovers[@]}"
''; '';
dumpSchema = dumpSchema =
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 ];
} }
'' ''
${withTools.latest} \ ${withTools.withPg} \
${cabal-install}/bin/cabal v2-run ${devCabalOptions} --verbose=0 -- \ ${cabal-install}/bin/cabal v2-run ${devCabalOptions} --verbose=0 -- \
postgrest --dump-schema \ postgrest --dump-schema \
| ${yq}/bin/yq -y . | ${yq}/bin/yq -y .
@@ -116,39 +109,45 @@ let
checkedShellScript checkedShellScript
{ {
name = "postgrest-coverage"; name = "postgrest-coverage";
docs = "Run spec and io tests while collecting hpc coverage data."; docs = "Run spec and io tests while collecting hpc coverage data. First runs weeder to detect dead code.";
args = [ "ARG_LEFTOVERS([hpc report arguments])" ]; args = [ "ARG_LEFTOVERS([hpc report arguments])" ];
inRootDir = true; inRootDir = true;
redirectTixFiles = false; redirectTixFiles = false;
withEnv = postgrest.env; withEnv = postgrest.env;
withTmpDir = true; withTmpDir = true;
} }
'' (
# required for `hpc markup` in CI; glibcLocales is not available e.g. on Darwin
lib.optionalString (stdenv.isLinux && hostPlatform.libc == "glibc") ''
export LOCALE_ARCHIVE="${glibcLocales}/lib/locale/locale-archive" export LOCALE_ARCHIVE="${glibcLocales}/lib/locale/locale-archive"
'' +
''
# clean up previous coverage reports # clean up previous coverage reports
mkdir -p coverage mkdir -p coverage
rm -rf coverage/* rm -rf coverage/*
# build once before running all the tests # build once before running all the tests
${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest lib:postgrest test:spec test:querycost ${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest lib:postgrest test:spec
(
trap 'echo Found dead code: Check file list above.' ERR ;
${weeder}/bin/weeder --config=./test/weeder.dhall
)
# collect all tests # collect all tests
HPCTIXFILE="$tmpdir"/io.tix \ HPCTIXFILE="$tmpdir"/io.tix \
${withTools.latest} ${cabal-install}/bin/cabal v2-exec ${devCabalOptions} \ ${withTools.withPg} -f test/io/fixtures.sql ${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- \
${ioTestPython}/bin/pytest -- -v test/io-tests ${ioTestPython}/bin/pytest -v test/io
HPCTIXFILE="$tmpdir"/spec.tix \ HPCTIXFILE="$tmpdir"/spec.tix \
${withTools.latest} ${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:spec ${withTools.withPg} ${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:spec
HPCTIXFILE="$tmpdir"/querycost.tix \
${withTools.latest} ${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:querycost
# Note: No coverage for doctests, as doctests leverage GHCi and GHCi does not support hpc # Note: No coverage for doctests, as doctests leverage GHCi and GHCi does not support hpc
# collect all the tix files # collect all the tix files
${ghc}/bin/hpc sum --union --exclude=Paths_postgrest --output="$tmpdir"/tests.tix \ ${ghc}/bin/hpc sum --union --exclude=Paths_postgrest --output="$tmpdir"/tests.tix \
"$tmpdir"/io*.tix "$tmpdir"/spec.tix "$tmpdir"/querycost.tix "$tmpdir"/io*.tix "$tmpdir"/spec.tix
# prepare the overlay # prepare the overlay
${ghc}/bin/hpc overlay --output="$tmpdir"/overlay.tix test/coverage.overlay ${ghc}/bin/hpc overlay --output="$tmpdir"/overlay.tix test/coverage.overlay
@@ -178,7 +177,8 @@ let
echo "file://$(pwd)/coverage/hpc_index.html" echo "file://$(pwd)/coverage/hpc_index.html"
${ghc}/bin/hpc report coverage/postgrest.tix "''${_arg_leftovers[@]}" ${ghc}/bin/hpc report coverage/postgrest.tix "''${_arg_leftovers[@]}"
fi fi
''; ''
);
coverageDraftOverlay = coverageDraftOverlay =
checkedShellScript checkedShellScript
@@ -192,6 +192,26 @@ let
sed -i 's|^module \(.*\):|module \1/|g' test/coverage.overlay sed -i 's|^module \(.*\):|module \1/|g' test/coverage.overlay
''; '';
checkStatic =
checkedShellScript
{
name = "postgrest-check-static";
docs = "Verify that the argument is a static executable.";
args = [ "ARG_POSITIONAL_SINGLE([executable], [Executable])" ];
inRootDir = true;
withEnv = postgrest.env;
}
''
exe="$_arg_executable"
ldd_output=$(ldd "$exe" 2>&1 || true)
if ! grep -q "not a dynamic executable" <<< "$ldd_output"; then
echo "not a static executable, ldd output:"
echo "$ldd_output"
exit 1
fi
"$exe" --help
'';
in in
buildToolbox buildToolbox
{ {
@@ -199,12 +219,12 @@ buildToolbox
tools = tools =
[ [
testSpec testSpec
testQuerycost
testDoctests testDoctests
testSpecIdempotence testSpecIdempotence
testIO testIO
dumpSchema dumpSchema
coverage coverage
coverageDraftOverlay coverageDraftOverlay
checkStatic
]; ];
} }
+282 -28
View File
@@ -1,28 +1,40 @@
{ bashCompletion { bash-completion
, buildToolbox , buildToolbox
, cabal-install
, cabalTools
, checkedShellScript , checkedShellScript
, curl
, devCabalOptions
, git
, lib , lib
, postgresqlVersions , postgresqlVersions
, writeTextFile , postgrest
, slocat
, writeText
}: }:
let let
withTmpDb = withTmpDb =
{ name, postgresql }: { name, postgresql }:
let
commandName = "postgrest-with-${name}";
superuserRole = "postgres";
in
checkedShellScript checkedShellScript
{ {
name = "postgrest-with-${name}"; name = commandName;
docs = "Run the given command in a temporary database with ${name}"; docs = "Run the given command in a temporary database with ${name}. If you wish to mutate the database, login with the '${superuserRole}' role.";
args = args =
[ [
"ARG_OPTIONAL_SINGLE([fixtures], [f], [SQL file to load fixtures from], [test/fixtures/load.sql])" "ARG_OPTIONAL_SINGLE([fixtures], [f], [SQL file to load fixtures from], [test/spec/fixtures/load.sql])"
"ARG_POSITIONAL_SINGLE([command], [Command to run])" "ARG_POSITIONAL_SINGLE([command], [Command to run])"
"ARG_LEFTOVERS([command arguments])" "ARG_LEFTOVERS([command arguments])"
"ARG_USE_ENV([PGUSER], [postgrest_test_authenticator], [Authenticator PG role])" "ARG_USE_ENV([PGUSER], [postgrest_test_authenticator], [Authenticator PG role])"
"ARG_USE_ENV([PGDATABASE], [postgres], [PG database name])" "ARG_USE_ENV([PGDATABASE], [postgres], [PG database name])"
"ARG_USE_ENV([PGRST_DB_SCHEMAS], [test], [Schema to expose])" "ARG_USE_ENV([PGRST_DB_SCHEMAS], [test], [Schema to expose])"
"ARG_USE_ENV([PGRST_DB_ANON_ROLE], [postgrest_test_anonymous], [Anonymous PG role])" "ARG_USE_ENV([PGTZ], [utc], [Timezone to use])"
"ARG_USE_ENV([PGOPTIONS], [-c search_path=public,test], [PG options to use])"
]; ];
addCommandCompletion = true; positionalCompletion = "_command";
inRootDir = true; inRootDir = true;
redirectTixFiles = false; redirectTixFiles = false;
withPath = [ postgresql ]; withPath = [ postgresql ];
@@ -30,8 +42,8 @@ let
} }
'' ''
# avoid starting multiple layers of withTmpDb # avoid starting multiple layers of withTmpDb
if test -v PGRST_DB_URI; then if test -v PGHOST; then
exec "$@" exec "$_arg_command" "''${_arg_leftovers[@]}"
fi fi
setuplog="$tmpdir/setup.log" setuplog="$tmpdir/setup.log"
@@ -48,21 +60,27 @@ let
export PGHOST="$tmpdir/socket" export PGHOST="$tmpdir/socket"
export PGUSER export PGUSER
export PGDATABASE export PGDATABASE
export PGRST_DB_URI="postgresql:///$PGDATABASE?host=$PGHOST&user=$PGUSER"
export PGRST_DB_SCHEMAS export PGRST_DB_SCHEMAS
export PGRST_DB_ANON_ROLE export PGTZ
export PGOPTIONS
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.
PGTZ=UTC initdb --no-locale --encoding=UTF8 --nosync -U "$PGUSER" --auth=trust \ # initdb -U creates a superuser(man initdb)
PGTZ=UTC initdb --no-locale --encoding=UTF8 --nosync -U "${superuserRole}" --auth=trust \
>> "$setuplog" >> "$setuplog"
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" \ 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"
# shellcheck disable=SC2317
stop () { stop () {
log "Stopping the database cluster..." log "Stopping the database cluster..."
pg_ctl stop -m i >> "$setuplog" pg_ctl stop -m i >> "$setuplog"
@@ -70,35 +88,42 @@ let
} }
trap stop EXIT trap stop EXIT
log "Loading fixtures..." log "Creating a minimally privileged $PGUSER connection role..."
psql -v ON_ERROR_STOP=1 -f "$_arg_fixtures" >> "$setuplog" createuser "$PGUSER" -U "${superuserRole}" --host="$tmpdir/socket" --no-createdb --no-inherit --no-superuser --no-createrole --no-replication --login
log "Loading fixtures under the ${superuserRole} role..."
psql -U "${superuserRole}" -v PGUSER="$PGUSER" -v ON_ERROR_STOP=1 -f "$_arg_fixtures" >> "$setuplog"
log "Done. Running command..." log "Done. Running command..."
echo "${commandName}: You can connect with: psql 'postgres:///$PGDATABASE?host=$tmpdir/socket' -U ${superuserRole}"
echo "${commandName}: You can tail the logs with: tail -f $tmpdir/db.log"
("$_arg_command" "''${_arg_leftovers[@]}") ("$_arg_command" "''${_arg_leftovers[@]}")
''; '';
# Helper script for running a command against all PostgreSQL versions. # Helper script for running a command against all PostgreSQL versions.
withAll = withPgAll =
let let
runners = runners =
builtins.map builtins.map
(pg: (version:
'' ''
cat << EOF cat << EOF
Running against ${pg.name}... Running against ${version.name}...
EOF EOF
trap 'echo "Failed on ${pg.name}"' exit trap 'echo "Failed on ${version.name}"' exit
(${withTmpDb pg} "$_arg_command" "''${_arg_leftovers[@]}") (${withTmpDb version} "$_arg_command" "''${_arg_leftovers[@]}")
trap "" exit trap "" exit
cat << EOF cat << EOF
Done running against ${pg.name}. Done running against ${version.name}.
EOF EOF
'') '')
@@ -113,21 +138,250 @@ let
"ARG_POSITIONAL_SINGLE([command], [Command to run])" "ARG_POSITIONAL_SINGLE([command], [Command to run])"
"ARG_LEFTOVERS([command arguments])" "ARG_LEFTOVERS([command arguments])"
]; ];
addCommandCompletion = true; positionalCompletion = "_command";
inRootDir = true; inRootDir = true;
} }
(lib.concatStringsSep "\n\n" runners); (lib.concatStringsSep "\n\n" runners);
# Create a `postgrest-with-postgresql-` for each PostgreSQL version # Create a `postgrest-with-postgresql-` for each PostgreSQL version
withVersions = builtins.map withTmpDb postgresqlVersions; withPgVersions = builtins.map withTmpDb postgresqlVersions;
withPg = builtins.head withPgVersions;
withSlowPg =
checkedShellScript
{
name = "postgrest-with-slow-pg";
docs = "Run the given command with simulated high latency postgresql";
args =
[
"ARG_POSITIONAL_SINGLE([command], [Command to run])"
"ARG_LEFTOVERS([command arguments])"
"ARG_USE_ENV([PGHOST], [], [PG host (socket name)])"
"ARG_USE_ENV([PGDELAY], [0ms], [extra PG latency (duration)])"
];
positionalCompletion = "_command";
inRootDir = true;
redirectTixFiles = false;
withTmpDir = true;
}
''
delay="''${PGDELAY:-0ms}"
echo "delaying data to/from postgres by $delay"
REALPGHOST="$PGHOST"
export PGHOST="$tmpdir/socket"
mkdir -p "$PGHOST"
${slocat}/bin/slocat -delay "$delay" -src "$PGHOST/.s.PGSQL.5432" -dst "$REALPGHOST/.s.PGSQL.5432" &
SLOCAT_PID=$!
# shellcheck disable=SC2317
stop_slocat() {
kill "$SLOCAT_PID" || true
wait "$SLOCAT_PID" || true
}
trap stop_slocat EXIT
sleep 1 # should wait for socket file to appear instead
("$_arg_command" "''${_arg_leftovers[@]}")
'';
withSlowPgrst =
checkedShellScript
{
name = "postgrest-with-slow-postgrest";
docs = "Run the given command with simulated high latency postgrest";
args =
[
"ARG_POSITIONAL_SINGLE([command], [Command to run])"
"ARG_LEFTOVERS([command arguments])"
"ARG_USE_ENV([PGRST_SERVER_UNIX_SOCKET], [], [PostgREST host (socket name)])"
"ARG_USE_ENV([PGRST_DELAY], [0ms], [extra PostgREST latency (duration)])"
];
positionalCompletion = "_command";
inRootDir = true;
redirectTixFiles = false;
withTmpDir = true;
}
''
delay="''${PGRST_DELAY:-0ms}"
echo "delaying data to/from PostgREST by $delay"
REAL_PGRST_SERVER_UNIX_SOCKET="$PGRST_SERVER_UNIX_SOCKET"
export PGRST_SERVER_UNIX_SOCKET="$tmpdir/postgrest.socket"
${slocat}/bin/slocat -delay "$delay" -src "$PGRST_SERVER_UNIX_SOCKET" -dst "$REAL_PGRST_SERVER_UNIX_SOCKET" &
SLOCAT_PID=$!
# shellcheck disable=SC2317
stop_slocat() {
kill "$SLOCAT_PID" || true
wait "$SLOCAT_PID" || true
}
trap stop_slocat EXIT
sleep 1 # should wait for socket file to appear instead
("$_arg_command" "''${_arg_leftovers[@]}")
'';
withGit =
let
name = "postgrest-with-git";
in
checkedShellScript
{
inherit name;
docs =
''
Create a new worktree of the postgrest repo in a temporary directory and
check out <commit>, then run <command> with arguments inside the temporary folder.
'';
args =
[
"ARG_POSITIONAL_SINGLE([commit], [Commit-ish reference to run command with])"
"ARG_POSITIONAL_SINGLE([command], [Command to run])"
"ARG_LEFTOVERS([command arguments])"
];
positionalCompletion =
''
if test "$prev" == "${name}"; then
__gitcomp_nl "$(__git_refs)"
else
_command_offset 2
fi
'';
inRootDir = true;
}
''
# not using withTmpDir here, because we don't want to keep the directory on error
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
${git}/bin/git worktree add -f "$tmpdir" "$_arg_commit" > /dev/null
cd "$tmpdir"
("$_arg_command" "''${_arg_leftovers[@]}")
${git}/bin/git worktree remove -f "$tmpdir" > /dev/null
'';
legacyConfig =
writeText "legacy.conf"
''
# Using this config file to support older postgrest versions for `postgrest-loadtest-against`
db-uri="$(PGRST_DB_URI)"
db-schema="$(PGRST_DB_SCHEMAS)"
db-anon-role="$(PGRST_DB_ANON_ROLE)"
db-pool="$(PGRST_DB_POOL)"
server-unix-socket="$(PGRST_SERVER_UNIX_SOCKET)"
log-level="$(PGRST_LOG_LEVEL)"
'';
waitForPgrstPid =
checkedShellScript
{
name = "postgrest-wait-for-pgrst-pid";
docs = "Wait for PostgREST to be running. Needs to be a separate command for timeout to work below.";
args = [
"ARG_USE_ENV([PGRST_SERVER_UNIX_SOCKET], [], [Unix socket to check for running PostgREST instance])"
];
}
''
# ARG_USE_ENV only adds defaults or docs for environment variables
# We manually implement a required check here
# See also: https://github.com/matejak/argbash/issues/80
: "''${PGRST_SERVER_UNIX_SOCKET:?PGRST_SERVER_UNIX_SOCKET is required}"
until [ -S "$PGRST_SERVER_UNIX_SOCKET" ]
do
sleep 0.1
done
# return pid of postgrest process
lsof -t -c '/^postgrest$/' "$PGRST_SERVER_UNIX_SOCKET"
'';
waitForPgrstReady =
checkedShellScript
{
name = "postgrest-wait-for-pgrst-ready";
docs = "Wait for PostgREST to be ready to serve requests. Needs to be a separate command for timeout to work below.";
args = [
"ARG_USE_ENV([PGRST_SERVER_UNIX_SOCKET], [], [Unix socket to check for running PostgREST instance])"
];
}
''
# ARG_USE_ENV only adds defaults or docs for environment variables
# We manually implement a required check here
# See also: https://github.com/matejak/argbash/issues/80
: "''${PGRST_SERVER_UNIX_SOCKET:?PGRST_SERVER_UNIX_SOCKET is required}"
function check_status () {
${curl}/bin/curl -s -o /dev/null -w "%{http_code}" --unix-socket "$PGRST_SERVER_UNIX_SOCKET" http://localhost/
}
while [[ "$(check_status)" != "200" ]];
do sleep 0.1;
done
'';
withPgrst =
checkedShellScript
{
name = "postgrest-with-pgrst";
docs = "Build and run PostgREST and run <command> with PGRST_SERVER_UNIX_SOCKET set.";
args =
[
"ARG_POSITIONAL_SINGLE([command], [Command to run])"
"ARG_LEFTOVERS([command arguments])"
];
positionalCompletion = "_command";
inRootDir = true;
withEnv = postgrest.env;
withTmpDir = true;
}
''
export PGRST_SERVER_UNIX_SOCKET="$tmpdir"/postgrest.socket
rm -f result
if [ -z "''${PGRST_BUILD_CABAL:-}" ]; then
echo -n "Building postgrest (nix)... "
nix-build -A postgrestPackage > "$tmpdir"/build.log 2>&1 || {
echo "failed, output:"
cat "$tmpdir"/build.log
exit 1
}
PGRST_CMD=./result/bin/postgrest
else
echo -n "Building postgrest (cabal)... "
postgrest-build
PGRST_CMD=postgrest-run
fi
echo "done."
echo -n "Starting postgrest... "
$PGRST_CMD ${legacyConfig} > "$tmpdir"/run.log 2>&1 &
pid=$!
# shellcheck disable=SC2317
cleanup() {
kill "$pid" || true
}
trap cleanup EXIT
timeout -s TERM 5 ${waitForPgrstReady} || {
echo "timed out, output:"
cat "$tmpdir"/run.log
exit 1
}
echo "done."
("$_arg_command" "''${_arg_leftovers[@]}")
'';
in in
buildToolbox buildToolbox
{ {
name = "postgrest-with"; name = "postgrest-with";
tools = [ withAll ] ++ withVersions; tools = [ withPgAll withGit withPgrst withSlowPg withSlowPgrst ] ++ withPgVersions;
extra = { # make withTools available for other nix files
# make withTools.latest available for other nix files extra = { inherit withGit withPg withPgAll withPgrst withSlowPg withSlowPgrst; };
latest = withTmpDb (builtins.head postgresqlVersions);
};
} }
+115 -136
View File
@@ -1,8 +1,8 @@
name: postgrest name: postgrest
version: 9.0.0 version: 11.2.0
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 the tables and views, supporting all HTTP verbs that security for tables, views, and functions, supporting all HTTP methods that security
permits. permits.
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
@@ -34,7 +34,8 @@ library
default-extensions: OverloadedStrings default-extensions: OverloadedStrings
NoImplicitPrelude NoImplicitPrelude
hs-source-dirs: src hs-source-dirs: src
exposed-modules: PostgREST.App exposed-modules: PostgREST.Admin
PostgREST.App
PostgREST.AppState PostgREST.AppState
PostgREST.Auth PostgREST.Auth
PostgREST.CLI PostgREST.CLI
@@ -43,77 +44,88 @@ library
PostgREST.Config.JSPath PostgREST.Config.JSPath
PostgREST.Config.PgVersion PostgREST.Config.PgVersion
PostgREST.Config.Proxy PostgREST.Config.Proxy
PostgREST.ContentType PostgREST.Cors
PostgREST.DbStructure PostgREST.SchemaCache
PostgREST.DbStructure.Identifiers PostgREST.SchemaCache.Identifiers
PostgREST.DbStructure.Proc PostgREST.SchemaCache.Routine
PostgREST.DbStructure.Relationship PostgREST.SchemaCache.Relationship
PostgREST.DbStructure.Table PostgREST.SchemaCache.Representations
PostgREST.SchemaCache.Table
PostgREST.Error PostgREST.Error
PostgREST.GucHeader PostgREST.Logger
PostgREST.Middleware 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.Plan.Types
PostgREST.RangeQuery PostgREST.RangeQuery
PostgREST.Request.ApiRequest PostgREST.ApiRequest
PostgREST.Request.DbRequestBuilder PostgREST.ApiRequest.Preferences
PostgREST.Request.Parsers PostgREST.ApiRequest.QueryParams
PostgREST.Request.Preferences PostgREST.ApiRequest.Types
PostgREST.Request.Types PostgREST.Response
PostgREST.Response.OpenAPI
PostgREST.Response.GucHeader
PostgREST.Version PostgREST.Version
PostgREST.Workers
other-modules: Paths_postgrest other-modules: Paths_postgrest
build-depends: base >= 4.9 && < 4.16 build-depends: base >= 4.9 && < 4.17
, HTTP >= 4000.3.7 && < 4000.4 , HTTP >= 4000.3.7 && < 4000.5
, Ranged-sets >= 0.3 && < 0.5 , Ranged-sets >= 0.3 && < 0.5
, aeson >= 1.4.7 && < 1.6 , aeson >= 2.0.3 && < 2.2
, ansi-wl-pprint >= 0.6.7 && < 0.7
, 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.11 , bytestring >= 0.10.8 && < 0.12
, case-insensitive >= 1.2 && < 1.3 , case-insensitive >= 1.2 && < 1.3
, cassava >= 0.4.5 && < 0.6 , cassava >= 0.4.5 && < 0.6
, configurator-pg >= 0.2 && < 0.3 , configurator-pg >= 0.2 && < 0.3
, containers >= 0.5.7 && < 0.7 , containers >= 0.5.7 && < 0.7
, contravariant >= 1.4 && < 1.6
, 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
, fast-logger >= 2.4.5 , extra >= 1.7.0 && < 2.0
, fuzzyset >= 0.2.3
, gitrev >= 1.2 && < 1.4 , gitrev >= 1.2 && < 1.4
, hasql >= 1.4 && < 1.5 , hasql >= 1.6.1.1 && < 1.7
, hasql-dynamic-statements == 0.3.1 , 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.10 && < 0.11
, hasql-transaction >= 1.0.1 && < 1.1 , hasql-transaction >= 1.0.1 && < 1.1
, heredoc >= 0.2 && < 0.3 , heredoc >= 0.2 && < 0.3
, 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.1 && < 0.9 , jose >= 0.8.5.1 && < 0.11
, lens >= 4.14 && < 5.1 , lens >= 4.14 && < 5.3
, lens-aeson >= 1.0.1 && < 1.2 , lens-aeson >= 1.0.1 && < 1.3
, mtl >= 2.2.2 && < 2.3 , mtl >= 2.2.2 && < 2.3
, network >= 2.6 && < 3.2
, network-uri >= 2.6.1 && < 2.8 , network-uri >= 2.6.1 && < 2.8
, optparse-applicative >= 0.13 && < 0.17 , optparse-applicative >= 0.13 && < 0.18
, parsec >= 3.1.11 && < 3.2 , parsec >= 3.1.11 && < 3.2
, protolude >= 0.3 && < 0.4 , protolude >= 0.3.1 && < 0.4
, regex-tdfa >= 1.2.2 && < 1.4 , regex-tdfa >= 1.2.2 && < 1.4
, retry >= 0.7.4 && < 0.10 , retry >= 0.7.4 && < 0.10
, scientific >= 0.3.4 && < 0.4 , scientific >= 0.3.4 && < 0.4
, swagger2 >= 2.4 && < 2.7 , swagger2 >= 2.4 && < 2.9
, text >= 1.2.2 && < 1.3 , text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.11 , time >= 1.6 && < 1.12
, unordered-containers >= 0.2.8 && < 0.3 , unordered-containers >= 0.2.8 && < 0.3
, vector >= 0.11 && < 0.13 , vault >= 0.3.1.5 && < 0.4
, 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.0.19 && < 3.2 , wai-extra >= 3.1.8 && < 3.2
, wai-logger >= 2.3.2 -- We already depend on wai-logger >= 2.3.7 indirectly via wai-extra,
, wai-middleware-static >= 0.8.1 && < 0.10 -- but we want to depend on 2.4.0 which fixes 'unknownSocket' log output
, warp >= 3.2.12 && < 3.4 -- for unix sockets; this is tested in test/io/test_io.py. See
-- https://github.com/kazu-yamamoto/logger/commit/3a71ca70afdbb93d4ecf0083eeba1fbbbcab3fc3
, wai-logger >= 2.4.0
, warp >= 3.3.19 && < 3.4
-- -fno-spec-constr may help keep compile time memory use in check, -- -fno-spec-constr may help keep compile time memory use in check,
-- see https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304 -- see https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304
-- -optP-Wno-nonportable-include-path -- -optP-Wno-nonportable-include-path
@@ -123,7 +135,7 @@ library
-fno-spec-constr -optP-Wno-nonportable-include-path -fno-spec-constr -optP-Wno-nonportable-include-path
if flag(dev) if flag(dev)
ghc-options: -O0 ghc-options: -O0 -fwrite-ide-info
if flag(hpc) if flag(hpc)
ghc-options: -fhpc -hpcdir .hpc ghc-options: -fhpc -hpcdir .hpc
else else
@@ -133,7 +145,6 @@ library
build-depends: build-depends:
unix unix
, directory >= 1.2.6 && < 1.4 , directory >= 1.2.6 && < 1.4
, network >= 2.6 && < 3.2
exposed-modules: exposed-modules:
PostgREST.Unix PostgREST.Unix
@@ -143,16 +154,18 @@ executable postgrest
NoImplicitPrelude NoImplicitPrelude
hs-source-dirs: main hs-source-dirs: main
main-is: Main.hs main-is: Main.hs
build-depends: base >= 4.9 && < 4.16 build-depends: base >= 4.9 && < 4.17
, containers >= 0.5.7 && < 0.7 , containers >= 0.5.7 && < 0.7
, postgrest , postgrest
, protolude >= 0.3 && < 0.4 , protolude >= 0.3.1 && < 0.4
ghc-options: -threaded -rtsopts "-with-rtsopts=-N -I2" ghc-options: -threaded -rtsopts "-with-rtsopts=-N -I0 -qg"
-O2 -Werror -Wall -fwarn-identities -O2 -Werror -Wall -fwarn-identities
-fno-spec-constr -optP-Wno-nonportable-include-path -fno-spec-constr -optP-Wno-nonportable-include-path
if flag(dev) if flag(dev)
ghc-options: -O0 ghc-options: -O0 -fwrite-ide-info
-- https://github.com/PostgREST/postgrest/issues/387
-with-rtsopts=-K1K
if flag(hpc) if flag(hpc)
ghc-options: -fhpc -hpcdir .hpc ghc-options: -fhpc -hpcdir .hpc
else else
@@ -164,135 +177,101 @@ test-suite spec
default-extensions: OverloadedStrings default-extensions: OverloadedStrings
QuasiQuotes QuasiQuotes
NoImplicitPrelude NoImplicitPrelude
hs-source-dirs: test hs-source-dirs: test/spec
main-is: Main.hs main-is: Main.hs
other-modules: Feature.AndOrParamsSpec other-modules: Feature.Auth.AsymmetricJwtSpec
Feature.AsymmetricJwtSpec Feature.Auth.AudienceJwtSecretSpec
Feature.AudienceJwtSecretSpec Feature.Auth.AuthSpec
Feature.AuthSpec Feature.Auth.BinaryJwtSecretSpec
Feature.BinaryJwtSecretSpec Feature.Auth.NoAnonSpec
Feature.Auth.NoJwtSpec
Feature.ConcurrentSpec Feature.ConcurrentSpec
Feature.CorsSpec Feature.CorsSpec
Feature.DeleteSpec
Feature.DisabledOpenApiSpec
Feature.EmbedDisambiguationSpec
Feature.EmbedInnerJoinSpec
Feature.ExtraSearchPathSpec Feature.ExtraSearchPathSpec
Feature.HtmlRawOutputSpec
Feature.InsertSpec
Feature.IgnorePrivOpenApiSpec
Feature.JsonOperatorSpec
Feature.LegacyGucsSpec Feature.LegacyGucsSpec
Feature.MultipleSchemaSpec Feature.NoSuperuserSpec
Feature.NoJwtSpec Feature.ObservabilitySpec
Feature.NonexistentSchemaSpec Feature.OpenApi.DisabledOpenApiSpec
Feature.OpenApiSpec Feature.OpenApi.IgnorePrivOpenApiSpec
Feature.OpenApi.OpenApiSpec
Feature.OpenApi.ProxySpec
Feature.OpenApi.RootSpec
Feature.OpenApi.SecurityOpenApiSpec
Feature.OptionsSpec Feature.OptionsSpec
Feature.ProxySpec Feature.Query.AndOrParamsSpec
Feature.QueryLimitedSpec Feature.Query.ComputedRelsSpec
Feature.QuerySpec Feature.Query.DeleteSpec
Feature.RangeSpec Feature.Query.EmbedDisambiguationSpec
Feature.RawOutputTypesSpec Feature.Query.EmbedInnerJoinSpec
Feature.Query.PlanSpec
Feature.Query.HtmlRawOutputSpec
Feature.Query.InsertSpec
Feature.Query.JsonOperatorSpec
Feature.Query.MultipleSchemaSpec
Feature.Query.ErrorSpec
Feature.Query.PgSafeUpdateSpec
Feature.Query.PostGISSpec
Feature.Query.QueryLimitedSpec
Feature.Query.QuerySpec
Feature.Query.RangeSpec
Feature.Query.RawOutputTypesSpec
Feature.Query.RelatedQueriesSpec
Feature.Query.RpcSpec
Feature.Query.SingularSpec
Feature.Query.NullsStrip
Feature.Query.SpreadQueriesSpec
Feature.Query.UnicodeSpec
Feature.Query.UpdateSpec
Feature.Query.UpsertSpec
Feature.RollbackSpec Feature.RollbackSpec
Feature.RootSpec
Feature.RpcPreRequestGucsSpec Feature.RpcPreRequestGucsSpec
Feature.RpcSpec
Feature.SingularSpec
Feature.UnicodeSpec
Feature.UpdateSpec
Feature.UpsertSpec
SpecHelper SpecHelper
TestTypes build-depends: base >= 4.9 && < 4.17
build-depends: base >= 4.9 && < 4.16 , aeson >= 2.0.3 && < 2.2
, aeson >= 1.4.7 && < 1.6
, 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
, base64-bytestring >= 1 && < 1.3 , base64-bytestring >= 1 && < 1.3
, bytestring >= 0.10.8 && < 0.11 , bytestring >= 0.10.8 && < 0.12
, case-insensitive >= 1.2 && < 1.3 , case-insensitive >= 1.2 && < 1.3
, cassava >= 0.4.5 && < 0.6
, containers >= 0.5.7 && < 0.7 , containers >= 0.5.7 && < 0.7
, contravariant >= 1.4 && < 1.6 , hasql-pool >= 0.10 && < 0.11
, hasql >= 1.4 && < 1.5
, hasql-pool >= 0.5 && < 0.6
, 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.10
, 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.1 , lens >= 4.14 && < 5.3
, lens-aeson >= 1.0.1 && < 1.2 , lens-aeson >= 1.0.1 && < 1.3
, monad-control >= 1.0.1 && < 1.1 , monad-control >= 1.0.1 && < 1.1
, postgrest , postgrest
, process >= 1.4.2 && < 1.7 , process >= 1.4.2 && < 1.7
, protolude >= 0.3 && < 0.4 , protolude >= 0.3.1 && < 0.4
, regex-tdfa >= 1.2.2 && < 1.4 , regex-tdfa >= 1.2.2 && < 1.4
, scientific >= 0.3.4 && < 0.4
, text >= 1.2.2 && < 1.3 , text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.11
, transformers-base >= 0.4.4 && < 0.5 , transformers-base >= 0.4.4 && < 0.5
, wai >= 3.2.1 && < 3.3 , wai >= 3.2.1 && < 3.3
, wai-extra >= 3.0.19 && < 3.2 , wai-extra >= 3.0.19 && < 3.2
ghc-options: -O0 -Werror -Wall -fwarn-identities ghc-options: -threaded -O0 -Werror -Wall -fwarn-identities
-fno-spec-constr -optP-Wno-nonportable-include-path -fno-spec-constr -optP-Wno-nonportable-include-path
-fno-warn-missing-signatures -fno-warn-missing-signatures
-fwrite-ide-info
test-suite querycost -- https://github.com/PostgREST/postgrest/issues/387
type: exitcode-stdio-1.0 -with-rtsopts=-K33K
default-language: Haskell2010
default-extensions: OverloadedStrings
QuasiQuotes
NoImplicitPrelude
hs-source-dirs: test
main-is: QueryCost.hs
other-modules: SpecHelper
build-depends: base >= 4.9 && < 4.16
, aeson >= 1.4.7 && < 1.6
, aeson-qq >= 0.8.1 && < 0.9
, async >= 2.1.1 && < 2.3
, auto-update >= 0.1.4 && < 0.2
, base64-bytestring >= 1 && < 1.3
, bytestring >= 0.10.8 && < 0.11
, case-insensitive >= 1.2 && < 1.3
, cassava >= 0.4.5 && < 0.6
, containers >= 0.5.7 && < 0.7
, contravariant >= 1.4 && < 1.6
, hasql >= 1.4 && < 1.5
, hasql-dynamic-statements == 0.3.1
, hasql-pool >= 0.5 && < 0.6
, hasql-transaction >= 1.0.1 && < 1.1
, heredoc >= 0.2 && < 0.3
, hspec >= 2.3 && < 2.9
, hspec-wai >= 0.10 && < 0.12
, hspec-wai-json >= 0.10 && < 0.12
, http-types >= 0.12.3 && < 0.13
, lens >= 4.14 && < 5.1
, lens-aeson >= 1.0.1 && < 1.2
, monad-control >= 1.0.1 && < 1.1
, postgrest
, process >= 1.4.2 && < 1.7
, protolude >= 0.3 && < 0.4
, regex-tdfa >= 1.2.2 && < 1.4
, text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.11
, transformers-base >= 0.4.4 && < 0.5
, wai >= 3.2.1 && < 3.3
, wai-extra >= 3.0.19 && < 3.2
ghc-options: -O0 -Werror -Wall -fwarn-identities
-fno-spec-constr -optP-Wno-nonportable-include-path
test-suite doctests test-suite doctests
type: exitcode-stdio-1.0 type: exitcode-stdio-1.0
default-language: Haskell2010 default-language: Haskell2010
default-extensions: OverloadedStrings default-extensions: OverloadedStrings
NoImplicitPrelude NoImplicitPrelude
hs-source-dirs: test/doctests hs-source-dirs: test/doc
main-is: Main.hs main-is: Main.hs
build-depends: base >= 4.9 && < 4.16 build-depends: base >= 4.9 && < 4.17
, doctest >= 0.8 , doctest >= 0.8
, postgrest , postgrest
, pretty-simple , pretty-simple
, protolude >= 0.3 && < 0.4 , protolude >= 0.3.1 && < 0.4
ghc-options: -threaded -O0 -Werror -Wall -fwarn-identities ghc-options: -threaded -O0 -Werror -Wall -fwarn-identities
-fno-spec-constr -optP-Wno-nonportable-include-path -fno-spec-constr -optP-Wno-nonportable-include-path
+11 -5
View File
@@ -11,7 +11,7 @@
}: }:
let let
postgrest = postgrest =
import ./default.nix; import ./default.nix { };
inherit (postgrest) pkgs; inherit (postgrest) pkgs;
@@ -21,6 +21,7 @@ let
[ [
postgrest.cabalTools postgrest.cabalTools
postgrest.devTools postgrest.devTools
postgrest.loadtest
postgrest.nixpkgsTools postgrest.nixpkgsTools
postgrest.style postgrest.style
postgrest.tests postgrest.tests
@@ -37,20 +38,25 @@ lib.overrideDerivation postgrest.env (
base.buildInputs ++ [ base.buildInputs ++ [
pkgs.cabal-install pkgs.cabal-install
pkgs.cabal2nix pkgs.cabal2nix
pkgs.git
pkgs.postgresql pkgs.postgresql
pkgs.update-nix-fetchgit
postgrest.hsie.bin postgrest.hsie.bin
] ]
++ toolboxes; ++ toolboxes;
shellHook = shellHook =
'' ''
source ${pkgs.bashCompletion}/etc/profile.d/bash_completion.sh export HISTFILE=.history
source ${postgrest.hsie.bashCompletion}
source ${pkgs.bash-completion}/etc/profile.d/bash_completion.sh
source ${pkgs.git}/share/git/contrib/completion/git-completion.bash
source ${postgrest.hsie.bash-completion}
'' ''
+ builtins.concatStringsSep "\n" ( + builtins.concatStringsSep "\n" (
builtins.map (bashCompletion: "source ${bashCompletion}") ( builtins.map (bash-completion: "source ${bash-completion}") (
builtins.concatLists (builtins.map (toolbox: toolbox.bashCompletion) toolboxes) builtins.concatLists (builtins.map (toolbox: toolbox.bash-completion) toolboxes)
) )
); );
} }
+87
View File
@@ -0,0 +1,87 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Admin
( runAdmin
) where
import qualified Data.Text as T
import qualified Hasql.Session 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.Monad.Extra (whenJust)
import Network.Socket
import Network.Socket.ByteString
import PostgREST.AppState (AppState)
import PostgREST.Config (AppConfig (..))
import qualified PostgREST.AppState as AppState
import Protolude
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
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
+379
View File
@@ -0,0 +1,379 @@
{-|
Module : PostgREST.Request.ApiRequest
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.ApiRequest
( ApiRequest(..)
, InvokeMethod(..)
, Mutation(..)
, MediaType(..)
, Action(..)
, Target(..)
, Payload(..)
, userApiRequest
) 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.Char8 as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.CaseInsensitive as CI
import qualified Data.Csv as CSV
import qualified Data.HashMap.Strict as HM
import qualified Data.List as L
import qualified Data.List.NonEmpty as NonEmptyList
import qualified Data.Map.Strict as M
import qualified Data.Set as S
import qualified Data.Text.Encoding as T
import qualified Data.Vector as V
import Data.Either.Combinators (mapBoth)
import Control.Arrow ((***))
import Data.Aeson.Types (emptyArray, emptyObject)
import Data.List (lookup, union)
import Data.Ranged.Ranges (emptyRange, rangeIntersection,
rangeIsEmpty)
import Network.HTTP.Types.Header (RequestHeaders, hCookie)
import Network.HTTP.Types.URI (parseSimpleQuery)
import Network.Wai (Request (..))
import Network.Wai.Parse (parseHttpAccept)
import Web.Cookie (parseCookies)
import PostgREST.ApiRequest.QueryParams (QueryParams (..))
import PostgREST.ApiRequest.Types (ApiRequestError (..),
RangeError (..))
import PostgREST.Config (AppConfig (..),
OpenAPIMode (..))
import PostgREST.MediaType (MTPlanFormat (..),
MediaType (..))
import PostgREST.RangeQuery (NonnegRange, allRange,
convertToLimitZeroRange,
hasLimitZero,
rangeRequested)
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema)
import qualified PostgREST.ApiRequest.Preferences as Preferences
import qualified PostgREST.ApiRequest.QueryParams as QueryParams
import qualified PostgREST.MediaType as MediaType
import Protolude
type RequestBody = LBS.ByteString
data Payload
= ProcessedJSON -- ^ Cached attributes of a JSON payload
{ payRaw :: LBS.ByteString
-- ^ This is the raw ByteString that comes from the request body. We
-- cache this instead of an Aeson Value because it was detected that for
-- large payloads the encoding had high memory usage, see
-- https://github.com/PostgREST/postgrest/pull/1005 for more details
, payKeys :: S.Set Text
-- ^ Keys of the object or if it's an array these keys are guaranteed to
-- be the same across all its objects
}
| ProcessedUrlEncoded { payArray :: [(Text, Text)], payKeys :: S.Set Text }
| RawJSON { payRaw :: LBS.ByteString }
| RawPay { payRaw :: LBS.ByteString }
data InvokeMethod = InvHead | InvGet | InvPost deriving Eq
data Mutation = MutationCreate | MutationDelete | MutationSingleUpsert | MutationUpdate deriving Eq
-- | Types of things a user wants to do to tables/views/procs
data Action
= ActionMutate Mutation
| ActionRead {isHead :: Bool}
| ActionInvoke InvokeMethod
| ActionInfo
| ActionInspect {isHead :: Bool}
deriving Eq
-- | The path info that will be mapped to a target (used to handle validations and errors before defining the Target)
data PathInfo
= PathInfo
{ pathName :: Text
, pathIsProc :: Bool
, pathIsDefSpec :: Bool
, pathIsRootSpec :: Bool
}
-- | The target db object of a user action
data Target = TargetIdent QualifiedIdentifier
| TargetProc{tProc :: QualifiedIdentifier, tpIsRootSpec :: Bool}
| TargetDefaultSpec{tdsSchema :: Schema} -- The default spec offered at root "/"
{-|
Describes what the user wants to do. This data type is a
translation of the raw elements of an HTTP request into domain
specific language. There is no guarantee that the intent is
sensible, it is up to a later stage of processing to determine
if it is an action we are able to perform.
-}
data ApiRequest = ApiRequest {
iAction :: Action -- ^ Similar but not identical to HTTP method, e.g. Create/Invoke both POST
, iRange :: HM.HashMap Text NonnegRange -- ^ Requested range of rows within response
, iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level
, iTarget :: Target -- ^ The target, be it calling a proc or accessing a table
, iPayload :: Maybe Payload -- ^ Data sent by client and used for mutation actions
, iPreferences :: Preferences.Preferences -- ^ Prefer header values
, iQueryParams :: QueryParams.QueryParams
, iColumns :: S.Set FieldName -- ^ parsed colums from &columns parameter and payload
, iHeaders :: [(ByteString, ByteString)] -- ^ HTTP request headers
, iCookies :: [(ByteString, ByteString)] -- ^ Request Cookies
, iPath :: ByteString -- ^ Raw request path
, iMethod :: ByteString -- ^ Raw request method
, iSchema :: Schema -- ^ The request schema. Can vary depending on profile headers.
, iNegotiatedByProfile :: Bool -- ^ If schema was was chosen according to the profile spec https://www.w3.org/TR/dx-prof-conneg/
, iAcceptMediaType :: MediaType -- ^ The media type in the Accept header
, iContentMediaType :: MediaType -- ^ The media type in the Content-Type header
}
-- | Examines HTTP request and translates it into user intent.
userApiRequest :: AppConfig -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest conf req reqBody = do
pInfo@PathInfo{..} <- getPathInfo conf $ pathInfo req
act <- getAction pInfo method
qPrms <- first QueryParamError $ QueryParams.parse (pathIsProc && act `elem` [ActionInvoke InvGet, ActionInvoke InvHead]) $ rawQueryString req
(acceptMediaType, contentMediaType) <- getMediaTypes conf hdrs act pInfo
(schema, negotiatedByProfile) <- getSchema conf hdrs method
(topLevelRange, ranges) <- getRanges method qPrms hdrs
(payload, columns) <- getPayload reqBody contentMediaType qPrms act pInfo
return $ ApiRequest {
iAction = act
, iTarget = if | pathIsProc -> TargetProc (QualifiedIdentifier schema pathName) pathIsRootSpec
| pathIsDefSpec -> TargetDefaultSpec schema
| otherwise -> TargetIdent $ QualifiedIdentifier schema pathName
, iRange = ranges
, iTopLevelRange = topLevelRange
, iPayload = payload
, iPreferences = Preferences.fromHeaders hdrs
, iQueryParams = qPrms
, iColumns = columns
, iHeaders = iHdrs
, iCookies = iCkies
, iPath = rawPathInfo req
, iMethod = method
, iSchema = schema
, iNegotiatedByProfile = negotiatedByProfile
, iAcceptMediaType = acceptMediaType
, iContentMediaType = contentMediaType
}
where
method = requestMethod req
hdrs = requestHeaders req
lookupHeader = flip lookup hdrs
iHdrs = [ (CI.foldedCase k, v) | (k,v) <- hdrs, k /= hCookie]
iCkies = maybe [] parseCookies $ lookupHeader "Cookie"
getPathInfo :: AppConfig -> [Text] -> Either ApiRequestError PathInfo
getPathInfo AppConfig{configOpenApiMode, configDbRootSpec} path =
case path of
[] -> case configDbRootSpec of
Just (QualifiedIdentifier _ pathName) -> Right $ PathInfo pathName True False True
Nothing | configOpenApiMode == OADisabled -> Left NotFound
| otherwise -> Right $ PathInfo mempty False True False
[table] -> Right $ PathInfo table False False False
["rpc", pName] -> Right $ PathInfo pName True False False
_ -> Left NotFound
getAction :: PathInfo -> ByteString -> Either ApiRequestError Action
getAction PathInfo{pathIsProc, pathIsDefSpec} method =
if pathIsProc && method `notElem` ["HEAD", "GET", "POST", "OPTIONS"]
then Left $ InvalidRpcMethod method
else case method of
-- The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response
-- From https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
"HEAD" | pathIsDefSpec -> Right $ ActionInspect{isHead=True}
| pathIsProc -> Right $ ActionInvoke InvHead
| otherwise -> Right $ ActionRead{isHead=True}
"GET" | pathIsDefSpec -> Right $ ActionInspect{isHead=False}
| pathIsProc -> Right $ ActionInvoke InvGet
| otherwise -> Right $ ActionRead{isHead=False}
"POST" | pathIsProc -> Right $ ActionInvoke InvPost
| otherwise -> Right $ ActionMutate MutationCreate
"PATCH" -> Right $ ActionMutate MutationUpdate
"PUT" -> Right $ ActionMutate MutationSingleUpsert
"DELETE" -> Right $ ActionMutate MutationDelete
"OPTIONS" -> Right ActionInfo
_ -> Left $ UnsupportedMethod method
getMediaTypes :: AppConfig -> RequestHeaders -> Action -> PathInfo -> Either ApiRequestError (MediaType, MediaType)
getMediaTypes conf hdrs action path = do
acceptMediaType <- negotiateContent conf action path accepts
pure (acceptMediaType, contentMediaType)
where
accepts = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"
contentMediaType = maybe MTApplicationJSON MediaType.decodeMediaType $ lookupHeader "content-type"
lookupHeader = flip lookup hdrs
getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool)
getSchema AppConfig{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
getRanges :: ByteString -> QueryParams -> RequestHeaders -> Either ApiRequestError (NonnegRange, HM.HashMap Text NonnegRange)
getRanges method QueryParams{qsOrder,qsRanges} hdrs
| isInvalidRange = Left $ InvalidRange (if rangeIsEmpty headerRange then LowerGTUpper else NegativeLimit)
| method `elem` ["PATCH", "DELETE"] && not (null qsRanges) && null qsOrder = Left LimitNoOrderError
| method == "PUT" && topLevelRange /= allRange = Left PutLimitNotAllowedError
| otherwise = Right (topLevelRange, ranges)
where
-- According to the RFC (https://www.rfc-editor.org/rfc/rfc9110.html#name-range),
-- the Range header must be ignored for all methods other than GET
headerRange = if method == "GET" then rangeRequested hdrs else allRange
limitRange = fromMaybe allRange (HM.lookup "limit" qsRanges)
headerAndLimitRange = rangeIntersection headerRange limitRange
-- 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)
ranges = HM.insert "limit" (convertToLimitZeroRange limitRange headerAndLimitRange) qsRanges
-- The only emptyRange allowed is the limit zero range
isInvalidRange = topLevelRange == emptyRange && not (hasLimitZero limitRange)
topLevelRange = fromMaybe allRange $ HM.lookup "limit" ranges -- if no limit is specified, get all the request rows
getPayload :: RequestBody -> MediaType -> QueryParams.QueryParams -> Action -> PathInfo -> Either ApiRequestError (Maybe Payload, S.Set FieldName)
getPayload reqBody contentMediaType QueryParams{qsColumns} action PathInfo{pathIsProc}= do
checkedPayload <- if shouldParsePayload then payload else Right Nothing
let cols = case (checkedPayload, columns) of
(Just ProcessedJSON{payKeys}, _) -> payKeys
(Just ProcessedUrlEncoded{payKeys}, _) -> payKeys
(Just RawJSON{}, Just cls) -> cls
_ -> S.empty
return (checkedPayload, cols)
where
payload :: Either ApiRequestError (Maybe Payload)
payload = mapBoth InvalidBody Just $ case (contentMediaType, pathIsProc) of
(MTApplicationJSON, _) ->
if isJust columns
then Right $ RawJSON reqBody
else note "All object keys must match" . payloadAttributes reqBody
=<< if LBS.null reqBody && pathIsProc
then Right emptyObject
else first BS.pack $ JSON.eitherDecode reqBody
(MTTextCSV, _) -> do
json <- csvToJson <$> first BS.pack (CSV.decodeByName reqBody)
note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
(MTUrlEncoded, isProc) -> do
let params = (T.decodeUtf8 *** T.decodeUtf8) <$> parseSimpleQuery (LBS.toStrict reqBody)
if isProc
then Right $ ProcessedUrlEncoded params (S.fromList $ fst <$> params)
else
let paramsMap = HM.fromList $ (identity *** JSON.String) <$> params in
Right $ ProcessedJSON (JSON.encode paramsMap) $ S.fromList (HM.keys paramsMap)
(MTTextPlain, True) -> Right $ RawPay reqBody
(MTTextXML, True) -> Right $ RawPay reqBody
(MTOctetStream, True) -> Right $ RawPay reqBody
(ct, _) -> Left $ "Content-Type not acceptable: " <> MediaType.toMime ct
shouldParsePayload = case (action, contentMediaType) of
(ActionMutate MutationCreate, _) -> True
(ActionInvoke InvPost, _) -> True
(ActionMutate MutationSingleUpsert, _) -> True
(ActionMutate MutationUpdate, _) -> True
_ -> False
columns = case action of
ActionMutate MutationCreate -> qsColumns
ActionMutate MutationUpdate -> qsColumns
ActionInvoke InvPost -> qsColumns
_ -> Nothing
type CsvData = V.Vector (M.Map Text LBS.ByteString)
{-|
Converts CSV like
a,b
1,hi
2,bye
into a JSON array like
[ {"a": "1", "b": "hi"}, {"a": 2, "b": "bye"} ]
The reason for its odd signature is so that it can compose
directly with CSV.decodeByName
-}
csvToJson :: (CSV.Header, CsvData) -> JSON.Value
csvToJson (_, vals) =
JSON.Array $ V.map rowToJsonObj vals
where
rowToJsonObj = JSON.Object . KM.fromMapText .
M.map (\str ->
if str == "NULL"
then JSON.Null
else JSON.String . T.decodeUtf8 $ LBS.toStrict str
)
payloadAttributes :: RequestBody -> JSON.Value -> Maybe Payload
payloadAttributes raw json =
-- Test that Array contains only Objects having the same keys
case json of
JSON.Array arr ->
case arr V.!? 0 of
Just (JSON.Object o) ->
let canonicalKeys = S.fromList $ K.toText <$> KM.keys o
areKeysUniform = all (\case
JSON.Object x -> S.fromList (K.toText <$> KM.keys x) == canonicalKeys
_ -> False) arr in
if areKeysUniform
then Just $ ProcessedJSON raw canonicalKeys
else Nothing
Just _ -> Nothing
Nothing -> Just emptyPJArray
JSON.Object o -> Just $ ProcessedJSON raw (S.fromList $ K.toText <$> KM.keys o)
-- truncate everything else to an empty array.
_ -> Just emptyPJArray
where
emptyPJArray = ProcessedJSON (JSON.encode emptyArray) S.empty
-- | Do content negotiation. i.e. choose a media type based on the intersection of accepted/produced media types.
negotiateContent :: AppConfig -> Action -> PathInfo -> [MediaType] -> Either ApiRequestError MediaType
negotiateContent conf action path accepts =
case firstAcceptedPick of
Just MTAny -> Right MTApplicationJSON -- by default(for */*) we respond with json
Just mt -> Right mt
Nothing -> Left . MediaTypeError $ map MediaType.toMime accepts
where
-- if there are multiple accepted media types, pick the first
firstAcceptedPick = listToMaybe $ L.intersect accepts $ producedMediaTypes conf action path
producedMediaTypes :: AppConfig -> Action -> PathInfo -> [MediaType]
producedMediaTypes conf action path =
case action of
ActionRead _ -> defaultMediaTypes ++ rawMediaTypes
ActionInvoke _ -> invokeMediaTypes
ActionInfo -> defaultMediaTypes
ActionMutate _ -> defaultMediaTypes
ActionInspect _ -> inspectMediaTypes
where
inspectMediaTypes = [MTOpenAPI, MTApplicationJSON, MTArrayJSONStrip, MTAny]
invokeMediaTypes =
defaultMediaTypes
++ rawMediaTypes
++ [MTOpenAPI | pathIsRootSpec path]
defaultMediaTypes =
[MTApplicationJSON, MTArrayJSONStrip, MTSingularJSON True, MTSingularJSON False, MTGeoJSON, MTTextCSV] ++
[MTPlan MTApplicationJSON PlanText mempty | configDbPlanEnabled conf] ++ [MTAny]
rawMediaTypes = configRawMediaTypes conf `union` [MTOctetStream, MTTextPlain, MTTextXML]
@@ -1,20 +1,22 @@
-- | -- |
-- 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(..)
, PreferMissing(..)
, PreferParameters(..) , PreferParameters(..)
, PreferRepresentation(..) , PreferRepresentation(..)
, PreferResolution(..) , PreferResolution(..)
, PreferTransaction(..) , PreferTransaction(..)
, fromHeaders , fromHeaders
, ToAppliedHeader(..) , ToAppliedHeader(..)
, shouldCount
) where ) where
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
@@ -32,6 +34,7 @@ import Protolude
-- >>> deriving instance Show PreferParameters -- >>> deriving instance Show PreferParameters
-- >>> deriving instance Show PreferCount -- >>> deriving instance Show PreferCount
-- >>> deriving instance Show PreferTransaction -- >>> deriving instance Show PreferTransaction
-- >>> deriving instance Show PreferMissing
-- >>> deriving instance Show Preferences -- >>> deriving instance Show Preferences
-- | Preferences recognized by the application. -- | Preferences recognized by the application.
@@ -42,6 +45,7 @@ data Preferences
, preferParameters :: Maybe PreferParameters , preferParameters :: Maybe PreferParameters
, preferCount :: Maybe PreferCount , preferCount :: Maybe PreferCount
, preferTransaction :: Maybe PreferTransaction , preferTransaction :: Maybe PreferTransaction
, preferMissing :: Maybe PreferMissing
} }
-- | -- |
@@ -56,17 +60,19 @@ data Preferences
-- , preferParameters = Nothing -- , preferParameters = Nothing
-- , preferCount = Just ExactCount -- , preferCount = Just ExactCount
-- , preferTransaction = Nothing -- , preferTransaction = Nothing
-- , preferMissing = Nothing
-- } -- }
-- --
-- Multiple headers can also be used: -- Multiple headers can also be used:
-- --
-- >>> pPrint $ fromHeaders [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact")] -- >>> pPrint $ fromHeaders [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null")]
-- Preferences -- Preferences
-- { preferResolution = Just IgnoreDuplicates -- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing -- , preferRepresentation = Nothing
-- , preferParameters = Nothing -- , preferParameters = Nothing
-- , preferCount = Just ExactCount -- , preferCount = Just ExactCount
-- , preferTransaction = Nothing -- , preferTransaction = Nothing
-- , preferMissing = Just ApplyNulls
-- } -- }
-- --
-- If a preference is set more than once, only the first is used: -- If a preference is set more than once, only the first is used:
@@ -91,13 +97,14 @@ data Preferences
-- --
-- Preferences can be separated by arbitrary amounts of space, lower-case header is also recognized: -- Preferences can be separated by arbitrary amounts of space, lower-case header is also recognized:
-- --
-- >>> pPrint $ fromHeaders [("prefer", "count=exact, tx=commit ,return=minimal")] -- >>> pPrint $ fromHeaders [("prefer", "count=exact, tx=commit ,return=representation , missing=default")]
-- Preferences -- Preferences
-- { preferResolution = Nothing -- { preferResolution = Nothing
-- , preferRepresentation = Just None -- , preferRepresentation = Just Full
-- , preferParameters = Nothing -- , preferParameters = Nothing
-- , preferCount = Just ExactCount -- , preferCount = Just ExactCount
-- , preferTransaction = Just Commit -- , preferTransaction = Just Commit
-- , preferMissing = Just ApplyDefaults
-- } -- }
-- --
fromHeaders :: [HTTP.Header] -> Preferences fromHeaders :: [HTTP.Header] -> Preferences
@@ -105,9 +112,10 @@ fromHeaders headers =
Preferences Preferences
{ preferResolution = parsePrefs [MergeDuplicates, IgnoreDuplicates] { preferResolution = parsePrefs [MergeDuplicates, IgnoreDuplicates]
, preferRepresentation = parsePrefs [Full, None, HeadersOnly] , preferRepresentation = parsePrefs [Full, None, HeadersOnly]
, preferParameters = parsePrefs [SingleObject, MultipleObjects] , preferParameters = parsePrefs [SingleObject]
, preferCount = parsePrefs [ExactCount, PlannedCount, EstimatedCount] , preferCount = parsePrefs [ExactCount, PlannedCount, EstimatedCount]
, preferTransaction = parsePrefs [Commit, Rollback] , preferTransaction = parsePrefs [Commit, Rollback]
, preferMissing = parsePrefs [ApplyDefaults, ApplyNulls]
} }
where where
prefHeaders = filter ((==) HTTP.hPrefer . fst) headers prefHeaders = filter ((==) HTTP.hPrefer . fst) headers
@@ -155,11 +163,13 @@ instance ToAppliedHeader PreferResolution
-- --
-- From https://tools.ietf.org/html/rfc7240#section-4.2 -- From https://tools.ietf.org/html/rfc7240#section-4.2
data PreferRepresentation data PreferRepresentation
= Full -- ^ Return the body plus the Location header(in case of POST). = Full -- ^ Return the body.
| HeadersOnly -- ^ Return the Location header(in case of POST). This needs a SELECT privilege on the pk. | HeadersOnly -- ^ Return the Location header(in case of POST). This needs a SELECT privilege on the pk.
| None -- ^ Return nothing from the mutated data. | None -- ^ Return nothing from the mutated data.
deriving Eq deriving Eq
instance ToAppliedHeader PreferRepresentation
instance ToHeaderValue PreferRepresentation where instance ToHeaderValue PreferRepresentation where
toHeaderValue Full = "return=representation" toHeaderValue Full = "return=representation"
toHeaderValue None = "return=minimal" toHeaderValue None = "return=minimal"
@@ -168,12 +178,10 @@ instance ToHeaderValue PreferRepresentation where
-- | How to pass parameters to stored procedures. -- | How to pass parameters to stored procedures.
data PreferParameters data PreferParameters
= SingleObject -- ^ Pass all parameters as a single json object to a stored procedure. = SingleObject -- ^ Pass all parameters as a single json object to a stored procedure.
| MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure.
deriving Eq deriving Eq
instance ToHeaderValue PreferParameters where instance ToHeaderValue PreferParameters where
toHeaderValue SingleObject = "params=single-object" toHeaderValue SingleObject = "params=single-object"
toHeaderValue MultipleObjects = "params=multiple-objects"
-- | How to determine the count of (expected) results -- | How to determine the count of (expected) results
data PreferCount data PreferCount
@@ -187,6 +195,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.
@@ -198,3 +210,17 @@ instance ToHeaderValue PreferTransaction where
toHeaderValue Rollback = "tx=rollback" toHeaderValue Rollback = "tx=rollback"
instance ToAppliedHeader PreferTransaction instance ToAppliedHeader PreferTransaction
-- |
-- How to handle the insertion/update when the keys specified in ?columns are not present
-- in the json body.
data PreferMissing
= ApplyDefaults -- ^ Use the default column value for missing values.
| ApplyNulls -- ^ Use the null value for missing values.
deriving Eq
instance ToHeaderValue PreferMissing where
toHeaderValue ApplyDefaults = "missing=default"
toHeaderValue ApplyNulls = "missing=null"
instance ToAppliedHeader PreferMissing
+813
View File
@@ -0,0 +1,813 @@
-- |
-- Module : PostgREST.ApiRequest.QueryParams
-- Description : Parser for PostgREST Query parameters
--
-- 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`.
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE TupleSections #-}
module PostgREST.ApiRequest.QueryParams
( parse
, QueryParams(..)
, pRequestRange
) where
import qualified Data.ByteString.Char8 as BS
import qualified Data.HashMap.Strict as HM
import qualified Data.List as L
import qualified Data.Set as S
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Network.HTTP.Base as HTTP
import qualified Network.HTTP.Types.URI as HTTP
import qualified Text.ParserCombinators.Parsec as P
import Control.Arrow ((***))
import Data.Either.Combinators (mapLeft)
import Data.List (init, last)
import Data.Ranged.Boundaries (Boundary (..))
import Data.Ranged.Ranges (Range (..))
import Data.Tree (Tree (..))
import Text.Parsec.Error (errorMessages,
showErrorMessages)
import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser,
anyChar, between, char, digit,
eof, errorPos, letter,
lookAhead, many1, noneOf,
notFollowedBy, oneOf,
optionMaybe, sepBy, sepBy1,
string, try, (<?>))
import PostgREST.RangeQuery (NonnegRange, allRange,
rangeGeq, rangeLimit,
rangeOffset, restrictRange)
import PostgREST.SchemaCache.Identifiers (FieldName)
import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field,
Filter (..), FtsOperator (..),
Hint, JoinType (..),
JsonOperand (..),
JsonOperation (..), JsonPath,
ListVal, LogicOperator (..),
LogicTree (..), OpExpr (..),
OpQuantifier (..), Operation (..),
OrderDirection (..),
OrderNulls (..), OrderTerm (..),
QPError (..), QuantOperator (..),
SelectItem (..),
SimpleOperator (..), SingleVal,
TrileanVal (..))
import Protolude hiding (try)
data QueryParams =
QueryParams
{ qsCanonical :: ByteString
-- ^ Canonical representation of the query params, sorted alphabetically
, qsParams :: [(Text, Text)]
-- ^ Parameters for RPC calls
, qsRanges :: HM.HashMap Text (Range Integer)
-- ^ Ranges derived from &limit and &offset params
, qsOrder :: [(EmbedPath, [OrderTerm])]
-- ^ &order parameters for each level
, qsLogic :: [(EmbedPath, LogicTree)]
-- ^ &and and &or parameters used for complex boolean logic
, qsColumns :: Maybe (S.Set FieldName)
-- ^ &columns parameter and payload
, qsSelect :: [Tree SelectItem]
-- ^ &select parameter used to shape the response
, qsFilters :: [(EmbedPath, Filter)]
-- ^ Filters on the result from e.g. &id=e.10
, qsFiltersRoot :: [Filter]
-- ^ Subset of the filters that apply on the root table. These are used on UPDATE/DELETE.
, qsFiltersNotRoot :: [(EmbedPath, Filter)]
-- ^ Subset of the filters that do not apply on the root table
, qsFilterFields :: S.Set FieldName
-- ^ Set of fields that filters apply to
, qsOnConflict :: Maybe [FieldName]
-- ^ &on_conflict parameter used to upsert on specific unique keys
}
-- |
-- Parse query parameters from a query string like "id=eq.1&select=name".
--
-- The canonical representation of the query string has parameters sorted alphabetically:
--
-- >>> qsCanonical <$> parse True "a=1&c=3&b=2&d"
-- Right "a=1&b=2&c=3&d="
--
-- 'select' is a reserved parameter that selects the fields to be returned:
--
-- >>> qsSelect <$> parse False "select=name,location"
-- Right [Node {rootLabel = SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectField {selField = ("location",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]
--
-- Filters are parameters whose value contains an operator, separated by a '.' from its value:
--
-- >>> qsFilters <$> parse False "a.b=eq.0"
-- Right [(["a"],Filter {field = ("b",[]), opExpr = OpExpr False (OpQuant OpEqual Nothing "0")})]
--
-- If the operator specified in a filter does not exist, parsing the query string fails:
--
-- >>> qsFilters <$> parse False "a.b=noop.0"
-- Left (QPError "\"failed to parse filter (noop.0)\" (line 1, column 1)" "unexpected \"o\" expecting \"not\" or operator (eq, gt, ...)")
parse :: Bool -> ByteString -> Either QPError QueryParams
parse isRpcGet qs = do
rOrd <- pRequestOrder `traverse` order
rLogic <- pRequestLogicTree `traverse` logic
rCols <- pRequestColumns columns
rSel <- pRequestSelect select
(rFlts, params) <- L.partition hasOp <$> pRequestFilter isRpcGet `traverse` filters
(rFltsRoot, rFltsNotRoot) <- pure $ L.partition hasRootFilter rFlts
rOnConflict <- pRequestOnConflict `traverse` onConflict
let rFltsFields = S.fromList (fst <$> filters)
params' = mapMaybe (\case {(_, Filter (fld, _) (NoOpExpr v)) -> Just (fld,v); _ -> Nothing}) params
rFltsRoot' = snd <$> rFltsRoot
return $ QueryParams canonical params' ranges rOrd rLogic rCols rSel rFlts rFltsRoot' rFltsNotRoot rFltsFields rOnConflict
where
hasRootFilter, hasOp :: (EmbedPath, Filter) -> Bool
hasRootFilter ([], _) = True
hasRootFilter _ = False
hasOp (_, Filter (_, _) (NoOpExpr _)) = False
hasOp _ = True
logic = filter (endingIn ["and", "or"] . fst) nonemptyParams
select = fromMaybe "*" $ lookupParam "select"
onConflict = lookupParam "on_conflict"
columns = lookupParam "columns"
order = filter (endingIn ["order"] . fst) nonemptyParams
limits = filter (endingIn ["limit"] . fst) nonemptyParams
-- Replace .offset ending with .limit to be able to match those params later in a map
offsets = first (replaceLast "limit") <$> filter (endingIn ["offset"] . fst) nonemptyParams
lookupParam :: Text -> Maybe Text
lookupParam needle = toS <$> join (L.lookup needle qParams)
nonemptyParams = mapMaybe (\(k, v) -> (k,) <$> v) qParams
qString = HTTP.parseQueryReplacePlus True qs
qParams = [(T.decodeUtf8 k, T.decodeUtf8 <$> v)|(k,v) <- qString]
canonical =
BS.pack $ HTTP.urlEncodeVars
. L.sortOn fst
. map (join (***) BS.unpack . second (fromMaybe mempty))
$ qString
endingIn:: [Text] -> Text -> Bool
endingIn xx key = lastWord `elem` xx
where lastWord = L.last $ T.split (== '.') key
filters = filter (isFilter . fst) nonemptyParams
isFilter k = not (endingIn reservedEmbeddable k) && notElem k reserved
reserved = ["select", "columns", "on_conflict"]
reservedEmbeddable = ["order", "limit", "offset", "and", "or"]
replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) <> [x]
ranges :: HM.HashMap Text (Range Integer)
ranges = HM.unionWith f limitParams offsetParams
where
f rl ro = Range (BoundaryBelow o) (BoundaryAbove $ o + l - 1)
where
l = fromMaybe 0 $ rangeLimit rl
o = rangeOffset ro
limitParams =
HM.fromList [(k, restrictRange (readMaybe v) allRange) | (k,v) <- limits]
offsetParams =
HM.fromList [(k, maybe allRange rangeGeq (readMaybe v)) | (k,v) <- offsets]
simpleOperator :: Parser SimpleOperator
simpleOperator =
try (string "neq" $> OpNotEqual) <|>
try (string "cs" $> OpContains) <|>
try (string "cd" $> OpContained) <|>
try (string "ov" $> OpOverlap) <|>
try (string "sl" $> OpStrictlyLeft) <|>
try (string "sr" $> OpStrictlyRight) <|>
try (string "nxr" $> OpNotExtendsRight) <|>
try (string "nxl" $> OpNotExtendsLeft) <|>
try (string "adj" $> OpAdjacent) <?>
"unknown single value operator"
quantOperator :: Parser QuantOperator
quantOperator =
try (string "eq" $> OpEqual) <|>
try (string "gte" $> OpGreaterThanEqual) <|>
try (string "gt" $> OpGreaterThan) <|>
try (string "lte" $> OpLessThanEqual) <|>
try (string "lt" $> OpLessThan) <|>
try (string "like" $> OpLike) <|>
try (string "ilike" $> OpILike) <|>
try (string "match" $> OpMatch) <|>
try (string "imatch" $> OpIMatch) <?>
"unknown single value operator"
pRequestSelect :: Text -> Either QPError [Tree SelectItem]
pRequestSelect selStr =
mapError $ P.parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
pRequestOnConflict :: Text -> Either QPError [FieldName]
pRequestOnConflict oncStr =
mapError $ P.parse pColumns ("failed to parse on_conflict parameter (" <> toS oncStr <> ")") (toS oncStr)
-- |
-- Parse `id=eq.1`(id, eq.1) into (EmbedPath, Filter)
--
-- >>> pRequestFilter False ("id", "eq.1")
-- Right ([],Filter {field = ("id",[]), opExpr = OpExpr False (OpQuant OpEqual Nothing "1")})
--
-- >>> pRequestFilter False ("id", "val")
-- Left (QPError "\"failed to parse filter (val)\" (line 1, column 1)" "unexpected \"v\" expecting \"not\" or operator (eq, gt, ...)")
--
-- >>> pRequestFilter True ("id", "val")
-- Right ([],Filter {field = ("id",[]), opExpr = NoOpExpr "val"})
pRequestFilter :: Bool -> (Text, Text) -> Either QPError (EmbedPath, Filter)
pRequestFilter isRpcGet (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
where
treePath = P.parse pTreePath ("failed to parse tree path (" ++ toS k ++ ")") $ toS k
oper = P.parse parseFlt ("failed to parse filter (" ++ toS v ++ ")") $ toS v
parseFlt = if isRpcGet
then pOpExpr pSingleVal <|> pure (NoOpExpr v)
else pOpExpr pSingleVal
path = fst <$> treePath
fld = snd <$> treePath
pRequestOrder :: (Text, Text) -> Either QPError (EmbedPath, [OrderTerm])
pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord'
where
treePath = P.parse pTreePath ("failed to parse tree path (" ++ toS k ++ ")") $ toS k
path = fst <$> treePath
ord' = P.parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v
pRequestRange :: (Text, NonnegRange) -> Either QPError (EmbedPath, NonnegRange)
pRequestRange (k, v) = mapError $ (,) <$> path <*> pure v
where
treePath = P.parse pTreePath ("failed to parse tree path (" ++ toS k ++ ")") $ toS k
path = fst <$> treePath
pRequestLogicTree :: (Text, Text) -> Either QPError (EmbedPath, LogicTree)
pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree
where
path = P.parse pLogicPath ("failed to parse logic path (" ++ toS k ++ ")") $ toS k
embedPath = fst <$> path
logicTree = do
op <- snd <$> path
-- Concat op and v to make pLogicTree argument regular,
-- in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)"
P.parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") $ toS (op <> v)
pRequestColumns :: Maybe Text -> Either QPError (Maybe (S.Set FieldName))
pRequestColumns colStr =
case colStr of
Just str ->
mapError $ Just . S.fromList <$> P.parse pColumns ("failed to parse columns parameter (" <> toS str <> ")") (toS str)
_ -> Right Nothing
ws :: Parser Text
ws = toS <$> many (oneOf " \t")
lexeme :: Parser a -> Parser a
lexeme p = ws *> p <* ws
pTreePath :: Parser (EmbedPath, Field)
pTreePath = do
p <- pFieldName `sepBy1` pDelimiter
jp <- P.option [] pJsonPath
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 "" "*,...client(*),other(*)"
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SpreadRelation {selRelation = "client", selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]},Node {rootLabel = SelectRelation {selRelation = "other", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}]
--
-- >>> P.parse pFieldForest "" ""
-- Right []
--
-- >>> P.parse pFieldForest "" "id,clients(name[])"
-- Left (line 1, column 16):
-- unexpected '['
-- expecting letter, digit, "-", "->>", "->", "::", ")", "," or end of input
--
-- >>> P.parse pFieldForest "" "data->>-78xy"
-- Left (line 1, column 11):
-- unexpected 'x'
-- expecting digit, "->", "::", ".", "," or end of input
pFieldForest :: Parser [Tree SelectItem]
pFieldForest = pFieldTree `sepBy` lexeme (char ',')
where
pFieldTree = Node <$> try pSpreadRelationSelect <*> between (char '(') (char ')') pFieldForest <|>
Node <$> try pRelationSelect <*> between (char '(') (char ')') pFieldForest <|>
Node <$> pFieldSelect <*> pure []
-- |
-- 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 =
pQuotedValue <|>
T.intercalate "-" . map toS <$> (pIdentifier `sepBy1` dash) <?>
"field name (* or [a..z0..9_$])"
where
isDash :: GenParser Char st ()
isDash = try ( char '-' >> notFollowedBy (char '>') )
dash :: Parser Char
dash = isDash $> '-'
-- |
-- Parse json operators in select, order and filters
--
-- >>> P.parse pJsonPath "" "->text"
-- Right [JArrow {jOp = JKey {jVal = "text"}}]
--
-- >>> P.parse pJsonPath "" "->1"
-- Right [JArrow {jOp = JIdx {jVal = "+1"}}]
--
-- >>> P.parse pJsonPath "" "->>text"
-- Right [J2Arrow {jOp = JKey {jVal = "text"}}]
--
-- >>> P.parse pJsonPath "" "->>1"
-- Right [J2Arrow {jOp = JIdx {jVal = "+1"}}]
--
-- >>> P.parse pJsonPath "" "->0,other"
-- Right [JArrow {jOp = JIdx {jVal = "+0"}}]
--
-- >>> P.parse pJsonPath "" "->0.desc"
-- Right [JArrow {jOp = JIdx {jVal = "+0"}}]
--
-- Fails on badly formed negatives
--
-- >>> P.parse pJsonPath "" "->>-78xy"
-- Left (line 1, column 7):
-- unexpected 'x'
-- expecting digit, "->", "::", ".", "," or end of input
--
-- >>> P.parse pJsonPath "" "->>--34"
-- Left (line 1, column 5):
-- unexpected "-"
-- expecting digit
--
-- >>> P.parse pJsonPath "" "->>-xy-4"
-- Left (line 1, column 5):
-- unexpected "x"
-- expecting digit
pJsonPath :: Parser JsonPath
pJsonPath = many pJsonOperation
where
pJsonOperation :: Parser JsonOperation
pJsonOperation = pJsonArrow <*> pJsonOperand
pJsonArrow =
try (string "->>" $> J2Arrow) <|>
try (string "->" $> JArrow)
pJsonOperand =
let pJKey = JKey . toS <$> pFieldName
pJIdx = JIdx . toS <$> ((:) <$> P.option '+' (char '-') <*> many1 digit) <* pEnd
pEnd = try (void $ lookAhead (string "->")) <|>
try (void $ lookAhead (string "::")) <|>
try (void $ lookAhead (string ".")) <|>
try (void $ lookAhead (string ",")) <|>
try eof in
try pJIdx <|> try pJKey
pField :: Parser Field
pField = lexeme $ (,) <$> pFieldName <*> P.option [] pJsonPath
aliasSeparator :: Parser ()
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 = lexeme $ do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
name <- pFieldName
(hint, jType) <- pEmbedParams
try (void $ lookAhead (string "("))
return $ SelectRelation name alias hint jType
-- |
-- 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 = lexeme $ try (do
s <- pStar
pEnd
return $ SelectField (s, []) Nothing Nothing)
<|> do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField
cast' <- optionMaybe (string "::" *> pIdentifier)
pEnd
return $ SelectField fld (toS <$> cast') alias
where
pEnd = try (void $ lookAhead (string ")")) <|>
try (void $ lookAhead (string ",")) <|>
try eof
pStar = string "*" $> "*"
-- |
-- Parse spread relations in select
--
-- >>> P.parse pSpreadRelationSelect "" "...rel(*)"
-- Right (SpreadRelation {selRelation = "rel", selHint = Nothing, selJoinType = Nothing})
--
-- >>> P.parse pSpreadRelationSelect "" "...rel!hint!inner(*)"
-- Right (SpreadRelation {selRelation = "rel", selHint = Just "hint", selJoinType = Just JTInner})
--
-- >>> P.parse pSpreadRelationSelect "" "rel(*)"
-- Left (line 1, column 1):
-- unexpected "r"
-- expecting "..."
--
-- >>> P.parse pSpreadRelationSelect "" "alias:...rel(*)"
-- Left (line 1, column 1):
-- unexpected "a"
-- expecting "..."
--
-- >>> P.parse pSpreadRelationSelect "" "...rel->jsonpath(*)"
-- Left (line 1, column 9):
-- unexpected '>'
pSpreadRelationSelect :: Parser SelectItem
pSpreadRelationSelect = lexeme $ do
name <- string "..." >> pFieldName
(hint, jType) <- pEmbedParams
try (void $ lookAhead (string "("))
return $ SpreadRelation name hint jType
pEmbedParams :: Parser (Maybe Hint, Maybe JoinType)
pEmbedParams = do
prm1 <- optionMaybe pEmbedParam
prm2 <- optionMaybe pEmbedParam
return (embedParamHint prm1 <|> embedParamHint prm2, embedParamJoin prm1 <|> embedParamJoin prm2)
where
pEmbedParam :: Parser EmbedParam
pEmbedParam =
char '!' *> (
try (string "left" $> EPJoinType JTLeft) <|>
try (string "inner" $> EPJoinType JTInner) <|>
try (EPHint <$> pFieldName))
embedParamHint prm = case prm of
Just (EPHint hint) -> Just hint
_ -> Nothing
embedParamJoin prm = case prm of
Just (EPJoinType jt) -> Just jt
_ -> Nothing
-- |
-- Parse operator expression used in horizontal filtering
--
-- >>> P.parse (pOpExpr pSingleVal) "" "fts().value"
-- Left (line 1, column 5):
-- unexpected ")"
-- expecting operator (eq, gt, ...)
--
-- >>> P.parse (pOpExpr pSingleVal) "" "eq(any).value"
-- Right (OpExpr False (OpQuant OpEqual (Just QuantAny) "value"))
--
-- >>> P.parse (pOpExpr pSingleVal) "" "eq(all).value"
-- Right (OpExpr False (OpQuant OpEqual (Just QuantAll) "value"))
--
-- >>> P.parse (pOpExpr pSingleVal) "" "not.eq(all).value"
-- Right (OpExpr True (OpQuant OpEqual (Just QuantAll) "value"))
--
-- >>> P.parse (pOpExpr pSingleVal) "" "eq().value"
-- Left (line 1, column 4):
-- unexpected ")"
-- expecting operator (eq, gt, ...)
--
-- >>> P.parse (pOpExpr pSingleVal) "" "is().value"
-- Left (line 1, column 3):
-- unexpected "("
-- expecting operator (eq, gt, ...)
--
-- >>> P.parse (pOpExpr pSingleVal) "" "in().value"
-- Left (line 1, column 3):
-- unexpected "("
-- expecting operator (eq, gt, ...)
pOpExpr :: Parser SingleVal -> Parser OpExpr
pOpExpr pSVal = do
boolExpr <- try (string "not" *> pDelimiter $> True) <|> pure False
OpExpr boolExpr <$> pOperation
where
pOperation :: Parser Operation
pOperation = pIn <|> pIs <|> pIsDist <|> try pFts <|> try pSimpleOp <|> try pQuantOp <?> "operator (eq, gt, ...)"
pIn = In <$> (try (string "in" *> pDelimiter) *> pListVal)
pIs = Is <$> (try (string "is" *> pDelimiter) *> pTriVal)
pIsDist = IsDistinctFrom <$> (try (string "isdistinct" *> pDelimiter) *> pSVal)
pSimpleOp = do
op <- simpleOperator
pDelimiter *> (Op op <$> pSVal)
pQuantOp = do
op <- quantOperator
quant <- optionMaybe $ try (between (char '(') (char ')') (try (string "any" $> QuantAny) <|> string "all" $> QuantAll))
pDelimiter *> (OpQuant op quant <$> pSVal)
pTriVal = try (ciString "null" $> TriNull)
<|> try (ciString "unknown" $> TriUnknown)
<|> try (ciString "true" $> TriTrue)
<|> try (ciString "false" $> TriFalse)
<?> "null or trilean value (unknown, true, false)"
pFts = do
op <- try (string "fts" $> FilterFts)
<|> try (string "plfts" $> FilterFtsPlain)
<|> try (string "phfts" $> FilterFtsPhrase)
<|> try (string "wfts" $> FilterFtsWebsearch)
lang <- optionMaybe $ try (between (char '(') (char ')') pIdentifier)
pDelimiter >> Fts op (toS <$> lang) <$> pSVal
-- case insensitive char and string
ciChar :: Char -> GenParser Char state Char
ciChar c = char c <|> char (toUpper c)
ciString :: [Char] -> GenParser Char state [Char]
ciString = traverse ciChar
pSingleVal :: Parser SingleVal
pSingleVal = toS <$> many anyChar
pListVal :: Parser ListVal
pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
pListElement :: Parser Text
pListElement = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> (toS <$> many (noneOf ",)"))
pQuotedValue :: Parser Text
pQuotedValue = toS <$> (char '"' *> many pCharsOrSlashed <* char '"')
where
pCharsOrSlashed = noneOf "\\\"" <|> (char '\\' *> anyChar)
pDelimiter :: Parser Char
pDelimiter = char '.' <?> "delimiter (.)"
-- |
-- Parses the elements in the order query parameter
--
-- >>> P.parse pOrder "" "name.desc.nullsfirst"
-- Right [OrderTerm {otTerm = ("name",[]), otDirection = Just OrderDesc, otNullOrder = Just OrderNullsFirst}]
--
-- >>> P.parse pOrder "" "json_col->key.asc.nullslast"
-- Right [OrderTerm {otTerm = ("json_col",[JArrow {jOp = JKey {jVal = "key"}}]), otDirection = Just OrderAsc, otNullOrder = Just OrderNullsLast}]
--
-- >>> P.parse pOrder "" "clients(json_col->key).desc.nullsfirst"
-- Right [OrderRelationTerm {otRelation = "clients", otRelTerm = ("json_col",[JArrow {jOp = JKey {jVal = "key"}}]), otDirection = Just OrderDesc, otNullOrder = Just OrderNullsFirst}]
--
-- >>> P.parse pOrder "" "clients(name,id)"
-- Left (line 1, column 8):
-- unexpected '('
-- expecting letter, digit, "-", "->>", "->", delimiter (.), "," or end of input
--
-- >>> P.parse pOrder "" "name,clients(name),id"
-- Right [OrderTerm {otTerm = ("name",[]), otDirection = Nothing, otNullOrder = Nothing},OrderRelationTerm {otRelation = "clients", otRelTerm = ("name",[]), otDirection = Nothing, otNullOrder = Nothing},OrderTerm {otTerm = ("id",[]), otDirection = Nothing, otNullOrder = Nothing}]
--
-- >>> P.parse pOrder "" "id.ac"
-- Left (line 1, column 4):
-- unexpected "c"
-- expecting "asc", "desc", "nullsfirst" or "nullslast"
--
-- >>> P.parse pOrder "" "id.descc"
-- Left (line 1, column 8):
-- unexpected 'c'
-- expecting delimiter (.), "," or end of input
--
-- >>> P.parse pOrder "" "id.nulsfist"
-- Left (line 1, column 4):
-- unexpected "n"
-- expecting "asc", "desc", "nullsfirst" or "nullslast"
--
-- >>> P.parse pOrder "" "id.nullslasttt"
-- Left (line 1, column 13):
-- unexpected 't'
-- expecting "," or end of input
--
-- >>> P.parse pOrder "" "id.smth34"
-- Left (line 1, column 4):
-- unexpected "s"
-- expecting "asc", "desc", "nullsfirst" or "nullslast"
--
-- >>> P.parse pOrder "" "id.asc.nlsfst"
-- Left (line 1, column 8):
-- unexpected "l"
-- expecting "nullsfirst" or "nullslast"
--
-- >>> P.parse pOrder "" "id.asc.nullslasttt"
-- Left (line 1, column 17):
-- unexpected 't'
-- expecting "," or end of input
--
-- >>> P.parse pOrder "" "id.asc.smth34"
-- Left (line 1, column 8):
-- unexpected "s"
-- expecting "nullsfirst" or "nullslast"
pOrder :: Parser [OrderTerm]
pOrder = lexeme (try pOrderRelationTerm <|> pOrderTerm) `sepBy1` char ','
where
pOrderTerm = do
fld <- pField
dir <- optionMaybe pOrdDir
nls <- optionMaybe pNulls <* pEnd <|>
pEnd $> Nothing
return $ OrderTerm fld dir nls
pOrderRelationTerm = do
nam <- pFieldName
fld <- between (char '(') (char ')') pField
dir <- optionMaybe pOrdDir
nls <- optionMaybe pNulls <* pEnd <|> pEnd $> Nothing
return $ OrderRelationTerm nam fld dir nls
pNulls :: Parser OrderNulls
pNulls = try (pDelimiter *> string "nullsfirst" $> OrderNullsFirst) <|>
try (pDelimiter *> string "nullslast" $> OrderNullsLast)
pOrdDir :: Parser OrderDirection
pOrdDir = try (pDelimiter *> string "asc" $> OrderAsc) <|>
try (pDelimiter *> string "desc" $> OrderDesc)
pEnd = try (void $ lookAhead (char ',')) <|> try eof
-- |
-- Parses the elements inside or/and
--
-- >>> P.parse pLogicTree "" "or()"
-- Left (line 1, column 4):
-- unexpected ")"
-- expecting field name (* or [a..z0..9_$]), negation operator (not) or logic operator (and, or)
--
-- >>> P.parse pLogicTree "" "or(id.in.1,2,id.eq.3)"
-- Left (line 1, column 10):
-- unexpected "1"
-- expecting "("
--
-- >>> P.parse pLogicTree "" "or)("
-- Left (line 1, column 3):
-- unexpected ")"
-- expecting "("
--
-- >>> P.parse pLogicTree "" "and(ord(id.eq.1,id.eq.1),id.eq.2)"
-- Left (line 1, column 7):
-- unexpected "d"
-- expecting "("
--
-- >>> P.parse pLogicTree "" "or(id.eq.1,not.xor(id.eq.2,id.eq.3))"
-- Left (line 1, column 16):
-- unexpected "x"
-- expecting logic operator (and, or)
pLogicTree :: Parser LogicTree
pLogicTree = Stmnt <$> try pLogicFilter
<|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')'))
where
pLogicFilter :: Parser Filter
pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal
pNot :: Parser Bool
pNot = try (string "not" *> pDelimiter $> True)
<|> pure False
<?> "negation operator (not)"
pLogicOp :: Parser LogicOperator
pLogicOp = try (string "and" $> And)
<|> string "or" $> Or
<?> "logic operator (and, or)"
pLogicSingleVal :: Parser SingleVal
pLogicSingleVal = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> try pPgArray <|> (toS <$> many (noneOf ",)"))
where
pPgArray :: Parser Text
pPgArray = do
a <- string "{"
b <- many (noneOf "{}")
c <- string "}"
pure (toS $ a ++ b ++ c)
pLogicPath :: Parser (EmbedPath, Text)
pLogicPath = do
path <- pFieldName `sepBy1` pDelimiter
let op = last path
notOp = "not." <> op
return (filter (/= "not") (init path), if "not" `elem` path then notOp else op)
pColumns :: Parser [FieldName]
pColumns = pFieldName `sepBy1` lexeme (char ',')
pIdentifier :: Parser Text
pIdentifier = T.strip . toS <$> many1 pIdentifierChar
pIdentifierChar :: Parser Char
pIdentifierChar = letter <|> digit <|> oneOf "_ $"
mapError :: Either ParseError a -> Either QPError a
mapError = mapLeft translateError
where
translateError e =
QPError message details
where
message = show $ errorPos e
details = T.strip $ T.replace "\n" " " $ toS
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
+254
View File
@@ -0,0 +1,254 @@
{-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.ApiRequest.Types
( Alias
, Cast
, Depth
, EmbedParam(..)
, ApiRequestError(..)
, EmbedPath
, Field
, Filter(..)
, Hint
, JoinType(..)
, JsonOperand(..)
, JsonOperation(..)
, JsonPath
, ListVal
, LogicOperator(..)
, LogicTree(..)
, NodeName
, OpExpr(..)
, Operation (..)
, OpQuantifier(..)
, OrderDirection(..)
, OrderNulls(..)
, OrderTerm(..)
, QPError(..)
, RangeError(..)
, SingleVal
, TrileanVal(..)
, SimpleOperator(..)
, QuantOperator(..)
, FtsOperator(..)
, SelectItem(..)
) where
import PostgREST.MediaType (MediaType (..))
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier)
import PostgREST.SchemaCache.Relationship (Relationship,
RelationshipsMap)
import PostgREST.SchemaCache.Routine (Routine (..))
import Protolude
-- | The value in `/tbl?select=alias:field::cast`
data SelectItem
= SelectField
{ selField :: Field
, selCast :: Maybe Cast
, selAlias :: Maybe Alias
}
-- | The value in `/tbl?select=alias:another_tbl(*)`
| SelectRelation
{ selRelation :: FieldName
, selAlias :: Maybe Alias
, selHint :: Maybe Hint
, selJoinType :: Maybe JoinType
}
-- | The value in `/tbl?select=...another_tbl(*)`
| SpreadRelation
{ selRelation :: FieldName
, selHint :: Maybe Hint
, selJoinType :: Maybe JoinType
}
deriving (Eq, Show)
data ApiRequestError
= AmbiguousRelBetween Text Text [Relationship]
| AmbiguousRpc [Routine]
| BinaryFieldError MediaType
| MediaTypeError [ByteString]
| InvalidBody ByteString
| InvalidFilters
| InvalidRange RangeError
| InvalidRpcMethod ByteString
| LimitNoOrderError
| NotFound
| NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
| NoRpc Text Text [Text] Bool MediaType Bool [QualifiedIdentifier] [Routine]
| NotEmbedded Text
| PutLimitNotAllowedError
| QueryParamError QPError
| RelatedOrderNotToOne Text Text
| SpreadNotToOne Text Text
| UnacceptableFilter Text
| UnacceptableSchema [Text]
| UnsupportedMethod ByteString
| ColumnNotFound Text Text
deriving Show
data QPError = QPError Text Text
deriving Show
data RangeError
= NegativeLimit
| LowerGTUpper
| OutOfBounds Text Text
deriving Show
type NodeName = Text
type Depth = Integer
data OrderTerm
= OrderTerm
{ otTerm :: Field
, otDirection :: Maybe OrderDirection
, otNullOrder :: Maybe OrderNulls
}
| OrderRelationTerm
{ otRelation :: FieldName
, otRelTerm :: Field
, otDirection :: Maybe OrderDirection
, otNullOrder :: Maybe OrderNulls
}
deriving (Eq, Show)
data OrderDirection
= OrderAsc
| OrderDesc
deriving (Eq, Show)
data OrderNulls
= OrderNullsFirst
| OrderNullsLast
deriving (Eq, Show)
type Field = (FieldName, JsonPath)
type Cast = Text
type Alias = Text
type Hint = Text
data EmbedParam
-- | Disambiguates an embedding operation when there's multiple relationships
-- between two tables. Can be the name of a foreign key constraint, column
-- name or the junction in an m2m relationship.
= EPHint Hint
| EPJoinType JoinType
data JoinType
= JTInner
| JTLeft
deriving (Eq, Show)
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path
-- ["clients", "projects"]
type EmbedPath = [Text]
-- | Json path operations as specified in
-- https://www.postgresql.org/docs/current/static/functions-json.html
type JsonPath = [JsonOperation]
-- | Represents the single arrow `->` or double arrow `->>` operators
data JsonOperation
= JArrow { jOp :: JsonOperand }
| J2Arrow { jOp :: JsonOperand }
deriving (Eq, Show, Ord)
-- | Represents the key(`->'key'`) or index(`->'1`::int`), the index is Text
-- because we reuse our escaping functons and let pg do the casting with
-- '1'::int
data JsonOperand
= JKey { jVal :: Text }
| JIdx { jVal :: Text }
deriving (Eq, Show, Ord)
-- | Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
--
-- And
-- / \
-- name.eq.N Or
-- / \
-- id.eq.1 id.eq.2
data LogicTree
= Expr Bool LogicOperator [LogicTree]
| Stmnt Filter
deriving (Eq, Show)
data LogicOperator
= And
| Or
deriving (Eq, Show)
data Filter
= Filter
{ field :: Field
, opExpr :: OpExpr
}
deriving (Eq, Show)
data OpExpr
= OpExpr Bool Operation
| NoOpExpr Text
deriving (Eq, Show)
data OpQuantifier = QuantAny | QuantAll
deriving (Eq, Show)
data Operation
= Op SimpleOperator SingleVal
| OpQuant QuantOperator (Maybe OpQuantifier) SingleVal
| In ListVal
| Is TrileanVal
| IsDistinctFrom SingleVal
| Fts FtsOperator (Maybe Language) SingleVal
deriving (Eq, Show)
type Language = Text
-- | Represents a single value in a filter, e.g. id=eq.singleval
type SingleVal = Text
-- | Represents a list value in a filter, e.g. id=in.(val1,val2,val3)
type ListVal = [Text]
-- | Three-valued logic values
data TrileanVal
= TriTrue
| TriFalse
| TriNull
| TriUnknown
deriving (Eq, Show)
-- Operators that are quantifiable, i.e. they can be used with the any/all modifiers
data QuantOperator
= OpEqual
| OpGreaterThanEqual
| OpGreaterThan
| OpLessThanEqual
| OpLessThan
| OpLike
| OpILike
| OpMatch
| OpIMatch
deriving (Eq, Show)
data SimpleOperator
= OpNotEqual
| OpContains
| OpContained
| OpOverlap
| OpStrictlyLeft
| OpStrictlyRight
| OpNotExtendsRight
| OpNotExtendsLeft
| OpAdjacent
deriving (Eq, Show)
--
-- | Operators for full text search operators
data FtsOperator
= FilterFts
| FilterFtsPlain
| FilterFtsPhrase
| FilterFtsWebsearch
deriving (Eq, Show)
+121 -506
View File
@@ -17,102 +17,64 @@ 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.String (IsString (..)) import Data.String (IsString (..))
import Data.Time.Clock (UTCTime)
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 Data.HashMap.Strict as HM
import qualified Data.ByteString.Lazy as LBS import qualified Data.Text.Encoding as T
import qualified Data.HashMap.Strict as M
import qualified Data.Set as S
import qualified Hasql.DynamicStatements.Snippet as SQL
import qualified Hasql.Pool as SQL
import qualified Hasql.Transaction as SQL
import qualified Hasql.Transaction.Sessions 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 as Wai
import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Handler.Warp as Warp
import qualified PostgREST.Admin as Admin
import qualified PostgREST.ApiRequest as ApiRequest
import qualified PostgREST.ApiRequest.Types as ApiRequestTypes
import qualified PostgREST.AppState as AppState import qualified PostgREST.AppState as AppState
import qualified PostgREST.Auth as Auth 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.Middleware as Middleware import qualified PostgREST.Logger as Logger
import qualified PostgREST.OpenAPI as OpenAPI import qualified PostgREST.Plan as Plan
import qualified PostgREST.Query.QueryBuilder as QueryBuilder import qualified PostgREST.Query as Query
import qualified PostgREST.Query.Statements as Statements import qualified PostgREST.Response as Response
import qualified PostgREST.RangeQuery as RangeQuery
import qualified PostgREST.Request.ApiRequest as ApiRequest
import qualified PostgREST.Request.DbRequestBuilder as ReqBuilder
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
Mutation (..), Target (..))
import PostgREST.AppState (AppState) import PostgREST.AppState (AppState)
import PostgREST.Config (AppConfig (..), import PostgREST.Auth (AuthResult (..))
LogLevel (..), import PostgREST.Config (AppConfig (..))
OpenAPIMode (..))
import PostgREST.Config.PgVersion (PgVersion (..)) import PostgREST.Config.PgVersion (PgVersion (..))
import PostgREST.ContentType (ContentType (..))
import PostgREST.DbStructure (DbStructure (..),
tablePKCols)
import PostgREST.DbStructure.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema)
import PostgREST.DbStructure.Proc (ProcDescription (..),
ProcVolatility (..))
import PostgREST.DbStructure.Table (Table (..))
import PostgREST.Error (Error) import PostgREST.Error (Error)
import PostgREST.GucHeader (GucHeader, import PostgREST.Query (DbHandler)
addHeadersIfNotIncluded, import PostgREST.SchemaCache (SchemaCache (..))
unwrapGucHeader) import PostgREST.SchemaCache.Routine (Routine (..))
import PostgREST.Request.ApiRequest (Action (..), import PostgREST.Version (docsVersion, prettyVersion)
ApiRequest (..),
InvokeMethod (..),
Target (..))
import PostgREST.Request.Preferences (PreferCount (..),
PreferParameters (..),
PreferRepresentation (..),
toAppliedHeader)
import PostgREST.Request.Types (ReadRequest, fstFieldNames)
import PostgREST.Version (prettyVersion)
import PostgREST.Workers (connectionWorker, listener)
import qualified PostgREST.ContentType as ContentType
import qualified PostgREST.DbStructure.Proc as Proc
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 AppState.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 AppState.runListener conf appState
let app = postgrest configLogLevel appState (connectionWorker appState) Admin.runAdmin conf appState $ serverSettings conf
let app = postgrest conf appState (AppState.connectionWorker appState)
case configServerUnixSocket of case configServerUnixSocket of
Just socket -> Just socket ->
@@ -122,7 +84,7 @@ run installHandlers maybeRunWithSocket appState = do
AppState.logWithZTime appState $ "Listening on unix socket " <> show socket AppState.logWithZTime appState $ "Listening on unix socket " <> show socket
runWithSocket (serverSettings conf) app configServerUnixSocketMode socket runWithSocket (serverSettings conf) app configServerUnixSocketMode socket
Nothing -> Nothing ->
panic "Cannot run with socket on non-unix plattforms." panic "Cannot run with unix socket on non-unix platforms."
Nothing -> Nothing ->
do do
AppState.logWithZTime appState $ "Listening on port " <> show configServerPort AppState.logWithZTime appState $ "Listening on port " <> show configServerPort
@@ -136,476 +98,129 @@ serverSettings AppConfig{..} =
& setServerName ("postgrest/" <> prettyVersion) & setServerName ("postgrest/" <> prettyVersion)
-- | PostgREST application -- | PostgREST application
postgrest :: LogLevel -> AppState.AppState -> IO () -> Wai.Application postgrest :: AppConfig -> AppState.AppState -> IO () -> Wai.Application
postgrest logLev appState connWorker = postgrest conf appState connWorker =
Middleware.pgrstMiddleware logLev $ Response.traceHeaderMiddleware conf .
\req respond -> do Cors.middleware .
time <- AppState.getTime appState Auth.middleware appState .
conf <- AppState.getConfig appState Logger.middleware (configLogLevel conf) $
maybeDbStructure <- AppState.getDbStructure appState -- fromJust can be used, because the auth middleware will **always** add
-- some AuthResult to the vault.
\req respond -> case fromJust $ Auth.getResult req of
Left err -> respond $ Error.errorResponseFor err
Right authResult -> do
appConf <- AppState.getConfig appState -- the config must be read again because it can reload
maybeSchemaCache <- AppState.getSchemaCache appState
pgVer <- AppState.getPgVersion appState pgVer <- AppState.getPgVersion appState
jsonDbS <- AppState.getJsonDbS appState
let let
eitherResponse :: IO (Either Error Wai.Response) eitherResponse :: IO (Either Error Wai.Response)
eitherResponse = eitherResponse =
runExceptT $ postgrestResponse conf maybeDbStructure jsonDbS pgVer (AppState.getPool appState) time req runExceptT $ postgrestResponse appState appConf maybeSchemaCache 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
:: AppConfig :: AppState.AppState
-> Maybe DbStructure -> AppConfig
-> ByteString -> Maybe SchemaCache
-> PgVersion -> PgVersion
-> SQL.Pool -> AuthResult
-> UTCTime
-> Wai.Request -> Wai.Request
-> Handler IO Wai.Response -> Handler IO Wai.Response
postgrestResponse conf maybeDbStructure jsonDbS pgVer pool time req = do postgrestResponse appState conf@AppConfig{..} maybeSchemaCache pgVer authResult@AuthResult{..} req = do
sCache <-
case maybeSchemaCache of
Just sCache ->
return sCache
Nothing ->
throwError Error.NoSchemaCacheError
body <- lift $ Wai.strictRequestBody req body <- lift $ Wai.strictRequestBody req
dbStructure <- apiRequest <-
case maybeDbStructure of
Just dbStructure ->
return dbStructure
Nothing ->
throwError Error.ConnectionLostError
apiRequest@ApiRequest{..} <-
liftEither . mapLeft Error.ApiRequestError $ liftEither . mapLeft Error.ApiRequestError $
ApiRequest.userApiRequest conf dbStructure req body ApiRequest.userApiRequest conf req body
-- The JWT must be checked before touching the db Response.optionalRollback conf apiRequest $
jwtClaims <- Auth.jwtClaims conf (toUtf8Lazy iJWT) time handleRequest authResult conf appState (Just authRole /= configDbAnonRole) configDbPreparedStatements pgVer apiRequest sCache
let runDbHandler :: AppState.AppState -> SQL.IsolationLevel -> SQL.Mode -> Bool -> Bool -> DbHandler b -> Handler IO b
handleReq apiReq = runDbHandler appState isoLvl mode authenticated prepared handler = do
handleRequest $ RequestContext conf dbStructure apiReq pgVer dbResp <- lift $ do
let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction
runDbHandler pool (txMode apiRequest) jwtClaims (configDbPreparedStatements conf) . AppState.usePool appState . transaction isoLvl mode $ runExceptT handler
Middleware.optionalRollback conf apiRequest $
Middleware.runPgLocals conf jwtClaims handleReq apiRequest jsonDbS pgVer
runDbHandler :: SQL.Pool -> SQL.Mode -> Auth.JWTClaims -> Bool -> DbHandler a -> Handler IO a
runDbHandler pool mode jwtClaims prepared handler = do
dbResp <-
let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in
lift . SQL.use pool . transaction SQL.ReadCommitted mode $ runExceptT handler
resp <- resp <-
liftEither . mapLeft Error.PgErr $ liftEither . mapLeft Error.PgErr $
mapLeft (Error.PgError $ Auth.containsRole jwtClaims) dbResp mapLeft (Error.PgError authenticated) dbResp
liftEither resp liftEither resp
handleRequest :: RequestContext -> DbHandler Wai.Response handleRequest :: AuthResult -> AppConfig -> AppState.AppState -> Bool -> Bool -> PgVersion -> ApiRequest -> SchemaCache -> Handler IO Wai.Response
handleRequest context@(RequestContext _ _ ApiRequest{..} _) = handleRequest AuthResult{..} conf appState authenticated prepared 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 wrPlan <- liftEither $ Plan.wrappedReadPlan identifier conf sCache apiReq
(ActionCreate, TargetIdent identifier) -> resultSet <- runQuery roleIsoLvl (Plan.wrTxMode wrPlan) $ Query.readQuery wrPlan conf apiReq
handleCreate identifier context return $ Response.readResponse headersOnly identifier apiReq resultSet
(ActionUpdate, TargetIdent identifier) ->
handleUpdate identifier context (ActionMutate MutationCreate, TargetIdent identifier) -> do
(ActionSingleUpsert, TargetIdent identifier) -> mrPlan <- liftEither $ Plan.mutateReadPlan MutationCreate apiReq identifier conf sCache
handleSingleUpsert identifier context resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.createQuery mrPlan apiReq conf
(ActionDelete, TargetIdent identifier) -> return $ Response.createResponse identifier mrPlan apiReq resultSet
handleDelete identifier context
(ActionMutate MutationUpdate, TargetIdent identifier) -> do
mrPlan <- liftEither $ Plan.mutateReadPlan MutationUpdate apiReq identifier conf sCache
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.updateQuery mrPlan apiReq conf
return $ Response.updateResponse apiReq resultSet
(ActionMutate MutationSingleUpsert, TargetIdent identifier) -> do
mrPlan <- liftEither $ Plan.mutateReadPlan MutationSingleUpsert apiReq identifier conf sCache
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ 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 roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.deleteQuery mrPlan apiReq conf
return $ Response.deleteResponse apiReq resultSet
(ActionInvoke invMethod, TargetProc identifier _) -> do
cPlan <- liftEither $ Plan.callReadPlan identifier conf sCache apiReq invMethod
resultSet <- runQuery (fromMaybe roleIsoLvl $ pdIsoLvl (Plan.crProc cPlan))(Plan.crTxMode cPlan) $ Query.invokeQuery (Plan.crProc cPlan) cPlan apiReq conf pgVer
return $ Response.invokeResponse invMethod (Plan.crProc cPlan) apiReq resultSet
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do
oaiResult <- runQuery roleIsoLvl Plan.inspectPlanTxMode $ Query.openApiQuery sCache pgVer conf tSchema
return $ Response.openApiResponse (T.decodeUtf8 prettyVersion, docsVersion) headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile
(ActionInfo, TargetIdent identifier) -> (ActionInfo, TargetIdent identifier) ->
handleInfo identifier context return $ Response.infoIdentResponse identifier sCache
(ActionInvoke invMethod, TargetProc proc _) ->
handleInvoke invMethod proc context (ActionInfo, TargetProc identifier _) -> do
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> cPlan <- liftEither $ Plan.callReadPlan identifier conf sCache apiReq ApiRequest.InvHead
handleOpenApi headersOnly tSchema context return $ Response.infoProcResponse (Plan.crProc cPlan)
(ActionInfo, TargetDefaultSpec _) ->
return Response.infoRootResponse
_ -> _ ->
throwError Error.NotFound -- This is unreachable as the ApiRequest.hs rejects it before
-- TODO Refactor the Action/Target types to remove this line
handleRead :: Bool -> QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response throwError $ Error.ApiRequestError ApiRequestTypes.NotFound
handleRead headersOnly identifier context@RequestContext{..} = do
req <- readRequest identifier context
bField <- binaryField context req
let
ApiRequest{..} = ctxApiRequest
AppConfig{..} = ctxConfig
countQuery = QueryBuilder.readRequestToCountQuery req
(tableTotal, queryTotal, _ , body, gucHeaders, gucStatus) <-
lift . SQL.statement mempty $
Statements.createReadStatement
(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
)
(iAcceptContentType == CTSingularJSON)
(shouldCount iPreferCount)
(iAcceptContentType == CTTextCSV)
bField
configDbPreparedStatements
total <- readTotal ctxConfig ctxApiRequest tableTotal countQuery
response <- liftEither $ gucResponse <$> gucStatus <*> gucHeaders
let
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange queryTotal total
headers =
[ contentRange
, ( "Content-Location"
, "/"
<> toUtf8 (qiName identifier)
<> if BS.null iCanonicalQS then mempty else "?" <> iCanonicalQS
)
]
++ contentTypeHeaders context
failNotSingular iAcceptContentType queryTotal . response status headers $
if headersOnly then mempty else LBS.fromStrict body
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 = roleSettings = fromMaybe mempty (HM.lookup authRole $ configRoleSettings conf)
lift . SQL.statement mempty . Statements.createExplainStatement countQuery $ roleIsoLvl = HM.findWithDefault SQL.ReadCommitted authRole $ configRoleIsoLvl conf
configDbPreparedStatements runQuery isoLvl mode query =
runDbHandler appState isoLvl mode authenticated prepared $ do
handleCreate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response Query.setPgLocals conf authClaims authRole (HM.toList roleSettings) apiReq pgVer
handleCreate identifier@QualifiedIdentifier{..} context@RequestContext{..} = do Query.runPreReq conf
let query
ApiRequest{..} = ctxApiRequest
pkCols = tablePKCols ctxDbStructure qiSchema qiName
WriteQueryResult{..} <- writeQuery identifier True pkCols context
let
response = gucResponse resGucStatus resGucHeaders
headers =
catMaybes
[ if null resFields then
Nothing
else
Just
( HTTP.hLocation
, "/"
<> toUtf8 qiName
<> HTTP.renderSimpleQuery True (splitKeyValue <$> resFields)
)
, Just . RangeQuery.contentRangeH 1 0 $
if shouldCount iPreferCount then Just resQueryTotal else Nothing
, if null pkCols && isNothing iOnConflict then
Nothing
else
toAppliedHeader <$> iPreferResolution
]
failNotSingular iAcceptContentType resQueryTotal $
if iPreferRepresentation == Full then
response HTTP.status201 (headers ++ contentTypeHeaders context) (LBS.fromStrict resBody)
else
response HTTP.status201 headers mempty
handleUpdate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleUpdate identifier context@(RequestContext _ _ ApiRequest{..} _) = do
WriteQueryResult{..} <- writeQuery identifier False mempty context
let
response = gucResponse resGucStatus resGucHeaders
fullRepr = iPreferRepresentation == Full
updateIsNoOp = S.null iColumns
status
| resQueryTotal == 0 && not updateIsNoOp = HTTP.status404
| fullRepr = HTTP.status200
| otherwise = HTTP.status204
contentRangeHeader =
RangeQuery.contentRangeH 0 (resQueryTotal - 1) $
if shouldCount iPreferCount then Just resQueryTotal else Nothing
failNotSingular iAcceptContentType resQueryTotal $
if fullRepr then
response status (contentTypeHeaders context ++ [contentRangeHeader]) (LBS.fromStrict resBody)
else
response status [contentRangeHeader] mempty
handleSingleUpsert :: QualifiedIdentifier -> RequestContext-> DbHandler Wai.Response
handleSingleUpsert identifier context@(RequestContext _ _ ApiRequest{..} _) = do
when (iTopLevelRange /= RangeQuery.allRange) $
throwError Error.PutRangeNotAllowedError
WriteQueryResult{..} <- writeQuery identifier False mempty context
let response = gucResponse resGucStatus resGucHeaders
-- 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 (resQueryTotal /= 1) $ do
lift SQL.condemn
throwError Error.PutMatchingPkError
return $
if iPreferRepresentation == Full then
response HTTP.status200 (contentTypeHeaders context) (LBS.fromStrict resBody)
else
response HTTP.status204 (contentTypeHeaders context) mempty
handleDelete :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleDelete identifier context@(RequestContext _ _ ApiRequest{..} _) = do
WriteQueryResult{..} <- writeQuery identifier False mempty context
let
response = gucResponse resGucStatus resGucHeaders
contentRangeHeader =
RangeQuery.contentRangeH 1 0 $
if shouldCount iPreferCount then Just resQueryTotal else Nothing
failNotSingular iAcceptContentType resQueryTotal $
if iPreferRepresentation == Full then
response HTTP.status200
(contentTypeHeaders context ++ [contentRangeHeader])
(LBS.fromStrict resBody)
else
response HTTP.status204 [contentRangeHeader] mempty
handleInfo :: Monad m => QualifiedIdentifier -> RequestContext -> Handler m Wai.Response
handleInfo identifier RequestContext{..} =
case find tableMatches $ dbTables ctxDbStructure of
Just table ->
return $ Wai.responseLBS HTTP.status200 [allOrigins, allowH table] mempty
Nothing ->
throwError Error.NotFound
where
allOrigins = ("Access-Control-Allow-Origin", "*")
allowH table =
( HTTP.hAllow
, BS.intercalate "," $
["OPTIONS,GET,HEAD"]
++ ["POST" | tableInsertable table]
++ ["PUT" | tableInsertable table && tableUpdatable table && hasPK]
++ ["PATCH" | tableUpdatable table]
++ ["DELETE" | tableDeletable table]
)
tableMatches table =
tableName table == qiName identifier
&& tableSchema table == qiSchema identifier
hasPK =
not $ null $ tablePKCols ctxDbStructure (qiSchema identifier) (qiName identifier)
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
(tableTotal, queryTotal, body, gucHeaders, gucStatus) <-
lift . SQL.statement mempty $
Statements.callProcStatement
(Proc.procReturnsScalar proc)
(Proc.procReturnsSingle proc)
(QueryBuilder.requestToCallProcQuery callReq)
(QueryBuilder.readRequestToQuery req)
(QueryBuilder.readRequestToCountQuery req)
(shouldCount iPreferCount)
(iAcceptContentType == CTSingularJSON)
(iAcceptContentType == CTTextCSV)
(iPreferParameters == Just MultipleObjects)
bField
(configDbPreparedStatements ctxConfig)
response <- liftEither $ gucResponse <$> gucStatus <*> gucHeaders
let
(status, contentRange) =
RangeQuery.rangeStatusHeader iTopLevelRange queryTotal tableTotal
failNotSingular iAcceptContentType queryTotal $
response status
(contentTypeHeaders context ++ [contentRange])
(if invMethod == InvHead then mempty else LBS.fromStrict body)
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 configDbPreparedStatements)
<*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)
OAIgnorePriv ->
OpenAPI.encode conf dbStructure
(filter (\x -> tableSchema x == tSchema) $ DbStructure.dbTables dbStructure)
(M.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ DbStructure.dbProcs dbStructure)
<$> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)
OADisabled ->
pure mempty
return $
Wai.responseLBS HTTP.status200
(ContentType.toHeader CTOpenAPI : 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
-- | Result from executing a write query on the database
data WriteQueryResult = WriteQueryResult
{ resQueryTotal :: Int64
, resFields :: [ByteString]
, resBody :: ByteString
, resGucStatus :: Maybe HTTP.Status
, resGucHeaders :: [GucHeader]
}
writeQuery :: QualifiedIdentifier -> Bool -> [Text] -> RequestContext -> DbHandler WriteQueryResult
writeQuery identifier@QualifiedIdentifier{..} isInsert pkCols context@RequestContext{..} = do
readReq <- readRequest identifier context
mutateReq <-
liftEither $
ReqBuilder.mutateRequest qiSchema qiName ctxApiRequest
(tablePKCols ctxDbStructure qiSchema qiName)
readReq
(_, queryTotal, fields, body, gucHeaders, gucStatus) <-
lift . SQL.statement mempty $
Statements.createWriteStatement
(QueryBuilder.readRequestToQuery readReq)
(QueryBuilder.mutateRequestToQuery mutateReq)
(iAcceptContentType ctxApiRequest == CTSingularJSON)
isInsert
(iAcceptContentType ctxApiRequest == CTTextCSV)
(iPreferRepresentation ctxApiRequest)
pkCols
(configDbPreparedStatements ctxConfig)
liftEither $ WriteQueryResult queryTotal fields body <$> gucStatus <*> gucHeaders
-- | 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 :: ContentType -> Int64 -> Wai.Response -> DbHandler Wai.Response
failNotSingular contentType queryTotal response =
if contentType == CTSingularJSON && queryTotal /= 1 then
do
lift SQL.condemn
throwError $ Error.singularityError queryTotal
else
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{..} =
ContentType.toHeader (iAcceptContentType ctxApiRequest) : maybeToList (profileHeader ctxApiRequest)
-- | If raw(binary) output is requested, check that ContentType is one of the
-- admitted rawContentTypes and that`?select=...` contains only one field other
-- than `*`
binaryField :: Monad m => RequestContext -> ReadRequest -> Handler m (Maybe FieldName)
binaryField RequestContext{..} readReq
| returnsScalar (iTarget ctxApiRequest) && iAcceptContentType ctxApiRequest `elem` rawContentTypes ctxConfig =
return $ Just "pgrst_scalar"
| iAcceptContentType ctxApiRequest `elem` rawContentTypes ctxConfig =
let
fldNames = fstFieldNames readReq
fieldName = headMay fldNames
in
if length fldNames == 1 && fieldName /= Just "*" then
return fieldName
else
throwError $ Error.BinaryFieldError (iAcceptContentType ctxApiRequest)
| otherwise =
return Nothing
rawContentTypes :: AppConfig -> [ContentType]
rawContentTypes AppConfig{..} =
(ContentType.decodeContentType <$> configRawMediaTypes) `union` [CTOctetStream, CTTextPlain]
profileHeader :: ApiRequest -> Maybe HTTP.Header
profileHeader ApiRequest{..} =
(,) "Content-Profile" <$> (toUtf8 <$> iProfile)
splitKeyValue :: ByteString -> (ByteString, ByteString)
splitKeyValue kv =
(k, BS.tail v)
where
(k, v) = BS.break (== '=') kv
+372 -49
View File
@@ -1,58 +1,80 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.AppState module PostgREST.AppState
( AppState ( AppState
, destroy
, getConfig , getConfig
, getDbStructure , getSchemaCache
, getIsWorkerOn , getIsListenerOn
, getJsonDbS
, getMainThreadId , getMainThreadId
, getPgVersion , getPgVersion
, getPool
, getTime
, getRetryNextIn , getRetryNextIn
, getTime
, init , init
, initWithPool , initWithPool
, logWithZTime , logWithZTime
, putConfig , putSchemaCache
, putDbStructure
, putIsWorkerOn
, putJsonDbS
, putPgVersion , putPgVersion
, putRetryNextIn , usePool
, releasePool , loadSchemaCache
, signalListener , reReadConfig
, waitListener , connectionWorker
, runListener
) where ) where
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS
import Data.Either.Combinators (whenLeft)
import qualified Data.Text.Encoding as T
import Hasql.Connection (acquire)
import qualified Hasql.Notifications as SQL
import qualified Hasql.Pool as SQL import qualified Hasql.Pool as SQL
import qualified Hasql.Session as SQL
import qualified Hasql.Transaction.Sessions as SQL
import qualified PostgREST.Error as Error
import PostgREST.Version (prettyVersion)
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate, import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
updateAction) updateAction)
import Control.Debounce
import Control.Retry (RetryStatus, capDelay, exponentialBackoff,
retrying, rsPreviousDelay)
import Data.IORef (IORef, atomicWriteIORef, newIORef, import Data.IORef (IORef, atomicWriteIORef, newIORef,
readIORef) readIORef)
import Data.Time (ZonedTime, defaultTimeLocale, formatTime, import Data.Time (ZonedTime, defaultTimeLocale, formatTime,
getZonedTime) getZonedTime)
import Data.Time.Clock (UTCTime, getCurrentTime) import Data.Time.Clock (UTCTime, getCurrentTime)
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..),
import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion) addFallbackAppName,
import PostgREST.DbStructure (DbStructure) readAppConfig)
import PostgREST.Config.Database (queryDbSettings,
queryPgVersion,
queryRoleSettings)
import PostgREST.Config.PgVersion (PgVersion (..),
minimumPgVersion)
import PostgREST.SchemaCache (SchemaCache,
querySchemaCache)
import PostgREST.SchemaCache.Identifiers (dumpQi)
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 -- | starts the connection worker with a debounce
, stateJsonDbS :: IORef ByteString , debouncedConnectionWorker :: IO ()
-- | Helper ref to make sure just one connectionWorker can run at a time
, stateIsWorkerOn :: IORef Bool
-- | Binary semaphore used to sync the listener(NOTIFY reload) with the connectionWorker. -- | Binary semaphore used to sync the listener(NOTIFY reload) with the connectionWorker.
, stateListener :: MVar () , stateListener :: MVar ()
-- | State of the LISTEN channel, used for the admin server checks
, stateIsListenerOn :: IORef Bool
-- | Config that can change at runtime -- | Config that can change at runtime
, stateConf :: IORef AppConfig , stateConf :: IORef AppConfig
-- | Time used for verifying JWT expiration -- | Time used for verifying JWT expiration
@@ -63,36 +85,78 @@ data AppState = AppState
, stateMainThreadId :: ThreadId , stateMainThreadId :: ThreadId
-- | Keeps track of when the next retry for connecting to database is scheduled -- | Keeps track of when the next retry for connecting to database is scheduled
, stateRetryNextIn :: IORef Int , stateRetryNextIn :: IORef Int
-- | Logs a pool error with a debounce
, debounceLogAcquisitionTimeout :: IO ()
} }
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 = do
AppState newPool appState <- AppState pool
<$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step <$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step
<*> newIORef Nothing <*> newIORef Nothing
<*> newIORef mempty <*> pure (pure ())
<*> newIORef False
<*> newEmptyMVar <*> newEmptyMVar
<*> newIORef False
<*> newIORef conf <*> newIORef conf
<*> mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime } <*> mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
<*> mkAutoUpdate defaultUpdateSettings { updateAction = getZonedTime } <*> mkAutoUpdate defaultUpdateSettings { updateAction = getZonedTime }
<*> myThreadId <*> myThreadId
<*> newIORef 0 <*> newIORef 0
<*> pure (pure ())
debLogTimeout <-
let oneSecond = 1000000 in
mkDebounce defaultDebounceSettings
{ debounceAction = logPgrstError appState SQL.AcquisitionTimeoutUsageError
, debounceFreq = 5*oneSecond
, debounceEdge = leadingEdge -- logs at the start and the end
}
debWorker <-
let decisecond = 100000 in
mkDebounce defaultDebounceSettings
{ debounceAction = internalConnectionWorker appState
, debounceFreq = decisecond
, debounceEdge = leadingEdge -- runs the worker at the start and the end
}
return appState { debounceLogAcquisitionTimeout = debLogTimeout, debouncedConnectionWorker = debWorker }
destroy :: AppState -> IO ()
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
(fromIntegral configDbPoolAcquisitionTimeout)
(fromIntegral configDbPoolMaxLifetime)
(fromIntegral configDbPoolMaxIdletime)
(toUtf8 $ addFallbackAppName prettyVersion configDbUri)
getPool :: AppState -> SQL.Pool -- | Run an action with a database connection.
getPool = statePool usePool :: AppState -> SQL.Session a -> IO (Either SQL.UsageError a)
usePool AppState{..} x = do
res <- SQL.use statePool x
whenLeft res (\case
SQL.AcquisitionTimeoutUsageError -> debounceLogAcquisitionTimeout -- this can happen rapidly for many requests, so we debounce
_ -> pure ())
return res
releasePool :: AppState -> IO () -- | Flush the connection pool so that any future use of the pool will
releasePool AppState{..} = SQL.release statePool >> throwTo stateMainThreadId UserInterrupt -- 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
@@ -100,24 +164,14 @@ 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 -> DbStructure -> IO () putSchemaCache :: AppState -> Maybe SchemaCache -> IO ()
putDbStructure appState structure = putSchemaCache appState = atomicWriteIORef (stateSchemaCache appState)
atomicWriteIORef (stateDbStructure appState) $ Just structure
getJsonDbS :: AppState -> IO ByteString connectionWorker :: AppState -> IO ()
getJsonDbS = readIORef . stateJsonDbS connectionWorker = debouncedConnectionWorker
putJsonDbS :: AppState -> ByteString -> IO ()
putJsonDbS appState = atomicWriteIORef (stateJsonDbS appState)
getIsWorkerOn :: AppState -> IO Bool
getIsWorkerOn = readIORef . stateIsWorkerOn
putIsWorkerOn :: AppState -> Bool -> IO ()
putIsWorkerOn = atomicWriteIORef . stateIsWorkerOn
getRetryNextIn :: AppState -> IO Int getRetryNextIn :: AppState -> IO Int
getRetryNextIn = readIORef . stateRetryNextIn getRetryNextIn = readIORef . stateRetryNextIn
@@ -140,6 +194,9 @@ logWithZTime appState txt = do
zTime <- stateGetZTime appState zTime <- stateGetZTime appState
hPutStrLn stderr $ toS (formatTime defaultTimeLocale "%d/%b/%Y:%T %z: " zTime) <> txt hPutStrLn stderr $ toS (formatTime defaultTimeLocale "%d/%b/%Y:%T %z: " zTime) <> txt
logPgrstError :: AppState -> SQL.UsageError -> IO ()
logPgrstError appState e = logWithZTime appState . T.decodeUtf8 . LBS.toStrict $ Error.errorPayload $ Error.PgError False e
getMainThreadId :: AppState -> ThreadId getMainThreadId :: AppState -> ThreadId
getMainThreadId = stateMainThreadId getMainThreadId = stateMainThreadId
@@ -153,3 +210,269 @@ waitListener = takeMVar . stateListener
-- the connectionWorker is the only mvar producer. -- the connectionWorker is the only mvar producer.
signalListener :: AppState -> IO () signalListener :: AppState -> IO ()
signalListener appState = void $ tryPutMVar (stateListener appState) () signalListener appState = void $ tryPutMVar (stateListener appState) ()
getIsListenerOn :: AppState -> IO Bool
getIsListenerOn = readIORef . stateIsListenerOn
putIsListenerOn :: AppState -> Bool -> IO ()
putIsListenerOn = atomicWriteIORef . stateIsListenerOn
-- | Schema cache status
data SCacheStatus
= SCLoaded
| SCOnRetry
| SCFatalFail
-- | Load the SchemaCache by using a connection from the pool.
loadSchemaCache :: AppState -> IO SCacheStatus
loadSchemaCache appState = do
conf@AppConfig{..} <- getConfig appState
result <-
let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
usePool appState . transaction SQL.ReadCommitted SQL.Read $
querySchemaCache conf
case result of
Left e -> do
case checkIsFatal e of
Just hint -> do
logWithZTime appState "A fatal error ocurred when loading the schema cache"
logPgrstError appState e
logWithZTime appState hint
return SCFatalFail
Nothing -> do
putSchemaCache appState Nothing
logWithZTime appState "An error ocurred when loading the schema cache"
logPgrstError appState e
return SCOnRetry
Right sCache -> do
putSchemaCache appState (Just sCache)
logWithZTime appState "Schema cache loaded"
return SCLoaded
-- | Current database connection status data ConnectionStatus
data ConnectionStatus
= NotConnected
| Connected PgVersion
| FatalConnectionError Text
deriving (Eq)
-- | The purpose of this worker is to obtain a healthy connection to pg and an
-- 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
-- invocation has not terminated. In all cases this method does not halt the
-- calling thread, the work is performed in a separate thread.
--
-- Background thread that does the following :
-- 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
-- program.
-- 3. Obtains the sCache. If this fails, it goes back to 1.
internalConnectionWorker :: AppState -> IO ()
internalConnectionWorker appState = work
where
work = do
AppConfig{..} <- getConfig appState
logWithZTime appState $ "Starting PostgREST " <> T.decodeUtf8 prettyVersion <> "..."
logWithZTime appState "Attempting to connect to the database..."
connected <- establishConnection appState
case connected of
FatalConnectionError reason ->
-- Fatal error when connecting
logWithZTime appState reason >> killThread (getMainThreadId appState)
NotConnected ->
-- Unreachable because establishConnection will keep trying to connect
return ()
Connected actualPgVersion -> do
-- Procede with initialization
putPgVersion appState actualPgVersion
when configDbChannelEnabled $
signalListener appState
logWithZTime appState "Connection successful"
-- this could be fail because the connection drops, but the loadSchemaCache will pick the error and retry again
-- We cannot retry after it fails immediately, because db-pre-config could have user errors. We just log the error and continue.
when configDbConfig $ reReadConfig False appState
scStatus <- loadSchemaCache appState
case scStatus of
SCLoaded ->
-- do nothing and proceed if the load was successful
return ()
SCOnRetry ->
-- retry reloading the schema cache
work
SCFatalFail ->
-- die if our schema cache query has an error
killThread $ getMainThreadId appState
-- | Repeatedly flush the pool, and check if a connection from the
-- pool allows access to the PostgreSQL database.
--
-- Releasing the pool is key for rapid recovery. Otherwise, the pool
-- 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
-- connection, no pool timeout.
--
-- The connection tries are capped, but if the connection times out no error is
-- thrown, just 'False' is returned.
establishConnection :: AppState -> IO ConnectionStatus
establishConnection appState =
retrying retrySettings shouldRetry $
const $ flushPool appState >> getConnectionStatus
where
retrySettings = capDelay delayMicroseconds $ exponentialBackoff backoffMicroseconds
delayMicroseconds = 32000000 -- 32 seconds
backoffMicroseconds = 1000000 -- 1 second
getConnectionStatus :: IO ConnectionStatus
getConnectionStatus = do
pgVersion <- usePool appState $ queryPgVersion False -- No need to prepare the query here, as the connection might not be established
case pgVersion of
Left e -> do
logPgrstError appState e
case checkIsFatal e of
Just reason ->
return $ FatalConnectionError reason
Nothing ->
return NotConnected
Right version ->
if version < minimumPgVersion then
return . FatalConnectionError $
"Cannot run in this PostgreSQL version, PostgREST needs at least "
<> pgvName minimumPgVersion
else
return . Connected $ version
shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool
shouldRetry rs isConnSucc = do
let
delay = fromMaybe 0 (rsPreviousDelay rs) `div` backoffMicroseconds
itShould = NotConnected == isConnSucc
when itShould . logWithZTime appState $
"Attempting to reconnect to the database in "
<> (show delay::Text)
<> " seconds..."
when itShould $ putRetryNextIn appState delay
return itShould
-- | Re-reads the config plus config options from the db
reReadConfig :: Bool -> AppState -> IO ()
reReadConfig startingUp appState = do
AppConfig{..} <- getConfig appState
dbSettings <-
if configDbConfig then do
qDbSettings <- usePool appState $ queryDbSettings (dumpQi <$> configDbPreConfig) configDbPreparedStatements
case qDbSettings of
Left e -> do
logWithZTime appState
"An error ocurred when trying to query database settings for the config parameters"
case checkIsFatal e of
Just hint -> do
logPgrstError appState e
logWithZTime appState hint
killThread (getMainThreadId appState)
Nothing -> do
logPgrstError appState e
pure mempty
Right x -> pure x
else
pure mempty
(roleSettings, roleIsolationLvl) <-
if configDbConfig then do
rSettings <- usePool appState $ queryRoleSettings configDbPreparedStatements
case rSettings of
Left e -> do
logWithZTime appState "An error ocurred when trying to query the role settings"
logPgrstError appState e
pure (mempty, mempty)
Right x -> pure x
else
pure mempty
readAppConfig dbSettings configFilePath (Just configDbUri) roleSettings roleIsolationLvl >>= \case
Left err ->
if startingUp then
panic err -- die on invalid config if the program is starting up
else
logWithZTime appState $ "Failed reloading config: " <> err
Right newConf -> do
putConfig appState newConf
if startingUp then
pass
else
logWithZTime appState "Config reloaded"
runListener :: AppConfig -> AppState -> IO ()
runListener AppConfig{configDbChannelEnabled} appState =
when configDbChannelEnabled $ listener appState
-- | Starts a dedicated pg connection to LISTEN for notifications. When a
-- NOTIFY <db-channel> - with an empty payload - is done, it refills the schema
-- cache. It uses the connectionWorker in case the LISTEN connection dies.
listener :: AppState -> IO ()
listener appState = do
AppConfig{..} <- getConfig appState
let dbChannel = toS configDbChannel
-- The listener has to wait for a signal from the connectionWorker.
-- This is because when the connection to the db is lost, the listener also
-- tries to recover the connection, but not with the same pace as the connectionWorker.
-- Not waiting makes stderr quickly fill with connection retries messages from the listener.
waitListener appState
-- forkFinally allows to detect if the thread dies
void . flip forkFinally (handleFinally dbChannel) $ do
dbOrError <- acquire $ toUtf8 (addFallbackAppName prettyVersion configDbUri)
case dbOrError of
Right db -> do
logWithZTime appState $ "Listening for notifications on the " <> dbChannel <> " channel"
putIsListenerOn appState True
SQL.listen db $ SQL.toPgIdentifier dbChannel
SQL.waitForNotifications handleNotification db
_ ->
die $ "Could not listen for notifications on the " <> dbChannel <> " channel"
where
handleFinally dbChannel _ = do
-- if the thread dies, we try to recover
logWithZTime appState $ "Retrying listening for notifications on the " <> dbChannel <> " channel.."
putIsListenerOn appState False
-- assume the pool connection was also lost, call the connection worker
connectionWorker appState
-- retry the listener
listener appState
handleNotification _ msg
| BS.null msg = cacheReloader
| msg == "reload schema" = cacheReloader
| msg == "reload config" = reReadConfig False appState
| otherwise = pure () -- Do nothing if anything else than an empty message is sent
cacheReloader =
-- reloads the schema cache + restarts pool connections
-- it's necessary to restart the pg connections because they cache the pg catalog(see #2620)
connectionWorker appState
checkIsFatal :: SQL.UsageError -> Maybe Text
checkIsFatal (SQL.ConnectionUsageError e)
| isAuthFailureMessage = Just $ toS failureMessage
| otherwise = Nothing
where isAuthFailureMessage =
("FATAL: password authentication failed" `isInfixOf` failureMessage) ||
("no password supplied" `isInfixOf` failureMessage)
failureMessage = BS.unpack $ fromMaybe mempty e
checkIsFatal(SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError serverError)))
= 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.
SQL.ServerError "42601" _ _ _ _
-> 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).
-- This would mean that a connection pooler in transaction mode is being used
-- while prepared statements are enabled in the PostgREST configuration,
-- both of which are incompatible with each other.
SQL.ServerError "42P05" _ _ _ _
-> 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).
-- 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" _ _ _
-> Just "Hint: Connection poolers in statement mode are not supported."
_ -> Nothing
checkIsFatal _ = Nothing
+69 -30
View File
@@ -12,43 +12,58 @@ very simple authentication system inside the PostgreSQL database.
-} -}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.Auth module PostgREST.Auth
( containsRole ( AuthResult (..)
, jwtClaims , getResult
, JWTClaims , getRole
, middleware
) where ) where
import qualified Crypto.JWT as JWT import qualified Crypto.JWT as JWT
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Aeson.Types as JSON
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.Vault.Lazy as Vault
import qualified Data.Vector as V import qualified Data.Vector as V
import qualified Network.HTTP.Types.Header as HTTP
import qualified Network.Wai as Wai
import qualified Network.Wai.Middleware.HttpAuth as Wai
import Control.Lens (set) import Control.Lens (set)
import Control.Monad.Except (liftEither) import Control.Monad.Except (liftEither)
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft)
import Data.List (lookup)
import Data.Time.Clock (UTCTime) import Data.Time.Clock (UTCTime)
import System.IO.Unsafe (unsafePerformIO)
import PostgREST.AppState (AppState, getConfig, getTime)
import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..)) import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..))
import PostgREST.Error (Error (..)) import PostgREST.Error (Error (..))
import Protolude import Protolude
type JWTClaims = M.HashMap Text JSON.Value data AuthResult = AuthResult
{ authClaims :: KM.KeyMap JSON.Value
, authRole :: BS.ByteString
}
-- | Receives the JWT secret and audience (from config) and a JWT and returns a -- | Receives the JWT secret and audience (from config) and a JWT and returns a
-- map of JWT claims. -- JSON object of JWT claims.
jwtClaims :: Monad m => parseToken :: Monad m =>
AppConfig -> LByteString -> UTCTime -> ExceptT Error m JWTClaims AppConfig -> LByteString -> UTCTime -> ExceptT Error m JSON.Value
jwtClaims _ "" _ = return M.empty parseToken _ "" _ = return JSON.emptyObject
jwtClaims AppConfig{..} payload time = do parseToken AppConfig{..} token time = do
secret <- liftEither . maybeToRight JwtTokenMissing $ configJWKS secret <- liftEither . maybeToRight JwtTokenMissing $ configJWKS
eitherClaims <- eitherClaims <-
lift . runExceptT $ lift . runExceptT $
JWT.verifyClaimsAt validation secret time =<< JWT.decodeCompact payload JWT.verifyClaimsAt validation secret time =<< JWT.decodeCompact token
liftEither . mapLeft jwtClaimsError $ claimsMap configJwtRoleClaimKey <$> eitherClaims liftEither . mapLeft jwtClaimsError $ JSON.toJSON <$> eitherClaims
where where
validation = validation =
JWT.defaultJWTValidationSettings audienceCheck & set JWT.allowedSkew 1 JWT.defaultJWTValidationSettings audienceCheck & set JWT.allowedSkew 30
audienceCheck :: JWT.StringOrURI -> Bool audienceCheck :: JWT.StringOrURI -> Bool
audienceCheck = maybe (const True) (==) configJwtAudience audienceCheck = maybe (const True) (==) configJwtAudience
@@ -57,26 +72,50 @@ jwtClaims AppConfig{..} payload time = do
jwtClaimsError JWT.JWTExpired = JwtTokenInvalid "JWT expired" jwtClaimsError JWT.JWTExpired = JwtTokenInvalid "JWT expired"
jwtClaimsError e = JwtTokenInvalid $ show e jwtClaimsError e = JwtTokenInvalid $ show e
-- | Turn JWT ClaimSet into something easier to work with. parseClaims :: Monad m =>
-- AppConfig -> JSON.Value -> ExceptT Error m AuthResult
-- Also, here the jspath is applied to put the "role" in the map. parseClaims AppConfig{..} jclaims@(JSON.Object mclaims) = do
claimsMap :: JSPath -> JWT.ClaimsSet -> JWTClaims -- role defaults to anon if not specified in jwt
claimsMap jspath claims = role <- liftEither . maybeToRight JwtTokenRequired $
case JSON.toJSON claims of unquoted <$> walkJSPath (Just jclaims) configJwtRoleClaimKey <|> configDbAnonRole
val@(JSON.Object o) -> return AuthResult
M.delete "role" o `M.union` role val { authClaims = mclaims & KM.insert "role" (JSON.toJSON $ decodeUtf8 role)
_ -> , authRole = role
M.empty }
where where
role value =
maybe M.empty (M.singleton "role") $ walkJSPath (Just value) jspath
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
walkJSPath x [] = x walkJSPath x [] = x
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (M.lookup key o) rest walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (KM.lookup (K.fromText key) o) rest
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
walkJSPath _ _ = Nothing walkJSPath _ _ = Nothing
-- | Whether a response from jwtClaims contains a role claim unquoted :: JSON.Value -> BS.ByteString
containsRole :: JWTClaims -> Bool unquoted (JSON.String t) = encodeUtf8 t
containsRole = M.member "role" unquoted v = LBS.toStrict $ JSON.encode v
-- impossible case - just added to please -Wincomplete-patterns
parseClaims _ _ = return AuthResult { authClaims = KM.empty, authRole = mempty }
-- | Validate authorization header.
-- Parse and store JWT claims for future use in the request.
middleware :: AppState -> Wai.Middleware
middleware appState app req respond = do
conf <- getConfig appState
time <- getTime appState
let token = fromMaybe "" $ Wai.extractBearerAuth =<< lookup HTTP.hAuthorization (Wai.requestHeaders req)
authResult <- runExceptT $
parseToken conf (LBS.fromStrict token) time >>=
parseClaims conf
let req' = req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
app req' respond
authResultKey :: Vault.Key (Either Error AuthResult)
authResultKey = unsafePerformIO Vault.newKey
{-# NOINLINE authResultKey #-}
getResult :: Wai.Request -> Maybe (Either Error AuthResult)
getResult = Vault.lookup authResultKey . Wai.vault
getRole :: Wai.Request -> Maybe BS.ByteString
getRole req = authRole <$> (rightToMaybe =<< getResult req)
+108 -103
View File
@@ -11,7 +11,6 @@ module PostgREST.CLI
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import qualified Hasql.Pool as SQL
import qualified Hasql.Transaction.Sessions as SQL import qualified Hasql.Transaction.Sessions as SQL
import qualified Options.Applicative as O import qualified Options.Applicative as O
@@ -20,9 +19,8 @@ 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 qualified PostgREST.App as App import qualified PostgREST.App as App
import qualified PostgREST.AppState as AppState import qualified PostgREST.AppState as AppState
@@ -34,41 +32,35 @@ import Protolude hiding (hPutStrLn)
main :: App.SignalHandlerInstaller -> Maybe App.SocketRunner -> CLI -> IO () main :: App.SignalHandlerInstaller -> Maybe App.SocketRunner -> CLI -> IO ()
main installSignalHandlers runAppWithSocket CLI{cliCommand, cliPath} = do main installSignalHandlers runAppWithSocket CLI{cliCommand, cliPath} = do
conf@AppConfig{..} <- conf@AppConfig{..} <-
either panic identity <$> Config.readAppConfig mempty cliPath Nothing either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty
appState <- AppState.init conf
-- Override the config with config options from the db -- Per https://github.com/PostgREST/postgrest/issues/268, we want to
-- TODO: the same operation is repeated on connectionWorker, ideally this -- explicitly close the connections to PostgreSQL on shutdown.
-- would be done only once, but dump CmdDumpConfig needs it for tests. -- 'AppState.destroy' takes care of that.
when configDbConfig $ reReadConfig True appState bracket
(AppState.init conf)
AppState.destroy
(\appState -> case cliCommand of
CmdDumpConfig -> do
when configDbConfig $ AppState.reReadConfig True appState
putStr . Config.toText =<< AppState.getConfig appState
CmdDumpSchema -> putStrLn =<< dumpSchema appState
CmdRun -> App.run installSignalHandlers runAppWithSocket appState)
exec cliCommand appState -- | Dump SchemaCache schema to JSON
where
exec :: Command -> AppState -> IO ()
exec CmdDumpConfig appState = putStr . Config.toText =<< AppState.getConfig appState
exec CmdDumpSchema appState = putStrLn =<< dumpSchema appState
exec CmdRun appState = App.run installSignalHandlers runAppWithSocket appState
-- | Dump DbStructure schema to JSON
dumpSchema :: AppState -> IO LBS.ByteString dumpSchema :: AppState -> IO LBS.ByteString
dumpSchema appState = do dumpSchema appState = do
AppConfig{..} <- AppState.getConfig appState conf@AppConfig{..} <- AppState.getConfig appState
actualPgVersion <- AppState.getPgVersion 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
SQL.use (AppState.getPool appState) $ AppState.usePool appState $
transaction SQL.ReadCommitted SQL.Read $ transaction SQL.ReadCommitted SQL.Read $
queryDbStructure querySchemaCache conf
(toList configDbSchemas)
configDbExtraSearchPath
actualPgVersion
configDbPreparedStatements
SQL.release $ AppState.getPool appState
case result of case result of
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
@@ -82,13 +74,13 @@ data Command
| CmdDumpSchema | CmdDumpSchema
-- | Read command line interface options. Also prints help. -- | Read command line interface options. Also prints help.
readCLIShowHelp :: Bool -> IO CLI readCLIShowHelp :: IO CLI
readCLIShowHelp hasEnvironment = readCLIShowHelp =
O.customExecParser prefs opts O.customExecParser prefs opts
where where
prefs = O.prefs $ O.showHelpOnError <> O.showHelpOnEmpty prefs = O.prefs $ O.showHelpOnError <> O.showHelpOnEmpty
opts = O.info parser $ O.fullDesc <> progDesc <> footer opts = O.info parser $ O.fullDesc <> progDesc
parser = O.helper <*> exampleParser <*> cliParser parser = O.helper <*> versionFlag <*> exampleParser <*> cliParser
progDesc = progDesc =
O.progDesc $ O.progDesc $
@@ -96,10 +88,11 @@ readCLIShowHelp hasEnvironment =
<> BS.unpack prettyVersion <> BS.unpack prettyVersion
<> " / create a REST API to an existing Postgres database" <> " / create a REST API to an existing Postgres database"
footer = versionFlag =
O.footer $ O.infoOption ("PostgREST " <> BS.unpack prettyVersion) $
"To run PostgREST, please pass the FILENAME argument" O.long "version"
<> " or set PGRST_ environment variables." <> O.short 'v'
<> O.help "Show the version information"
exampleParser = exampleParser =
O.infoOption exampleConfigFile $ O.infoOption exampleConfigFile $
@@ -111,12 +104,12 @@ readCLIShowHelp hasEnvironment =
cliParser = cliParser =
CLI CLI
<$> (dumpConfigFlag <|> dumpSchemaFlag) <$> (dumpConfigFlag <|> dumpSchemaFlag)
<*> optionalIf hasEnvironment configFileOption <*> O.optional configFileOption
configFileOption = configFileOption =
O.strArgument $ O.strArgument $
O.metavar "FILENAME" O.metavar "FILENAME"
<> O.help "Path to configuration file (optional with PGRST_ environment variables)" <> O.help "Path to configuration file"
dumpConfigFlag = dumpConfigFlag =
O.flag CmdRun CmdDumpConfig $ O.flag CmdRun CmdDumpConfig $
@@ -128,36 +121,13 @@ readCLIShowHelp hasEnvironment =
O.long "dump-schema" O.long "dump-schema"
<> O.help "Dump loaded schema as JSON and exit (for debugging, output structure is unstable)" <> O.help "Dump loaded schema as JSON and exit (for debugging, output structure is unstable)"
optionalIf :: Alternative f => Bool -> f a -> f (Maybe a)
optionalIf True = O.optional
optionalIf False = fmap Just
exampleConfigFile :: [Char] exampleConfigFile :: [Char]
exampleConfigFile = exampleConfigFile =
[str|### REQUIRED: [str|## Admin server used for checks. It's disabled by default unless a port is specified.
|db-uri = "postgres://user:pass@localhost:5432/dbname" |# admin-server-port = 3001
|db-schema = "public"
|db-anon-role = "postgres"
| |
|### OPTIONAL: |## The database role to use when no client authentication is provided
|## number of open connections in the pool |# db-anon-role = "anon"
|db-pool = 10
|
|## Time to live, in seconds, for an idle database pool connection.
|db-pool-timeout = 10
|
|## extra schemas to add to the search_path of every request
|db-extra-search-path = "public"
|
|## limit rows in response
|# db-max-rows = 1000
|
|## stored proc to exec immediately after auth
|# db-pre-request = "stored_proc_name"
|
|## stored proc that overrides the root "/" spec
|## it must be inside the db-schema
|# db-root-spec = "stored_proc_name"
| |
|## Notification channel for reloading the schema cache |## Notification channel for reloading the schema cache
|db-channel = "pgrst" |db-channel = "pgrst"
@@ -168,56 +138,91 @@ exampleConfigFile =
|## Enable in-database configuration |## Enable in-database configuration
|db-config = true |db-config = true
| |
|## Function for in-database configuration
|## db-pre-config = "postgrest.pre_config"
|
|## Extra schemas to add to the search_path of every request
|db-extra-search-path = "public"
|
|## Limit rows in response
|# db-max-rows = 1000
|
|## Allow getting the EXPLAIN plan through the `Accept: application/vnd.pgrst.plan` header
|# db-plan-enabled = false
|
|## Number of open connections in the pool
|db-pool = 10
|
|## Time in seconds to wait to acquire a slot from the connection pool
|# db-pool-acquisition-timeout = 10
|
|## Time in seconds after which to recycle pool connections
|# db-pool-max-lifetime = 1800
|
|## Time in seconds after which to recycle unused pool connections
|# db-pool-max-idletime = 30
|
|## Stored proc to exec immediately after auth
|# db-pre-request = "stored_proc_name"
|
|## Enable or disable prepared statements. disabling is only necessary when behind a connection pooler.
|## When disabled, statements will be parametrized but won't be prepared.
|db-prepared-statements = true
|
|## The name of which database schema to expose to REST clients
|db-schemas = "public"
|
|## How to terminate database transactions
|## Possible values are:
|## commit (default)
|## Transaction is always committed, this can not be overriden
|## commit-allow-override
|## Transaction is committed, but can be overriden with Prefer tx=rollback header
|## rollback
|## Transaction is always rolled back, this can not be overriden
|## rollback-allow-override
|## Transaction is rolled back, but can be overriden with Prefer tx=commit header
|db-tx-end = "commit"
|
|## The standard connection URI format, documented at
|## https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
|db-uri = "postgresql://"
|
|## Determine if GUC request settings for headers, cookies and jwt claims use the legacy names (string with dashes, invalid starting from PostgreSQL v14) with text values instead of the new names (string without dashes, valid on all PostgreSQL versions) with json values. |## Determine if GUC request settings for headers, cookies and jwt claims use the legacy names (string with dashes, invalid starting from PostgreSQL v14) with text values instead of the new names (string without dashes, valid on all PostgreSQL versions) with json values.
|## For PostgreSQL v14 and up, this setting will be ignored. |## For PostgreSQL v14 and up, this setting will be ignored.
|db-use-legacy-gucs = true |db-use-legacy-gucs = true
| |
|## how to terminate database transactions |# jwt-aud = "your_audience_claim"
|## possible values are:
|## commit (default)
|## transaction is always committed, this can not be overriden
|## commit-allow-override
|## transaction is committed, but can be overriden with Prefer tx=rollback header
|## rollback
|## transaction is always rolled back, this can not be overriden
|## rollback-allow-override
|## transaction is rolled back, but can be overriden with Prefer tx=commit header
|db-tx-end = "commit"
| |
|## enable or disable prepared statements. disabling is only necessary when behind a connection pooler. |## Jspath to the role claim key
|## when disabled, statements will be parametrized but won't be prepared. |jwt-role-claim-key = ".role"
|db-prepared-statements = true |
|## Choose a secret, JSON Web Key (or set) to enable JWT auth
|## (use "@filename" to load from separate file)
|# jwt-secret = "secret_with_at_least_32_characters"
|jwt-secret-is-base64 = false
|
|## Logging level, the admitted values are: crit, error, warn and info.
|log-level = "error"
|
|## Determine if the OpenAPI output should follow or ignore role privileges or be disabled entirely.
|## Admitted values: follow-privileges, ignore-privileges, disabled
|openapi-mode = "follow-privileges"
|
|## Base url for the OpenAPI output
|openapi-server-proxy-uri = ""
|
|## Content types to produce raw output
|# raw-media-types="image/png, image/jpg"
| |
|server-host = "!4" |server-host = "!4"
|server-port = 3000 |server-port = 3000
| |
|## unix socket location |## Unix socket location
|## if specified it takes precedence over server-port |## if specified it takes precedence over server-port
|# server-unix-socket = "/tmp/pgrst.sock" |# server-unix-socket = "/tmp/pgrst.sock"
| |
|## unix socket file mode |## Unix socket file mode
|## when none is provided, 660 is applied by default |## When none is provided, 660 is applied by default
|# server-unix-socket-mode = "660" |# server-unix-socket-mode = "660"
|
|## determine if the OpenAPI output should follow or ignore role privileges or be disabled entirely
|## admitted values: follow-privileges, ignore-privileges, disabled
|openapi-mode = "follow-privileges"
|
|## base url for the OpenAPI output
|openapi-server-proxy-uri = ""
|
|## choose a secret, JSON Web Key (or set) to enable JWT auth
|## (use "@filename" to load from separate file)
|# jwt-secret = "secret_with_at_least_32_characters"
|# jwt-aud = "your_audience_claim"
|jwt-secret-is-base64 = false
|
|## jspath to the role claim key
|jwt-role-claim-key = ".role"
|
|## content types to produce raw output
|# raw-media-types="image/png, image/jpg"
|
|## logging level, the admitted values are: crit, error, warn and info.
|log-level = "error"
|] |]
+100 -46
View File
@@ -24,6 +24,7 @@ module PostgREST.Config
, readPGRSTEnvironment , readPGRSTEnvironment
, toURI , toURI
, parseSecret , parseSecret
, addFallbackAppName
) where ) where
import qualified Crypto.JOSE.Types as JOSE import qualified Crypto.JOSE.Types as JOSE
@@ -32,6 +33,7 @@ import qualified Data.Aeson as JSON
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import qualified Data.CaseInsensitive as CI
import qualified Data.Configurator as C import qualified Data.Configurator as C
import qualified Data.Map.Strict as M import qualified Data.Map.Strict as M
import qualified Data.Text as T import qualified Data.Text as T
@@ -46,16 +48,21 @@ 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 Network.URI (escapeURIString,
isUnescapedInURIComponent, parseURI,
uriQuery)
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)
import PostgREST.Config.Database (RoleIsolationLvl,
RoleSettings)
import PostgREST.Config.JSPath (JSPath, JSPathExp (..), 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, import PostgREST.MediaType (MediaType (..), toMime)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi,
toQi) toQi)
import Protolude hiding (Proxy, toList) import Protolude hiding (Proxy, toList)
@@ -63,18 +70,22 @@ import Protolude hiding (Proxy, toList)
data AppConfig = AppConfig data AppConfig = AppConfig
{ configAppSettings :: [(Text, Text)] { configAppSettings :: [(Text, Text)]
, configDbAnonRole :: Text , configDbAnonRole :: Maybe BS.ByteString
, configDbChannel :: Text , configDbChannel :: Text
, configDbChannelEnabled :: Bool , configDbChannelEnabled :: Bool
, configDbExtraSearchPath :: [Text] , configDbExtraSearchPath :: [Text]
, configDbMaxRows :: Maybe Integer , configDbMaxRows :: Maybe Integer
, configDbPlanEnabled :: Bool
, configDbPoolSize :: Int , configDbPoolSize :: Int
, configDbPoolTimeout :: NominalDiffTime , configDbPoolAcquisitionTimeout :: Int
, configDbPoolMaxLifetime :: Int
, configDbPoolMaxIdletime :: 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
, configDbPreConfig :: Maybe QualifiedIdentifier
, configDbTxAllowOverride :: Bool , configDbTxAllowOverride :: Bool
, configDbTxRollbackAll :: Bool , configDbTxRollbackAll :: Bool
, configDbUri :: Text , configDbUri :: Text
@@ -87,12 +98,18 @@ data AppConfig = AppConfig
, configJwtSecretIsBase64 :: Bool , configJwtSecretIsBase64 :: Bool
, configLogLevel :: LogLevel , configLogLevel :: LogLevel
, configOpenApiMode :: OpenAPIMode , configOpenApiMode :: OpenAPIMode
, configOpenApiSecurityActive :: Bool
, configOpenApiServerProxyUri :: Maybe Text , configOpenApiServerProxyUri :: Maybe Text
, configRawMediaTypes :: [BS.ByteString] , configRawMediaTypes :: [MediaType]
, configServerHost :: Text , configServerHost :: Text
, configServerPort :: Int , configServerPort :: Int
, configServerTraceHeader :: Maybe (CI.CI BS.ByteString)
, configServerUnixSocket :: Maybe FilePath , configServerUnixSocket :: Maybe FilePath
, configServerUnixSocketMode :: FileMode , configServerUnixSocketMode :: FileMode
, configAdminServerPort :: Maybe Int
, configRoleSettings :: RoleSettings
, configRoleIsoLvl :: RoleIsolationLvl
, configInternalSCSleep :: Maybe Int32
} }
data LogLevel = LogCrit | LogError | LogWarn | LogInfo data LogLevel = LogCrit | LogError | LogWarn | LogInfo
@@ -120,18 +137,22 @@ toText conf =
where where
-- apply conf to all pgrst settings -- apply conf to all pgrst settings
pgrstSettings = (\(k, v) -> (k, v conf)) <$> pgrstSettings = (\(k, v) -> (k, v conf)) <$>
[("db-anon-role", q . configDbAnonRole) [("db-anon-role", q . T.decodeUtf8 . fromMaybe "" . configDbAnonRole)
,("db-channel", q . configDbChannel) ,("db-channel", q . configDbChannel)
,("db-channel-enabled", T.toLower . show . configDbChannelEnabled) ,("db-channel-enabled", T.toLower . show . configDbChannelEnabled)
,("db-extra-search-path", q . T.intercalate "," . configDbExtraSearchPath) ,("db-extra-search-path", q . T.intercalate "," . configDbExtraSearchPath)
,("db-max-rows", maybe "\"\"" show . configDbMaxRows) ,("db-max-rows", maybe "\"\"" show . configDbMaxRows)
,("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", show . configDbPoolAcquisitionTimeout)
,("db-pool-max-lifetime", show . configDbPoolMaxLifetime)
,("db-pool-max-idletime", show . configDbPoolMaxIdletime)
,("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)
,("db-schemas", q . T.intercalate "," . toList . configDbSchemas) ,("db-schemas", q . T.intercalate "," . toList . configDbSchemas)
,("db-config", q . T.toLower . show . configDbConfig) ,("db-config", T.toLower . show . configDbConfig)
,("db-pre-config", q . maybe mempty dumpQi . configDbPreConfig)
,("db-tx-end", q . showTxEnd) ,("db-tx-end", q . showTxEnd)
,("db-uri", q . configDbUri) ,("db-uri", q . configDbUri)
,("db-use-legacy-gucs", T.toLower . show . configDbUseLegacyGucs) ,("db-use-legacy-gucs", T.toLower . show . configDbUseLegacyGucs)
@@ -141,12 +162,15 @@ toText conf =
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64) ,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
,("log-level", q . dumpLogLevel . configLogLevel) ,("log-level", q . dumpLogLevel . configLogLevel)
,("openapi-mode", q . dumpOpenApiMode . configOpenApiMode) ,("openapi-mode", q . dumpOpenApiMode . configOpenApiMode)
,("openapi-security-active", T.toLower . show . configOpenApiSecurityActive)
,("openapi-server-proxy-uri", q . fromMaybe mempty . configOpenApiServerProxyUri) ,("openapi-server-proxy-uri", q . fromMaybe mempty . configOpenApiServerProxyUri)
,("raw-media-types", q . T.decodeUtf8 . BS.intercalate "," . configRawMediaTypes) ,("raw-media-types", q . T.decodeUtf8 . BS.intercalate "," . fmap toMime . configRawMediaTypes)
,("server-host", q . configServerHost) ,("server-host", q . configServerHost)
,("server-port", show . configServerPort) ,("server-port", show . configServerPort)
,("server-trace-header", q . T.decodeUtf8 . maybe mempty CI.original . configServerTraceHeader)
,("server-unix-socket", q . maybe mempty T.pack . configServerUnixSocket) ,("server-unix-socket", q . maybe mempty T.pack . configServerUnixSocket)
,("server-unix-socket-mode", q . T.pack . showSocketMode) ,("server-unix-socket-mode", q . T.pack . showSocketMode)
,("admin-server-port", maybe "\"\"" show . configAdminServerPort)
] ]
-- quote all app.settings -- quote all app.settings
@@ -173,20 +197,20 @@ class JustIfMaybe a b where
justIfMaybe :: a -> b justIfMaybe :: a -> b
instance JustIfMaybe a a where instance JustIfMaybe a a where
justIfMaybe a = a justIfMaybe = identity
instance JustIfMaybe a (Maybe a) where instance JustIfMaybe a (Maybe a) where
justIfMaybe a = Just a justIfMaybe = Just
-- | Reads and parses the config and overrides its parameters from env vars, -- | Reads and parses the config and overrides its parameters from env vars,
-- files or db settings. -- files or db settings.
readAppConfig :: [(Text, Text)] -> Maybe FilePath -> Maybe Text -> IO (Either Text AppConfig) readAppConfig :: [(Text, Text)] -> Maybe FilePath -> Maybe Text -> RoleSettings -> RoleIsolationLvl -> IO (Either Text AppConfig)
readAppConfig dbSettings optPath prevDbUri = do readAppConfig dbSettings optPath prevDbUri roleSettings roleIsolationLvl = do
env <- readPGRSTEnvironment env <- readPGRSTEnvironment
-- if no filename provided, start with an empty map to read config from environment -- if no filename provided, start with an empty map to read config from environment
conf <- maybe (return $ Right M.empty) loadConfig optPath conf <- maybe (return $ Right M.empty) loadConfig optPath
case C.runParser (parser optPath env dbSettings) =<< mapLeft show conf of case C.runParser (parser optPath env dbSettings roleSettings roleIsolationLvl) =<< mapLeft show conf of
Left err -> Left err ->
return . Left $ "Error in config " <> err return . Left $ "Error in config " <> err
Right parsedConfig -> Right parsedConfig ->
@@ -201,30 +225,34 @@ readAppConfig dbSettings optPath prevDbUri = do
decodeJWKS <$> decodeJWKS <$>
(decodeSecret =<< readSecretFile =<< readDbUriFile prevDbUri parsedConfig) (decodeSecret =<< readSecretFile =<< readDbUriFile prevDbUri parsedConfig)
parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> C.Parser C.Config AppConfig parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> RoleIsolationLvl -> C.Parser C.Config AppConfig
parser optPath env dbSettings = parser optPath env dbSettings roleSettings roleIsolationLvl =
AppConfig AppConfig
<$> parseAppSettings "app.settings" <$> parseAppSettings "app.settings"
<*> reqString "db-anon-role" <*> (fmap encodeUtf8 <$> optString "db-anon-role")
<*> (fromMaybe "pgrst" <$> optString "db-channel") <*> (fromMaybe "pgrst" <$> optString "db-channel")
<*> (fromMaybe True <$> optBool "db-channel-enabled") <*> (fromMaybe True <$> optBool "db-channel-enabled")
<*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path") <*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path")
<*> optWithAlias (optInt "db-max-rows") <*> optWithAlias (optInt "db-max-rows")
(optInt "max-rows") (optInt "max-rows")
<*> (fromMaybe False <$> optBool "db-plan-enabled")
<*> (fromMaybe 10 <$> optInt "db-pool") <*> (fromMaybe 10 <$> optInt "db-pool")
<*> (fromIntegral . fromMaybe 10 <$> optInt "db-pool-timeout") <*> (fromMaybe 10 <$> optInt "db-pool-acquisition-timeout")
<*> (fromMaybe 1800 <$> optInt "db-pool-max-lifetime")
<*> (fromMaybe 30 <$> optWithAlias (optInt "db-pool-timeout")
(optInt "db-pool-max-idletime"))
<*> (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")
<*> (fmap toQi <$> optWithAlias (optString "db-root-spec") <*> (fmap toQi <$> optWithAlias (optString "db-root-spec")
(optString "root-spec")) (optString "root-spec"))
<*> (fromList . splitOnCommas <$> reqWithAlias (optValue "db-schemas") <*> (fromList . maybe ["public"] splitOnCommas <$> optWithAlias (optValue "db-schemas")
(optValue "db-schema") (optValue "db-schema"))
"missing key: either db-schemas or db-schema must be set")
<*> (fromMaybe True <$> optBool "db-config") <*> (fromMaybe True <$> optBool "db-config")
<*> (fmap toQi <$> optString "db-pre-config")
<*> parseTxEnd "db-tx-end" snd <*> parseTxEnd "db-tx-end" snd
<*> parseTxEnd "db-tx-end" fst <*> parseTxEnd "db-tx-end" fst
<*> reqString "db-uri" <*> (fromMaybe "postgresql://" <$> optString "db-uri")
<*> (fromMaybe True <$> optBool "db-use-legacy-gucs") <*> (fromMaybe True <$> optBool "db-use-legacy-gucs")
<*> pure optPath <*> pure optPath
<*> pure Nothing <*> pure Nothing
@@ -236,12 +264,18 @@ parser optPath env dbSettings =
(optBool "secret-is-base64")) (optBool "secret-is-base64"))
<*> parseLogLevel "log-level" <*> parseLogLevel "log-level"
<*> parseOpenAPIMode "openapi-mode" <*> parseOpenAPIMode "openapi-mode"
<*> (fromMaybe False <$> optBool "openapi-security-active")
<*> parseOpenAPIServerProxyURI "openapi-server-proxy-uri" <*> parseOpenAPIServerProxyURI "openapi-server-proxy-uri"
<*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types") <*> (maybe [] (fmap (MTOther . encodeUtf8) . splitOnCommas) <$> optValue "raw-media-types")
<*> (fromMaybe "!4" <$> optString "server-host") <*> (fromMaybe "!4" <$> optString "server-host")
<*> (fromMaybe 3000 <$> optInt "server-port") <*> (fromMaybe 3000 <$> optInt "server-port")
<*> (fmap (CI.mk . encodeUtf8) <$> optString "server-trace-header")
<*> (fmap T.unpack <$> optString "server-unix-socket") <*> (fmap T.unpack <$> optString "server-unix-socket")
<*> parseSocketFileMode "server-unix-socket-mode" <*> parseSocketFileMode "server-unix-socket-mode"
<*> optInt "admin-server-port"
<*> pure roleSettings
<*> pure roleIsolationLvl
<*> optInt "internal-schema-cache-sleep"
where where
parseAppSettings :: C.Key -> C.Parser C.Config [(Text, Text)] parseAppSettings :: C.Key -> C.Parser C.Config [(Text, Text)]
parseAppSettings key = addFromEnv . fmap (fmap coerceText) <$> C.subassocs key C.value parseAppSettings key = addFromEnv . fmap (fmap coerceText) <$> C.subassocs key C.value
@@ -314,24 +348,12 @@ parser optPath env dbSettings =
Nothing -> pure [JSPKey "role"] Nothing -> pure [JSPKey "role"]
Just rck -> either (fail . show) pure $ pRoleClaimKey rck Just rck -> either (fail . show) pure $ pRoleClaimKey rck
reqWithAlias :: C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) -> [Char] -> C.Parser C.Config a
reqWithAlias orig alias err =
orig >>= \case
Just v -> pure v
Nothing ->
alias >>= \case
Just v -> pure v
Nothing -> fail err
optWithAlias :: C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) optWithAlias :: C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a)
optWithAlias orig alias = optWithAlias orig alias =
orig >>= \case orig >>= \case
Just v -> pure $ Just v Just v -> pure $ Just v
Nothing -> alias Nothing -> alias
reqString :: C.Key -> C.Parser C.Config Text
reqString k = overrideFromDbOrEnvironment C.required k coerceText
optString :: C.Key -> C.Parser C.Config (Maybe Text) optString :: C.Key -> C.Parser C.Config (Maybe Text)
optString k = mfilter (/= "") <$> overrideFromDbOrEnvironment C.optional k coerceText optString k = mfilter (/= "") <$> overrideFromDbOrEnvironment C.optional k coerceText
@@ -348,20 +370,14 @@ parser optPath env dbSettings =
(C.Key -> C.Parser C.Value a -> C.Parser C.Config b) -> (C.Key -> C.Parser C.Value a -> C.Parser C.Config b) ->
C.Key -> (C.Value -> a) -> C.Parser C.Config b C.Key -> (C.Value -> a) -> C.Parser C.Config b
overrideFromDbOrEnvironment necessity key coercion = overrideFromDbOrEnvironment necessity key coercion =
case reloadableDbSetting <|> M.lookup envVarName env of case dbConf <|> M.lookup envVarName env of
Just dbOrEnvVal -> pure $ justIfMaybe $ coercion $ C.String dbOrEnvVal Just dbOrEnvVal -> pure $ justIfMaybe $ coercion $ C.String dbOrEnvVal
Nothing -> necessity key (coercion <$> C.value) Nothing -> necessity key (coercion <$> C.value)
where where
dashToUnderscore '-' = '_' dashToUnderscore '-' = '_'
dashToUnderscore c = c dashToUnderscore c = c
envVarName = "PGRST_" <> (toUpper . dashToUnderscore <$> toS key) envVarName = "PGRST_" <> (toUpper . dashToUnderscore <$> toS key)
reloadableDbSetting = dbConf = lookup (T.pack $ dashToUnderscore <$> toS key) dbSettings
let dbSettingName = T.pack $ dashToUnderscore <$> toS key in
if dbSettingName `notElem` [
"server_host", "server_port", "server_unix_socket", "server_unix_socket_mode", "log_level",
"db_anon_role", "db_uri", "db_channel_enabled", "db_channel", "db_pool", "db_pool_timeout", "db_config"]
then lookup dbSettingName dbSettings
else Nothing
coerceText :: C.Value -> Text coerceText :: C.Value -> Text
coerceText (C.String s) = s coerceText (C.String s) = s
@@ -369,17 +385,17 @@ parser optPath env dbSettings =
coerceInt :: (Read i, Integral i) => C.Value -> Maybe i coerceInt :: (Read i, Integral i) => C.Value -> Maybe i
coerceInt (C.Number x) = rightToMaybe $ floatingOrInteger x coerceInt (C.Number x) = rightToMaybe $ floatingOrInteger x
coerceInt (C.String x) = readMaybe $ toS x coerceInt (C.String x) = readMaybe x
coerceInt _ = Nothing coerceInt _ = Nothing
coerceBool :: C.Value -> Maybe Bool coerceBool :: C.Value -> Maybe Bool
coerceBool (C.Bool b) = Just b coerceBool (C.Bool b) = Just b
coerceBool (C.String s) = coerceBool (C.String s) =
-- parse all kinds of text: True, true, TRUE, "true", ... -- parse all kinds of text: True, true, TRUE, "true", ...
case readMaybe . toS $ T.toTitle $ T.filter isAlpha $ toS s of case readMaybe $ T.toTitle $ T.filter isAlpha $ toS s of
Just b -> Just b Just b -> Just b
-- numeric instead? -- numeric instead?
Nothing -> (> 0) <$> (readMaybe $ toS s :: Maybe Integer) Nothing -> (> 0) <$> (readMaybe s :: Maybe Integer)
coerceBool _ = Nothing coerceBool _ = Nothing
splitOnCommas :: C.Value -> [Text] splitOnCommas :: C.Value -> [Text]
@@ -448,3 +464,41 @@ type Environment = M.Map [Char] Text
readPGRSTEnvironment :: IO Environment readPGRSTEnvironment :: IO Environment
readPGRSTEnvironment = readPGRSTEnvironment =
M.map T.pack . M.fromList . filter (isPrefixOf "PGRST_" . fst) <$> getEnvironment M.map T.pack . M.fromList . filter (isPrefixOf "PGRST_" . fst) <$> getEnvironment
-- | Adds a `fallback_application_name` value to the connection string. This allows querying the PostgREST version on pg_stat_activity.
--
-- >>> let ver = "11.1.0 (5a04ec7)"::ByteString
-- >>> let strangeVer = "11'1&0@#$%,.:\"[]{}?+^()=asdfqwer"::ByteString
--
-- >>> addFallbackAppName ver "postgres://user:pass@host:5432/postgres"
-- "postgres://user:pass@host:5432/postgres?fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29"
--
-- >>> addFallbackAppName ver "postgres://user:pass@host:5432/postgres?"
-- "postgres://user:pass@host:5432/postgres?fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29"
--
-- >>> addFallbackAppName ver "postgres:///postgres?host=server&port=5432"
-- "postgres:///postgres?host=server&port=5432&fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29"
--
-- >>> addFallbackAppName ver "host=localhost port=5432 dbname=postgres"
-- "host=localhost port=5432 dbname=postgres fallback_application_name='PostgREST 11.1.0 (5a04ec7)'"
--
-- >>> addFallbackAppName ver "postgresql://"
-- "postgresql://?fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29"
--
-- >>> addFallbackAppName strangeVer "host=localhost port=5432 dbname=postgres"
-- "host=localhost port=5432 dbname=postgres fallback_application_name='PostgREST 11\\'1&0@#$%,.:\"[]{}?+^()=asdfqwer'"
--
-- >>> addFallbackAppName strangeVer "postgres:///postgres?host=server&port=5432"
-- "postgres:///postgres?host=server&port=5432&fallback_application_name=PostgREST%2011%271%260%40%23%24%25%2C.%3A%22%5B%5D%7B%7D%3F%2B%5E%28%29%3Dasdfqwer"
addFallbackAppName :: ByteString -> Text -> Text
addFallbackAppName version dbUri = dbUri <>
case uriQuery <$> parseURI (toS dbUri) of
Nothing -> " " <> keyValFmt -- Assume key/value connection string if the uri is not valid
Just "" -> "?" <> uriFmt
Just "?" -> uriFmt
_ -> "&" <> uriFmt
where
uriFmt = pKeyWord <> toS (escapeURIString isUnescapedInURIComponent $ toS pgrstVer)
keyValFmt = pKeyWord <> "'" <> T.replace "'" "\\'" pgrstVer <> "'"
pKeyWord = "fallback_application_name="
pgrstVer = "PostgREST " <> T.decodeUtf8 version
+162 -22
View File
@@ -1,56 +1,196 @@
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
module PostgREST.Config.Database module PostgREST.Config.Database
( queryDbSettings ( pgVersionStatement
, queryDbSettings
, queryRoleSettings
, queryPgVersion , queryPgVersion
, RoleSettings
, RoleIsolationLvl
, toIsolationLevel
) where ) where
import Control.Arrow ((***))
import PostgREST.Config.PgVersion (PgVersion (..)) import PostgREST.Config.PgVersion (PgVersion (..))
import qualified Data.HashMap.Strict as HM
import qualified Hasql.Decoders as HD import qualified Hasql.Decoders as HD
import qualified Hasql.Encoders as HE import qualified Hasql.Encoders as HE
import qualified Hasql.Pool as SQL
import Hasql.Session (Session, statement) import Hasql.Session (Session, statement)
import qualified Hasql.Statement as SQL import qualified Hasql.Statement as SQL
import qualified Hasql.Transaction as SQL import qualified Hasql.Transaction as SQL
import qualified Hasql.Transaction.Sessions as SQL import qualified Hasql.Transaction.Sessions as SQL
import Text.InterpolatedString.Perl6 (q) import Text.InterpolatedString.Perl6 (q, qc)
import Protolude import Protolude
queryPgVersion :: Session PgVersion type RoleSettings = (HM.HashMap ByteString (HM.HashMap ByteString ByteString))
queryPgVersion = statement mempty $ SQL.Statement sql HE.noParams versionRow False type RoleIsolationLvl = HM.HashMap ByteString SQL.IsolationLevel
toIsolationLevel :: (Eq a, IsString a) => a -> SQL.IsolationLevel
toIsolationLevel a = case a of
"repeatable read" -> SQL.RepeatableRead
"serializable" -> SQL.Serializable
_ -> SQL.ReadCommitted
prefix :: Text
prefix = "pgrst."
-- | In-db settings names
dbSettingsNames :: [Text]
dbSettingsNames =
(prefix <>) <$>
["db_anon_role"
,"db_pre_config"
,"db_extra_search_path"
,"db_max_rows"
,"db_plan_enabled"
,"db_pre_request"
,"db_prepared_statements"
,"db_root_spec"
,"db_schemas"
,"db_tx_end"
,"db_use_legacy_gucs"
,"jwt_aud"
,"jwt_role_claim_key"
,"jwt_secret"
,"jwt_secret_is_base64"
,"openapi_mode"
,"openapi_security_active"
,"openapi_server_proxy_uri"
,"raw_media_types"
,"server_trace_header"
]
queryPgVersion :: Bool -> Session PgVersion
queryPgVersion prepared = statement mempty $ pgVersionStatement prepared
pgVersionStatement :: Bool -> SQL.Statement () PgVersion
pgVersionStatement = SQL.Statement sql HE.noParams versionRow
where where
sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')" sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')"
versionRow = HD.singleRow $ PgVersion <$> column HD.int4 <*> column HD.text versionRow = HD.singleRow $ PgVersion <$> column HD.int4 <*> column HD.text
queryDbSettings :: SQL.Pool -> Bool -> IO (Either SQL.UsageError [(Text, Text)]) -- | Query the in-database configuration. The settings have the following priorities:
queryDbSettings pool prepared = --
-- 1. Role + with database-specific settings:
-- ALTER ROLE authenticator IN DATABASE postgres SET <prefix>jwt_aud = 'val';
-- 2. Role + with settings:
-- ALTER ROLE authenticator SET <prefix>jwt_aud = 'overridden';
-- 3. pre-config function:
-- CREATE FUNCTION pre_config() .. PERFORM set_config(<prefix>jwt_aud, 'pre_config_aud'..)
--
-- The example above will result in <prefix>jwt_aud = 'val'
-- A setting on the database only will have no effect: ALTER DATABASE postgres SET <prefix>jwt_aud = 'xx'
queryDbSettings :: Maybe Text -> Bool -> Session [(Text, Text)]
queryDbSettings preConfFunc prepared =
let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in
SQL.use pool . transaction SQL.ReadCommitted SQL.Read $ transaction SQL.ReadCommitted SQL.Read $ SQL.statement dbSettingsNames $ SQL.Statement sql (arrayParam HE.text) decodeSettings prepared
SQL.statement mempty dbSettingsStatement where
sql = [qc|
WITH
role_setting AS (
SELECT setdatabase as database,
unnest(setconfig) as setting
FROM pg_catalog.pg_db_role_setting
WHERE setrole = CURRENT_USER::regrole::oid
AND setdatabase IN (0, (SELECT oid FROM pg_catalog.pg_database WHERE datname = CURRENT_CATALOG))
),
kv_settings AS (
SELECT database,
substr(setting, 1, strpos(setting, '=') - 1) as k,
substr(setting, strpos(setting, '=') + 1) as v
FROM role_setting
{preConfigF}
)
SELECT DISTINCT ON (key)
replace(k, '{prefix}', '') AS key,
v AS value
FROM kv_settings
WHERE k = ANY($1) AND v IS NOT NULL
ORDER BY key, database DESC NULLS LAST;
|]
preConfigF = case preConfFunc of
Nothing -> mempty
Just func -> [qc|
UNION
SELECT
null as database,
x as k,
current_setting(x, true) as v
FROM unnest($1) x
JOIN {func}() _ ON TRUE
|]::Text
decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text
-- | Get db settings from the connection role. Global settings will be overridden by database specific settings. queryRoleSettings :: Bool -> Session (RoleSettings, RoleIsolationLvl)
dbSettingsStatement :: SQL.Statement () [(Text, Text)] queryRoleSettings prepared =
dbSettingsStatement = SQL.Statement sql HE.noParams decodeSettings False let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in
transaction SQL.ReadCommitted SQL.Read $ SQL.statement mempty $ SQL.Statement sql HE.noParams (processRows <$> rows) prepared
where where
sql = [q| sql = [q|
with with
role_setting as ( role_setting as (
select setdatabase, unnest(setconfig) as setting from pg_catalog.pg_db_role_setting select r.rolname, unnest(r.rolconfig) as setting
where setrole = current_user::regrole::oid from pg_auth_members m
and setdatabase in (0, (select oid from pg_catalog.pg_database where datname = current_catalog)) join pg_roles r on r.oid = m.roleid
where member = current_user::regrole::oid
), ),
kv_settings as ( kv_settings AS (
select setdatabase, split_part(setting, '=', 1) as k, split_part(setting, '=', 2) as value from role_setting SELECT
where setting like 'pgrst.%' rolname,
substr(setting, 1, strpos(setting, '=') - 1) as key,
lower(substr(setting, strpos(setting, '=') + 1)) as value
FROM role_setting
),
iso_setting AS (
SELECT rolname, value
FROM kv_settings
WHERE key = 'default_transaction_isolation'
) )
select distinct on (key) replace(k, 'pgrst.', '') as key, value select
from kv_settings kv.rolname,
order by key, setdatabase desc; i.value as iso_lvl,
array_agg(row(kv.key, kv.value)) filter (where key <> 'default_transation_isolation') as role_settings
from kv_settings kv
join pg_settings ps on ps.name = kv.key and ps.context = 'user'
left join iso_setting i on i.rolname = kv.rolname
group by kv.rolname, i.value;
|] |]
decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text
processRows :: [(Text, Maybe Text, [(Text, Text)])] -> (RoleSettings, RoleIsolationLvl)
processRows rs =
let
rowsWRoleSettings = [ (x, z) | (x, _, z) <- rs ]
rowsWIsolation = [ (x, y) | (x, Just y, _) <- rs ]
in
( HM.fromList $ bimap encodeUtf8 (HM.fromList . ((encodeUtf8 *** encodeUtf8) <$>)) <$> rowsWRoleSettings
, HM.fromList $ (encodeUtf8 *** toIsolationLevel) <$> rowsWIsolation
)
rows :: HD.Result [(Text, Maybe Text, [(Text, Text)])]
rows = HD.rowList $ (,,) <$> column HD.text <*> nullableColumn HD.text <*> compositeArrayColumn ((,) <$> compositeField HD.text <*> compositeField 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
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
nullableColumn = HD.column . HD.nullable
compositeField :: HD.Value a -> HD.Composite a
compositeField = HD.field . HD.nonNullable
compositeArrayColumn :: HD.Composite a -> HD.Row [a]
compositeArrayColumn = arrayColumn . HD.composite
arrayColumn :: HD.Value a -> HD.Row [a]
arrayColumn = column . HD.listArray . HD.nonNullable
param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable
arrayParam :: HE.Value a -> HE.Params [a]
arrayParam = param . HE.foldableArray . HE.nonNullable
+4
View File
@@ -9,6 +9,7 @@ module PostgREST.Config.PgVersion
, pgVersion110 , pgVersion110
, pgVersion112 , pgVersion112
, pgVersion114 , pgVersion114
, pgVersion120
, pgVersion121 , pgVersion121
, pgVersion130 , pgVersion130
, pgVersion140 , pgVersion140
@@ -50,6 +51,9 @@ pgVersion112 = PgVersion 110002 "11.2"
pgVersion114 :: PgVersion pgVersion114 :: PgVersion
pgVersion114 = PgVersion 110004 "11.4" pgVersion114 = PgVersion 110004 "11.4"
pgVersion120 :: PgVersion
pgVersion120 = PgVersion 120000 "12.0"
pgVersion121 :: PgVersion pgVersion121 :: PgVersion
pgVersion121 = PgVersion 120001 "12.1" pgVersion121 = PgVersion 120001 "12.1"
-64
View File
@@ -1,64 +0,0 @@
{-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.ContentType
( ContentType(..)
, toHeader
, toMime
, decodeContentType
) where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BS (c2w)
import Network.HTTP.Types.Header (Header, hContentType)
import Protolude
-- | Enumeration of currently supported response content types
data ContentType
= CTApplicationJSON
| CTSingularJSON
| CTTextCSV
| CTTextPlain
| CTOpenAPI
| CTUrlEncoded
| CTOctetStream
| CTAny
| CTOther ByteString
deriving (Eq)
-- | Convert from ContentType to a full HTTP Header
toHeader :: ContentType -> Header
toHeader ct = (hContentType, toMime ct <> charset)
where
charset = case ct of
CTOctetStream -> mempty
CTOther _ -> mempty
_ -> "; charset=utf-8"
-- | Convert from ContentType to a ByteString representing the mime type
toMime :: ContentType -> ByteString
toMime CTApplicationJSON = "application/json"
toMime CTTextCSV = "text/csv"
toMime CTTextPlain = "text/plain"
toMime CTOpenAPI = "application/openapi+json"
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
toMime CTUrlEncoded = "application/x-www-form-urlencoded"
toMime CTOctetStream = "application/octet-stream"
toMime CTAny = "*/*"
toMime (CTOther ct) = ct
-- | Convert from ByteString to ContentType. Warning: discards MIME parameters
decodeContentType :: BS.ByteString -> ContentType
decodeContentType ct =
case BS.takeWhile (/= BS.c2w ';') ct of
"application/json" -> CTApplicationJSON
"text/csv" -> CTTextCSV
"text/plain" -> CTTextPlain
"application/openapi+json" -> CTOpenAPI
"application/vnd.pgrst.object+json" -> CTSingularJSON
"application/vnd.pgrst.object" -> CTSingularJSON
"application/x-www-form-urlencoded" -> CTUrlEncoded
"application/octet-stream" -> CTOctetStream
"*/*" -> CTAny
ct' -> CTOther ct'
+42
View File
@@ -0,0 +1,42 @@
{-|
Module : PostgREST.Cors
Description : Wai Middleware to set cors policy.
-}
module PostgREST.Cors (middleware) where
import qualified Data.ByteString.Char8 as BS
import qualified Data.CaseInsensitive as CI
import qualified Network.Wai as Wai
import qualified Network.Wai.Middleware.Cors as Wai
import Data.List (lookup)
import Protolude
middleware :: Wai.Middleware
middleware = Wai.cors corsPolicy
-- | CORS policy to be used in by Wai Cors middleware
corsPolicy :: Wai.Request -> Maybe Wai.CorsResourcePolicy
corsPolicy req = case lookup "origin" headers of
Just origin ->
Just Wai.CorsResourcePolicy
{ Wai.corsOrigins = Just ([origin], True)
, Wai.corsMethods = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
, Wai.corsRequestHeaders = "Authorization" : accHeaders
, Wai.corsExposedHeaders = Just
[ "Content-Encoding", "Content-Location", "Content-Range", "Content-Type"
, "Date", "Location", "Server", "Transfer-Encoding", "Range-Unit"]
, Wai.corsMaxAge = Just $ 60*60*24
, Wai.corsVaryOrigin = False
, Wai.corsRequireOrigin = False
, Wai.corsIgnoreFailures = True
}
Nothing -> Nothing
where
headers = Wai.requestHeaders req
accHeaders = case lookup "access-control-request-headers" headers of
Just hdrs -> map (CI.mk . BS.strip) $ BS.split ',' hdrs
-- Impossible case, Middleware.Cors will not evaluate this when
-- the Access-Control-Request-Headers header is not set.
Nothing -> []
-924
View File
@@ -1,924 +0,0 @@
{-|
Module : PostgREST.DbStructure
Description : PostgREST schema cache
This module contains queries that target PostgreSQL system catalogs, these are used to build the schema cache(DbStructure).
The schema cache is necessary for resource embedding, foreign keys are used for inferring the relationships between tables.
These queries are executed once at startup or when PostgREST is reloaded.
-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeSynonymInstances #-}
module PostgREST.DbStructure
( DbStructure(..)
, queryDbStructure
, accessibleTables
, accessibleProcs
, schemaDescription
, tableCols
, tablePKCols
) where
import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M
import qualified Data.List as L
import qualified Hasql.Decoders as HD
import qualified Hasql.Encoders as HE
import qualified Hasql.Statement as SQL
import qualified Hasql.Transaction as SQL
import Contravariant.Extras (contrazip2)
import Data.Set as S (fromList)
import Data.Text (split)
import Text.InterpolatedString.Perl6 (q)
import PostgREST.Config.PgVersion (PgVersion, pgVersion100)
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..),
Schema, TableName)
import PostgREST.DbStructure.Proc (PgType (..),
ProcDescription (..),
ProcParam (..),
ProcVolatility (..),
ProcsMap, RetType (..))
import PostgREST.DbStructure.Relationship (Cardinality (..),
Junction (..),
PrimaryKey (..),
Relationship (..))
import PostgREST.DbStructure.Table (Column (..), Table (..))
import Protolude
import Protolude.Unsafe (unsafeHead)
data DbStructure = DbStructure
{ dbTables :: [Table]
, dbColumns :: [Column]
, dbRelationships :: [Relationship]
, dbPrimaryKeys :: [PrimaryKey]
, dbProcs :: ProcsMap
}
deriving (Generic, JSON.ToJSON)
-- TODO Table could hold references to all its Columns
tableCols :: DbStructure -> Schema -> TableName -> [Column]
tableCols dbs tSchema tName = filter (\Column{colTable=Table{tableSchema=s, tableName=t}} -> s==tSchema && t==tName) $ dbColumns dbs
-- TODO Table could hold references to all its PrimaryKeys
tablePKCols :: DbStructure -> Schema -> TableName -> [Text]
tablePKCols dbs tSchema tName = pkName <$> filter (\pk -> tSchema == (tableSchema . pkTable) pk && tName == (tableName . pkTable) pk) (dbPrimaryKeys dbs)
-- | The source table column a view column refers to
type SourceColumn = (Column, ViewColumn)
type ViewColumn = Column
-- | A SQL query that can be executed independently
type SqlQuery = ByteString
queryDbStructure :: [Schema] -> [Schema] -> PgVersion -> Bool -> SQL.Transaction DbStructure
queryDbStructure schemas extraSearchPath pgVer 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
tabs <- SQL.statement mempty $ allTables pgVer prepared
cols <- SQL.statement schemas $ allColumns tabs prepared
srcCols <- SQL.statement (schemas, extraSearchPath) $ pfkSourceColumns cols prepared
m2oRels <- SQL.statement mempty $ allM2ORels tabs cols prepared
keys <- SQL.statement mempty $ allPrimaryKeys tabs prepared
procs <- SQL.statement schemas $ allProcs prepared
let rels = addO2MRels . addM2MRels $ addViewM2ORels srcCols m2oRels
keys' = addViewPrimaryKeys srcCols keys
return $ removeInternal schemas $ DbStructure {
dbTables = tabs
, dbColumns = cols
, dbRelationships = rels
, dbPrimaryKeys = keys'
, dbProcs = procs
}
-- | Remove db objects that belong to an internal schema(not exposed through the API) from the DbStructure.
removeInternal :: [Schema] -> DbStructure -> DbStructure
removeInternal schemas dbStruct =
DbStructure {
dbTables = filter (\x -> tableSchema x `elem` schemas) $ dbTables dbStruct
, dbColumns = filter (\x -> tableSchema (colTable x) `elem` schemas) (dbColumns dbStruct)
, dbRelationships = filter (\x -> tableSchema (relTable x) `elem` schemas &&
tableSchema (relForeignTable x) `elem` schemas &&
not (hasInternalJunction x)) $ dbRelationships dbStruct
, dbPrimaryKeys = filter (\x -> tableSchema (pkTable x) `elem` schemas) $ dbPrimaryKeys dbStruct
, dbProcs = dbProcs dbStruct -- procs are only obtained from the exposed schemas, no need to filter them.
}
where
hasInternalJunction rel = case relCardinality rel of
M2M Junction{junTable} -> tableSchema junTable `notElem` schemas
_ -> False
decodeTables :: HD.Result [Table]
decodeTables =
HD.rowList tblRow
where
tblRow = Table <$> column HD.text
<*> column HD.text
<*> nullableColumn HD.text
<*> column HD.bool
<*> column HD.bool
<*> column HD.bool
decodeColumns :: [Table] -> HD.Result [Column]
decodeColumns tables =
mapMaybe (columnFromRow tables) <$> HD.rowList colRow
where
colRow =
(,,,,,,,,)
<$> column HD.text
<*> column HD.text
<*> column HD.text
<*> nullableColumn HD.text
<*> column HD.bool
<*> column HD.text
<*> nullableColumn HD.int4
<*> nullableColumn HD.text
<*> nullableColumn HD.text
decodeRels :: [Table] -> [Column] -> HD.Result [Relationship]
decodeRels tables cols =
mapMaybe (relFromRow tables cols) <$> HD.rowList relRow
where
relRow = (,,,,,,)
<$> column HD.text
<*> column HD.text
<*> column HD.text
<*> arrayColumn HD.text
<*> column HD.text
<*> column HD.text
<*> arrayColumn HD.text
decodePks :: [Table] -> HD.Result [PrimaryKey]
decodePks tables =
mapMaybe (pkFromRow tables) <$> HD.rowList pkRow
where
pkRow = (,,) <$> column HD.text <*> column HD.text <*> column HD.text
decodeSourceColumns :: [Column] -> HD.Result [SourceColumn]
decodeSourceColumns cols =
mapMaybe (sourceColumnFromRow cols) <$> HD.rowList srcColRow
where
srcColRow = (,,,,,)
<$> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text
sourceColumnFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe SourceColumn
sourceColumnFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
where
col1 = findCol s1 t1 c1
col2 = findCol s2 t2 c2
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
decodeProcs :: HD.Result ProcsMap
decodeProcs =
-- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addKey) <$> HD.rowList procRow
where
procRow = ProcDescription
<$> column HD.text
<*> column HD.text
<*> nullableColumn HD.text
<*> compositeArrayColumn
(ProcParam
<$> compositeField HD.text
<*> compositeField HD.text
<*> compositeField HD.bool
<*> compositeField HD.bool)
<*> (parseRetType
<$> column HD.text
<*> column HD.text
<*> column HD.bool
<*> column HD.bool)
<*> (parseVolatility <$> column HD.char)
<*> column HD.bool
addKey :: ProcDescription -> (QualifiedIdentifier, ProcDescription)
addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd)
parseRetType :: Text -> Text -> Bool -> Bool -> RetType
parseRetType schema name isSetOf isComposite
| isSetOf = SetOf pgType
| otherwise = Single pgType
where
qi = QualifiedIdentifier schema name
pgType
| isComposite = Composite qi
| otherwise = Scalar
parseVolatility :: Char -> ProcVolatility
parseVolatility v | v == 'i' = Immutable
| v == 's' = Stable
| otherwise = Volatile -- only 'v' can happen here
allProcs :: Bool -> SQL.Statement [Schema] ProcsMap
allProcs = SQL.Statement sql (arrayParam HE.text) decodeProcs
where
sql = procsSqlQuery <> " WHERE pn.nspname = ANY($1)"
accessibleProcs :: Bool -> SQL.Statement Schema ProcsMap
accessibleProcs = SQL.Statement sql (param HE.text) decodeProcs
where
sql = procsSqlQuery <> " WHERE pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')"
procsSqlQuery :: SqlQuery
procsSqlQuery = [q|
-- Recursively get the base types of domains
WITH
base_types AS (
WITH RECURSIVE
recurse AS (
SELECT
oid,
typbasetype,
COALESCE(NULLIF(typbasetype, 0), oid) AS base
FROM pg_type
UNION
SELECT
t.oid,
b.typbasetype,
COALESCE(NULLIF(b.typbasetype, 0), b.oid) AS base
FROM recurse t
JOIN pg_type b ON t.typbasetype = b.oid
)
SELECT
oid,
base
FROM recurse
WHERE typbasetype = 0
),
arguments AS (
SELECT
oid,
array_agg((
COALESCE(name, ''), -- name
type::regtype::text, -- type
idx <= (pronargs - pronargdefaults), -- is_required
COALESCE(mode = 'v', FALSE) -- is_variadic
) ORDER BY idx) AS args
FROM pg_proc,
unnest(proargnames, proargtypes, proargmodes)
WITH ORDINALITY AS _ (name, type, mode, idx)
WHERE type IS NOT NULL -- only input arguments
GROUP BY oid
)
SELECT
pn.nspname AS proc_schema,
p.proname AS proc_name,
d.description AS proc_description,
COALESCE(a.args, '{}') AS args,
tn.nspname AS schema,
COALESCE(comp.relname, t.typname) AS name,
p.proretset AS rettype_is_setof,
(t.typtype = 'c'
-- if any TABLE, INOUT or OUT arguments present, treat as composite
or COALESCE(proargmodes::text[] && '{t,b,o}', false)
) AS rettype_is_composite,
p.provolatile,
p.provariadic > 0 as hasvariadic
FROM pg_proc p
LEFT JOIN arguments a ON a.oid = p.oid
JOIN pg_namespace pn ON pn.oid = p.pronamespace
JOIN base_types bt ON bt.oid = p.prorettype
JOIN pg_type t ON t.oid = bt.base
JOIN pg_namespace tn ON tn.oid = t.typnamespace
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
LEFT JOIN pg_catalog.pg_description as d ON d.objoid = p.oid
|]
schemaDescription :: Bool -> SQL.Statement Schema (Maybe Text)
schemaDescription =
SQL.Statement sql (param HE.text) (join <$> HD.rowMaybe (nullableColumn HD.text))
where
sql = [q|
select
description
from
pg_catalog.pg_namespace n
left join pg_catalog.pg_description d on d.objoid = n.oid
where
n.nspname = $1 |]
accessibleTables :: PgVersion -> Bool -> SQL.Statement Schema [Table]
accessibleTables pgVer =
SQL.Statement sql (param HE.text) decodeTables
where
sql = [q|
select
n.nspname as table_schema,
relname as table_name,
d.description as table_description,
(
c.relkind IN ('r','p')
OR (
c.relkind IN ('v','f')
-- CMD_INSERT - see allTables query below for explanation
AND (pg_relation_is_updatable(c.oid::regclass, TRUE) & 8) = 8
)
) AS insertable,
(
c.relkind IN ('r','p')
OR (
c.relkind IN ('v','f')
-- CMD_UPDATE
AND (pg_relation_is_updatable(c.oid::regclass, TRUE) & 4) = 4
)
) as updatable,
(
c.relkind IN ('r','p')
OR (
c.relkind IN ('v','f')
-- CMD_DELETE
AND (pg_relation_is_updatable(c.oid::regclass, TRUE) & 16) = 16
)
) as deletable
from
pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_catalog.pg_description as d on d.objoid = c.oid and d.objsubid = 0
where
c.relkind in ('v','r','m','f','p')
and n.nspname = $1 |]
<> relIsNotPartition pgVer <> [q|
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')
)
order by relname |]
{-
Adds Views M2O Relationships based on SourceColumns found, the logic is as follows:
Having a Relationship{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relCardinality=M2O} represented by:
t1.c1------t2.c2
When only having a t1_view.c1 source column, we need to add a View-Table M2O Relationship
t1.c1----t2.c2 t1.c1----------t2.c2
-> ________/
/
t1_view.c1 t1_view.c1
When only having a t2_view.c2 source column, we need to add a Table-View M2O Relationship
t1.c1----t2.c2 t1.c1----------t2.c2
-> \________
\
t2_view.c2 t2_view.c1
When having t1_view.c1 and a t2_view.c2 source columns, we need to add a View-View M2O Relationship in addition to the prior
t1.c1----t2.c2 t1.c1----------t2.c2
-> \________/
/ \
t1_view.c1 t2_view.c2 t1_view.c1-------t2_view.c1
The logic for composite pks is similar just need to make sure all the Relationship columns have source columns.
-}
addViewM2ORels :: [SourceColumn] -> [Relationship] -> [Relationship]
addViewM2ORels allSrcCols = concatMap (\rel@Relationship{..} -> rel :
let
srcColsGroupedByView :: [Column] -> [[SourceColumn]]
srcColsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $
filter (\(c, _) -> c `elem` relCols) allSrcCols
relSrcCols = srcColsGroupedByView relColumns
relFSrcCols = srcColsGroupedByView relForeignColumns
getView :: [SourceColumn] -> Table
getView = colTable . snd . unsafeHead
srcCols `allSrcColsOf` cols = S.fromList (fst <$> srcCols) == S.fromList cols
-- Relationship is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query.
-- So we need to change the order of the SourceColumns to match the relColumns
-- TODO: This could be avoided if the Relationship type is improved with a structure that maintains the association of relColumns and relFColumns
srcCols `sortAccordingTo` cols = sortOn (\(k, _) -> L.lookup k $ zip cols [0::Int ..]) srcCols
viewTableM2O =
[ Relationship
(getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)
relForeignTable relForeignColumns relCardinality
| srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns ]
tableViewM2O =
[ Relationship
relTable relColumns
(getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relForeignColumns)
relCardinality
| fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relForeignColumns ]
viewViewM2O =
[ Relationship
(getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)
(getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relForeignColumns)
relCardinality
| srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns
, fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relForeignColumns ]
in viewTableM2O ++ tableViewM2O ++ viewViewM2O)
addO2MRels :: [Relationship] -> [Relationship]
addO2MRels rels = rels ++ [ Relationship ft fc t c (O2M cons)
| Relationship t c ft fc (M2O cons) <- rels ]
addM2MRels :: [Relationship] -> [Relationship]
addM2MRels rels = rels ++ [ Relationship t c ft fc (M2M $ Junction jt1 cons1 jc1 cons2 jc2)
| Relationship jt1 jc1 t c (M2O cons1) <- rels
, Relationship jt2 jc2 ft fc (M2O cons2) <- rels
, jt1 == jt2
, cons1 /= cons2]
addViewPrimaryKeys :: [SourceColumn] -> [PrimaryKey] -> [PrimaryKey]
addViewPrimaryKeys srcCols = concatMap (\pk ->
let viewPks = (\(_, viewCol) -> PrimaryKey{pkTable=colTable viewCol, pkName=colName viewCol}) <$>
filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) srcCols in
pk : viewPks)
allTables :: PgVersion -> Bool -> SQL.Statement () [Table]
allTables pgVer =
SQL.Statement sql HE.noParams decodeTables
where
sql = [q|
SELECT
n.nspname AS table_schema,
c.relname AS table_name,
d.description AS table_description,
(
c.relkind IN ('r','p')
OR (
c.relkind in ('v','f')
-- The function `pg_relation_is_updateable` returns a bitmask where 8
-- corresponds to `1 << CMD_INSERT` in the PostgreSQL source code, i.e.
-- it's possible to insert into the relation.
AND (pg_relation_is_updatable(c.oid::regclass, TRUE) & 8) = 8
)
) AS insertable,
(
c.relkind IN ('r','p')
OR (
c.relkind in ('v','f')
-- CMD_UPDATE
AND (pg_relation_is_updatable(c.oid::regclass, TRUE) & 4) = 4
)
) AS updatable,
(
c.relkind IN ('r','p')
OR (
c.relkind in ('v','f')
-- CMD_DELETE
AND (pg_relation_is_updatable(c.oid::regclass, TRUE) & 16) = 16
)
) AS deletable
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_catalog.pg_description as d on d.objoid = c.oid and d.objsubid = 0
WHERE c.relkind IN ('v','r','m','f','p')
AND n.nspname NOT IN ('pg_catalog', 'information_schema') |]
<> relIsNotPartition pgVer <> [q|
ORDER BY table_schema, table_name |]
relIsNotPartition :: PgVersion -> SqlQuery
relIsNotPartition pgVer = if pgVer >= pgVersion100 then " AND not c.relispartition " else mempty
allColumns :: [Table] -> Bool -> SQL.Statement [Schema] [Column]
allColumns tabs =
SQL.Statement sql (arrayParam HE.text) (decodeColumns tabs)
where
sql = [q|
SELECT DISTINCT
info.table_schema AS schema,
info.table_name AS table_name,
info.column_name AS name,
info.description AS description,
info.is_nullable::boolean AS nullable,
info.data_type AS col_type,
info.character_maximum_length AS max_len,
info.column_default AS default_value,
array_to_string(enum_info.vals, ',') AS enum,
info.position
FROM (
-- CTE based on pg_catalog to get PRIMARY/FOREIGN key and UNIQUE columns outside api schema
WITH key_columns AS (
SELECT
r.oid AS r_oid,
c.oid AS c_oid,
n.nspname,
c.relname,
r.conname,
r.contype,
unnest(r.conkey) AS conkey
FROM
pg_catalog.pg_constraint r,
pg_catalog.pg_class c,
pg_catalog.pg_namespace n
WHERE
r.contype IN ('f', 'p', 'u')
AND c.relkind IN ('r', 'v', 'f', 'm', 'p')
AND r.conrelid = c.oid
AND c.relnamespace = n.oid
AND n.nspname <> ANY (ARRAY['pg_catalog', 'information_schema'] || $1)
),
/*
-- CTE based on information_schema.columns
-- changed:
-- remove the owner filter
-- limit columns to the ones in the api schema or PK/FK columns
*/
columns AS (
SELECT
nc.nspname::name AS table_schema,
c.relname::name AS table_name,
a.attname::name AS column_name,
d.description AS description,
pg_get_expr(ad.adbin, ad.adrelid)::text AS column_default,
not (a.attnotnull OR t.typtype = 'd' AND t.typnotnull) AS is_nullable,
CASE
WHEN t.typtype = 'd' THEN
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)
ELSE format_type(a.atttypid, a.atttypmod)
END
ELSE
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)
ELSE format_type(a.atttypid, a.atttypmod)
END
END::text AS data_type,
information_schema._pg_char_max_length(
information_schema._pg_truetypid(a.*, t.*),
information_schema._pg_truetypmod(a.*, t.*)
)::integer AS character_maximum_length,
COALESCE(bt.typname, t.typname)::name AS udt_name,
a.attnum::integer AS position
FROM pg_attribute a
LEFT JOIN key_columns kc
ON kc.conkey = a.attnum AND kc.c_oid = a.attrelid
LEFT JOIN pg_catalog.pg_description AS d
ON d.objoid = a.attrelid and d.objsubid = a.attnum
LEFT JOIN pg_attrdef ad
ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
JOIN (pg_class c JOIN pg_namespace nc ON c.relnamespace = nc.oid)
ON a.attrelid = c.oid
JOIN (pg_type t JOIN pg_namespace nt ON t.typnamespace = nt.oid)
ON a.atttypid = t.oid
LEFT JOIN (pg_type bt JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid)
ON t.typtype = 'd' AND t.typbasetype = bt.oid
LEFT JOIN (pg_collation co JOIN pg_namespace nco ON co.collnamespace = nco.oid)
ON a.attcollation = co.oid AND (nco.nspname <> 'pg_catalog'::name OR co.collname <> 'default'::name)
WHERE
NOT pg_is_other_temp_schema(nc.oid)
AND a.attnum > 0
AND NOT a.attisdropped
AND c.relkind in ('r', 'v', 'f', 'm', 'p')
-- Filter only columns that are FK/PK or in the api schema:
AND (nc.nspname = ANY ($1) OR kc.r_oid IS NOT NULL)
)
SELECT
table_schema,
table_name,
column_name,
description,
is_nullable,
data_type,
character_maximum_length,
column_default,
udt_name,
position
FROM columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
) AS info
LEFT OUTER JOIN (
SELECT
n.nspname AS s,
t.typname AS n,
array_agg(e.enumlabel ORDER BY e.enumsortorder) AS vals
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
GROUP BY s,n
) AS enum_info ON (info.udt_name = enum_info.n)
ORDER BY schema, position |]
columnFromRow :: [Table] ->
(Text, Text, Text,
Maybe Text, Bool, Text,
Maybe Int32, Maybe Text, Maybe Text)
-> Maybe Column
columnFromRow tabs (s, t, n, desc, nul, typ, l, d, e) = buildColumn <$> table
where
buildColumn tbl = Column tbl n desc nul typ l d (parseEnum e)
table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
parseEnum :: Maybe Text -> [Text]
parseEnum = maybe [] (split (==','))
allM2ORels :: [Table] -> [Column] -> Bool -> SQL.Statement () [Relationship]
allM2ORels tabs cols =
SQL.Statement sql HE.noParams (decodeRels tabs cols)
where
sql = [q|
SELECT ns1.nspname AS table_schema,
tab.relname AS table_name,
conname AS constraint_name,
column_info.cols AS columns,
ns2.nspname AS foreign_table_schema,
other.relname AS foreign_table_name,
column_info.refs AS foreign_columns
FROM pg_constraint,
LATERAL (
SELECT array_agg(cols.attname) AS cols,
array_agg(cols.attnum) AS nums,
array_agg(refs.attname) AS refs
FROM ( SELECT unnest(conkey) AS col, unnest(confkey) AS ref) k,
LATERAL (SELECT * FROM pg_attribute WHERE attrelid = conrelid AND attnum = col) AS cols,
LATERAL (SELECT * FROM pg_attribute WHERE attrelid = confrelid AND attnum = ref) AS refs) AS column_info,
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = connamespace) AS ns1,
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = conrelid) AS tab,
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = confrelid) AS other,
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = other.relnamespace) AS ns2
WHERE confrelid != 0
ORDER BY (conrelid, column_info.nums) |]
relFromRow :: [Table] -> [Column] -> (Text, Text, Text, [Text], Text, Text, [Text]) -> Maybe Relationship
relFromRow allTabs allCols (rs, rt, cn, rcs, frs, frt, frcs) =
Relationship <$> table <*> cols <*> tableF <*> colsF <*> pure (M2O cn)
where
findTable s t = find (\tbl -> tableSchema tbl == s && tableName tbl == t) allTabs
findCol s t c = find (\col -> tableSchema (colTable col) == s && tableName (colTable col) == t && colName col == c) allCols
table = findTable rs rt
tableF = findTable frs frt
cols = mapM (findCol rs rt) rcs
colsF = mapM (findCol frs frt) frcs
allPrimaryKeys :: [Table] -> Bool -> SQL.Statement () [PrimaryKey]
allPrimaryKeys tabs =
SQL.Statement sql HE.noParams (decodePks tabs)
where
sql = [q|
-- CTE to replace information_schema.table_constraints to remove owner limit
WITH tc AS (
SELECT
c.conname::name AS constraint_name,
nr.nspname::name AS table_schema,
r.relname::name AS table_name
FROM pg_namespace nc,
pg_namespace nr,
pg_constraint c,
pg_class r
WHERE
nc.oid = c.connamespace
AND nr.oid = r.relnamespace
AND c.conrelid = r.oid
AND r.relkind IN ('r', 'p')
AND NOT pg_is_other_temp_schema(nr.oid)
AND c.contype = 'p'
),
-- CTE to replace information_schema.key_column_usage to remove owner limit
kc AS (
SELECT
ss.conname::name AS constraint_name,
ss.nr_nspname::name AS table_schema,
ss.relname::name AS table_name,
a.attname::name AS column_name,
(ss.x).n::integer AS ordinal_position,
CASE
WHEN ss.contype = 'f' THEN information_schema._pg_index_position(ss.conindid, ss.confkey[(ss.x).n])
ELSE NULL::integer
END::integer AS position_in_unique_constraint
FROM pg_attribute a,
( SELECT r.oid AS roid,
r.relname,
r.relowner,
nc.nspname AS nc_nspname,
nr.nspname AS nr_nspname,
c.oid AS coid,
c.conname,
c.contype,
c.conindid,
c.confkey,
information_schema._pg_expandarray(c.conkey) AS x
FROM pg_namespace nr,
pg_class r,
pg_namespace nc,
pg_constraint c
WHERE
nr.oid = r.relnamespace
AND r.oid = c.conrelid
AND nc.oid = c.connamespace
AND c.contype in ('p', 'u', 'f')
AND r.relkind IN ('r', 'p')
AND NOT pg_is_other_temp_schema(nr.oid)
) ss
WHERE
ss.roid = a.attrelid
AND a.attnum = (ss.x).x
AND NOT a.attisdropped
)
SELECT
kc.table_schema,
kc.table_name,
kc.column_name
FROM
tc, kc
WHERE
kc.table_name = tc.table_name AND
kc.table_schema = tc.table_schema AND
kc.constraint_name = tc.constraint_name AND
kc.table_schema NOT IN ('pg_catalog', 'information_schema') |]
pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
-- returns all the primary and foreign key columns which are referenced in views
pfkSourceColumns :: [Column] -> Bool -> SQL.Statement ([Schema], [Schema]) [SourceColumn]
pfkSourceColumns cols =
SQL.Statement sql (contrazip2 (arrayParam HE.text) (arrayParam HE.text)) (decodeSourceColumns cols)
-- query explanation at:
-- * rationale: https://gist.github.com/wolfgangwalther/5425d64e7b0d20aad71f6f68474d9f19
-- * json transformation: https://gist.github.com/wolfgangwalther/3a8939da680c24ad767e93ad2c183089
where
sql = [q|
with recursive
pks_fks as (
-- pk + fk referencing col
select
conrelid as resorigtbl,
unnest(conkey) as resorigcol
from pg_constraint
where contype IN ('p', 'f')
union
-- fk referenced col
select
confrelid,
unnest(confkey)
from pg_constraint
where contype='f'
),
views as (
select
c.oid as view_id,
n.nspname as view_schema,
c.relname as view_name,
r.ev_action as view_definition
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
join pg_rewrite r on r.ev_class = c.oid
where c.relkind in ('v', 'm') and n.nspname = ANY($1 || $2)
),
transform_json as (
select
view_id, view_schema, view_name,
-- the following formatting is without indentation on purpose
-- to allow simple diffs, with less whitespace noise
replace(
replace(
replace(
replace(
replace(
replace(
replace(
replace(
regexp_replace(
replace(
replace(
replace(
replace(
replace(
replace(
replace(
replace(
replace(
replace(
view_definition::text,
-- This conversion to json is heavily optimized for performance.
-- The general idea is to use as few regexp_replace() calls as possible.
-- Simple replace() is a lot faster, so we jump through some hoops
-- to be able to use regexp_replace() only once.
-- This has been tested against a huge schema with 250+ different views.
-- The unit tests do NOT reflect all possible inputs. Be careful when changing this!
-- -----------------------------------------------
-- pattern | replacement | flags
-- -----------------------------------------------
-- `,` is not part of the pg_node_tree format, but used in the regex.
-- This removes all `,` that might be part of column names.
',' , ''
-- The same applies for `{` and `}`, although those are used a lot in pg_node_tree.
-- We remove the escaped ones, which might be part of column names again.
), E'\\{' , ''
), E'\\}' , ''
-- The fields we need are formatted as json manually to protect them from the regex.
), ' :targetList ' , ',"targetList":'
), ' :resno ' , ',"resno":'
), ' :resorigtbl ' , ',"resorigtbl":'
), ' :resorigcol ' , ',"resorigcol":'
-- Make the regex also match the node type, e.g. `{QUERY ...`, to remove it in one pass.
), '{' , '{ :'
-- Protect node lists, which start with `({` or `((` from the greedy regex.
-- The extra `{` is removed again later.
), '((' , '{(('
), '({' , '{({'
-- This regex removes all unused fields to avoid the need to format all of them correctly.
-- This leads to a smaller json result as well.
-- Removal stops at `,` for used fields (see above) and `}` for the end of the current node.
-- Nesting can't be parsed correctly with a regex, so we stop at `{` as well and
-- add an empty key for the followig node.
), ' :[^}{,]+' , ',"":' , 'g'
-- For performance, the regex also added those empty keys when hitting a `,` or `}`.
-- Those are removed next.
), ',"":}' , '}'
), ',"":,' , ','
-- This reverses the "node list protection" from above.
), '{(' , '('
-- Every key above has been added with a `,` so far. The first key in an object doesn't need it.
), '{,' , '{'
-- pg_node_tree has `()` around lists, but JSON uses `[]`
), '(' , '['
), ')' , ']'
-- pg_node_tree has ` ` between list items, but JSON uses `,`
), ' ' , ','
-- `<>` in pg_node_tree is the same as `null` in JSON, but due to very poor performance of json_typeof
-- we need to make this an empty array here to prevent json_array_elements from throwing an error
-- when the targetList is null.
), '<>' , '[]'
)::json as view_definition
from views
),
target_entries as(
select
view_id, view_schema, view_name,
json_array_elements(view_definition->0->'targetList') as entry
from transform_json
),
results as(
select
view_id, view_schema, view_name,
(entry->>'resno')::int as view_column,
(entry->>'resorigtbl')::oid as resorigtbl,
(entry->>'resorigcol')::int as resorigcol
from target_entries
),
recursion as(
select r.*
from results r
where view_schema = ANY ($1)
union all
select
view.view_id,
view.view_schema,
view.view_name,
view.view_column,
tab.resorigtbl,
tab.resorigcol
from recursion view
join results tab on view.resorigtbl=tab.view_id and view.resorigcol=tab.view_column
)
select
sch.nspname as table_schema,
tbl.relname as table_name,
col.attname as table_column_name,
rec.view_schema,
rec.view_name,
vcol.attname as view_column_name
from recursion rec
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)
order by view_schema, view_name, view_column_name; |]
param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable
arrayParam :: HE.Value a -> HE.Params [a]
arrayParam = param . HE.foldableArray . HE.nonNullable
compositeArrayColumn :: HD.Composite a -> HD.Row [a]
compositeArrayColumn = arrayColumn . HD.composite
compositeField :: HD.Value a -> HD.Composite a
compositeField = HD.field . HD.nonNullable
column :: HD.Value a -> HD.Row a
column = HD.column . HD.nonNullable
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
nullableColumn = HD.column . HD.nullable
arrayColumn :: HD.Value a -> HD.Row [a]
arrayColumn = column . HD.listArray . HD.nonNullable
-85
View File
@@ -1,85 +0,0 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
module PostgREST.DbStructure.Proc
( PgType(..)
, ProcDescription(..)
, ProcParam(..)
, ProcVolatility(..)
, ProcsMap
, RetType(..)
, procReturnsScalar
, procReturnsSingle
, procTableName
) where
import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..),
Schema, TableName)
import Protolude
data PgType
= Scalar
| Composite QualifiedIdentifier
deriving (Eq, Ord, Generic, JSON.ToJSON)
data RetType
= Single PgType
| SetOf PgType
deriving (Eq, Ord, Generic, JSON.ToJSON)
data ProcVolatility
= Volatile
| Stable
| Immutable
deriving (Eq, Ord, Generic, JSON.ToJSON)
data ProcDescription = ProcDescription
{ pdSchema :: Schema
, pdName :: Text
, pdDescription :: Maybe Text
, pdParams :: [ProcParam]
, pdReturnType :: RetType
, pdVolatility :: ProcVolatility
, pdHasVariadic :: Bool
}
deriving (Eq, Generic, JSON.ToJSON)
data ProcParam = ProcParam
{ ppName :: Text
, ppType :: Text
, ppReq :: Bool
, ppVar :: Bool
}
deriving (Eq, Ord, Generic, JSON.ToJSON)
-- Order by least number of params in the case of overloaded functions
instance Ord ProcDescription where
ProcDescription schema1 name1 des1 prms1 rt1 vol1 hasVar1 `compare` ProcDescription schema2 name2 des2 prms2 rt2 vol2 hasVar2
| schema1 == schema2 && name1 == name2 && length prms1 < length prms2 = LT
| schema2 == schema2 && name1 == name2 && length prms1 > length prms2 = GT
| otherwise = (schema1, name1, des1, prms1, rt1, vol1, hasVar1) `compare` (schema2, name2, des2, prms2, rt2, vol2, hasVar2)
-- | A map of all procs, all of which can be overloaded(one entry will have more than one ProcDescription).
-- | It uses a HashMap for a faster lookup.
type ProcsMap = M.HashMap QualifiedIdentifier [ProcDescription]
procReturnsScalar :: ProcDescription -> Bool
procReturnsScalar proc = case proc of
ProcDescription{pdReturnType = (Single Scalar)} -> True
ProcDescription{pdReturnType = (SetOf Scalar)} -> True
_ -> False
procReturnsSingle :: ProcDescription -> Bool
procReturnsSingle proc = case proc of
ProcDescription{pdReturnType = (Single _)} -> True
_ -> False
procTableName :: ProcDescription -> Maybe TableName
procTableName proc = case pdReturnType proc of
SetOf (Composite qi) -> Just $ qiName qi
Single (Composite qi) -> Just $ qiName qi
_ -> Nothing
-62
View File
@@ -1,62 +0,0 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
module PostgREST.DbStructure.Relationship
( Cardinality(..)
, PrimaryKey(..)
, Relationship(..)
, Junction(..)
, isSelfReference
) where
import qualified Data.Aeson as JSON
import PostgREST.DbStructure.Table (Column (..), Table (..))
import Protolude
-- | Relationship between two tables.
--
-- The order of the relColumns and relForeignColumns should be maintained to get the
-- join conditions right.
--
-- TODO merge relColumns and relForeignColumns to a tuple or Data.Bimap
data Relationship = Relationship
{ relTable :: Table
, relColumns :: [Column]
, relForeignTable :: Table
, relForeignColumns :: [Column]
, relCardinality :: Cardinality
}
deriving (Eq, Generic, JSON.ToJSON)
-- | The relationship cardinality
-- | https://en.wikipedia.org/wiki/Cardinality_(data_modeling)
-- TODO: missing one-to-one
data Cardinality
= O2M FKConstraint -- ^ one-to-many cardinality
| M2O FKConstraint -- ^ many-to-one cardinality
| M2M Junction -- ^ many-to-many cardinality
deriving (Eq, Generic, JSON.ToJSON)
type FKConstraint = Text
-- | Junction table on an M2M relationship
data Junction = Junction
{ junTable :: Table
, junConstraint1 :: FKConstraint
, junColumns1 :: [Column]
, junConstraint2 :: FKConstraint
, junColumns2 :: [Column]
}
deriving (Eq, Generic, JSON.ToJSON)
isSelfReference :: Relationship -> Bool
isSelfReference r = relTable r == relForeignTable r
data PrimaryKey = PrimaryKey
{ pkTable :: Table
, pkName :: Text
}
deriving (Generic, JSON.ToJSON)
-55
View File
@@ -1,55 +0,0 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
module PostgREST.DbStructure.Table
( Column(..)
, Table(..)
, tableQi
) where
import qualified Data.Aeson as JSON
import PostgREST.DbStructure.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema, TableName)
import Protolude
data Table = Table
{ tableSchema :: Schema
, tableName :: TableName
, tableDescription :: Maybe Text
-- The following fields identify what can be done on the table/view, they're not related to the privileges granted to it
, tableInsertable :: Bool
, tableUpdatable :: Bool
, tableDeletable :: Bool
}
deriving (Show, Ord, Generic, JSON.ToJSON)
instance Eq Table where
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
tableQi :: Table -> QualifiedIdentifier
tableQi Table{tableSchema=s, tableName=n} = QualifiedIdentifier s n
data Column = Column
{ colTable :: Table
, colName :: FieldName
, colDescription :: Maybe Text
, colNullable :: Bool
, colType :: Text
, colMaxLen :: Maybe Int32
, colDefault :: Maybe Text
, colEnum :: [Text]
}
deriving (Ord, Generic, JSON.ToJSON)
instance Eq Column where
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
data PrimaryKey = PrimaryKey
{ pkTable :: Table
, pkName :: Text
}
deriving (Generic, JSON.ToJSON)
+472 -182
View File
@@ -11,12 +11,13 @@ module PostgREST.Error
, PgError(..) , PgError(..)
, Error(..) , Error(..)
, errorPayload , errorPayload
, checkIsFatal
, singularityError , singularityError
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.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,16 +30,20 @@ import Network.Wai (Response, responseLBS)
import Network.HTTP.Types.Header (Header) import Network.HTTP.Types.Header (Header)
import PostgREST.ContentType (ContentType (..)) import PostgREST.ApiRequest.Types (ApiRequestError (..),
import qualified PostgREST.ContentType as ContentType QPError (..),
RangeError (..))
import PostgREST.MediaType (MediaType (..))
import qualified PostgREST.MediaType as MediaType
import PostgREST.DbStructure.Proc (ProcDescription (..), import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
ProcParam (..)) Schema)
import PostgREST.DbStructure.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
Relationship (..)) Relationship (..),
import PostgREST.DbStructure.Table (Column (..), Table (..)) RelationshipsMap)
import PostgREST.SchemaCache.Routine (Routine (..),
RoutineParam (..))
import Protolude import Protolude
@@ -52,108 +57,301 @@ class (JSON.ToJSON a) => PgrstError a where
errorResponseFor :: a -> Response errorResponseFor :: a -> Response
errorResponseFor err = responseLBS (status err) (headers err) $ errorPayload err errorResponseFor err = responseLBS (status err) (headers err) $ errorPayload err
data ApiRequestError
= ActionInappropriate
| InvalidRange
| InvalidBody ByteString
| ParseRequestError Text Text
| NoRelBetween Text Text
| AmbiguousRelBetween Text Text [Relationship]
| AmbiguousRpc [ProcDescription]
| NoRpc Text Text [Text] Bool ContentType Bool
| InvalidFilters
| UnacceptableSchema [Text]
| ContentTypeError [ByteString]
| UnsupportedVerb -- Unreachable?
instance PgrstError ApiRequestError where instance PgrstError ApiRequestError where
status InvalidRange = HTTP.status416
status InvalidFilters = HTTP.status405
status (InvalidBody _) = HTTP.status400
status UnsupportedVerb = HTTP.status405
status ActionInappropriate = HTTP.status405
status (ParseRequestError _ _) = HTTP.status400
status (NoRelBetween _ _) = HTTP.status400
status AmbiguousRelBetween{} = HTTP.status300 status AmbiguousRelBetween{} = HTTP.status300
status (AmbiguousRpc _) = HTTP.status300 status AmbiguousRpc{} = HTTP.status300
status NoRpc{} = HTTP.status404 status BinaryFieldError{} = HTTP.status406
status (UnacceptableSchema _) = HTTP.status406 status MediaTypeError{} = HTTP.status415
status (ContentTypeError _) = HTTP.status415 status InvalidBody{} = HTTP.status400
status InvalidFilters = HTTP.status405
status InvalidRpcMethod{} = HTTP.status405
status InvalidRange{} = HTTP.status416
status NotFound = HTTP.status404
headers _ = [ContentType.toHeader CTApplicationJSON] status NoRelBetween{} = HTTP.status400
status NoRpc{} = HTTP.status404
status NotEmbedded{} = HTTP.status400
status PutLimitNotAllowedError = HTTP.status400
status QueryParamError{} = HTTP.status400
status RelatedOrderNotToOne{} = HTTP.status400
status SpreadNotToOne{} = HTTP.status400
status UnacceptableFilter{} = HTTP.status400
status UnacceptableSchema{} = HTTP.status406
status UnsupportedMethod{} = HTTP.status405
status LimitNoOrderError = HTTP.status400
status ColumnNotFound{} = HTTP.status400
headers _ = [MediaType.toContentType MTApplicationJSON]
instance JSON.ToJSON ApiRequestError where instance JSON.ToJSON ApiRequestError where
toJSON (ParseRequestError message details) = JSON.object [ toJSON (QueryParamError (QPError message details)) = JSON.object [
"message" .= message, "details" .= details] "code" .= ApiRequestErrorCode00,
toJSON ActionInappropriate = JSON.object [ "message" .= message,
"message" .= ("Bad Request" :: Text)] "details" .= details,
"hint" .= JSON.Null]
toJSON (InvalidRpcMethod method) = JSON.object [
"code" .= ApiRequestErrorCode01,
"message" .= ("Cannot use the " <> T.decodeUtf8 method <> " method on RPC"),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (InvalidBody errorMessage) = JSON.object [ toJSON (InvalidBody errorMessage) = JSON.object [
"message" .= T.decodeUtf8 errorMessage] "code" .= ApiRequestErrorCode02,
toJSON InvalidRange = JSON.object [ "message" .= T.decodeUtf8 errorMessage,
"message" .= ("HTTP Range error" :: Text)] "details" .= JSON.Null,
toJSON (NoRelBetween parent child) = JSON.object [ "hint" .= JSON.Null]
"hint" .= ("If a new foreign key between these entities was created in the database, try reloading the schema cache." :: Text), toJSON (InvalidRange rangeError) = JSON.object [
"message" .= ("Could not find a relationship between " <> parent <> " and " <> child <> " in the schema cache" :: Text)] "code" .= ApiRequestErrorCode03,
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [ "message" .= ("Requested range not satisfiable" :: Text),
"hint" .= ("Try changing '" <> child <> "' to one of the following: " <> relHint rels <> ". Find the desired relationship in the 'details' key." :: Text), "details" .= (case rangeError of
"message" .= ("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'" :: Text), NegativeLimit -> "Limit should be greater than or equal to zero."
"details" .= (compressedRel <$> rels) ] LowerGTUpper -> "The lower boundary must be lower than or equal to the upper boundary in the Range header."
toJSON (AmbiguousRpc procs) = JSON.object [ OutOfBounds lower total -> "An offset of " <> lower <> " was requested, but there are only " <> total <> " rows."),
"hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text), "hint" .= JSON.Null]
"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])]
toJSON (NoRpc schema procName argumentKeys hasPreferSingleObject contentType isInvPost) =
let prms = "(" <> T.intercalate ", " argumentKeys <> ")" in JSON.object [
"hint" .= ("If a new function was created in the database with this name and parameters, try reloading the schema cache." :: Text),
"message" .= ("Could not find the " <> schema <> "." <> procName <>
(case (hasPreferSingleObject, isInvPost, contentType) of
(True, _, _) -> " function with a single json or jsonb parameter"
(_, True, CTTextPlain) -> " function with a single unnamed text parameter"
(_, True, CTOctetStream) -> " function with a single unnamed bytea parameter"
(_, True, CTApplicationJSON) -> prms <> " function or the " <> schema <> "." <> procName <>" function with a single unnamed json or jsonb parameter"
_ -> prms <> " function") <>
" in the schema cache")]
toJSON UnsupportedVerb = JSON.object [
"message" .= ("Unsupported HTTP verb" :: Text)]
toJSON InvalidFilters = JSON.object [ toJSON InvalidFilters = JSON.object [
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)] "code" .= ApiRequestErrorCode05,
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (UnacceptableSchema schemas) = JSON.object [ toJSON (UnacceptableSchema schemas) = JSON.object [
"message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas)] "code" .= ApiRequestErrorCode06,
toJSON (ContentTypeError cts) = JSON.object [ "message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas),
"message" .= ("None of these Content-Types are available: " <> T.intercalate ", " (map T.decodeUtf8 cts))] "details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (MediaTypeError cts) = JSON.object [
"code" .= ApiRequestErrorCode07,
"message" .= ("None of these media types are available: " <> T.intercalate ", " (map T.decodeUtf8 cts)),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON NotFound = JSON.object []
toJSON (NotEmbedded resource) = JSON.object [
"code" .= ApiRequestErrorCode08,
"message" .= ("'" <> resource <> "' is not an embedded resource in this request" :: Text),
"details" .= JSON.Null,
"hint" .= ("Verify that '" <> resource <> "' is included in the 'select' query parameter." :: Text)]
toJSON LimitNoOrderError = JSON.object [
"code" .= ApiRequestErrorCode09,
"message" .= ("A 'limit' was applied without an explicit 'order'":: Text),
"details" .= JSON.Null,
"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 PutLimitNotAllowedError = JSON.object [
"code" .= ApiRequestErrorCode14,
"message" .= ("limit/offset querystring parameters are not allowed for PUT" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (UnsupportedMethod method) = JSON.object [
"code" .= ApiRequestErrorCode17,
"message" .= ("Unsupported HTTP method: " <> T.decodeUtf8 method),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (RelatedOrderNotToOne origin target) = JSON.object [
"code" .= ApiRequestErrorCode18,
"message" .= ("A related order on '" <> target <> "' is not possible" :: Text),
"details" .= ("'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship" :: Text),
"hint" .= JSON.Null]
toJSON (SpreadNotToOne origin target) = JSON.object [
"code" .= ApiRequestErrorCode19,
"message" .= ("A spread operation on '" <> target <> "' is not possible" :: Text),
"details" .= ("'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship" :: Text),
"hint" .= JSON.Null]
toJSON (UnacceptableFilter target) = JSON.object [
"code" .= ApiRequestErrorCode20,
"message" .= ("Bad operator on the '" <> target <> "' embedded resource":: Text),
"details" .= ("Only is null or not is null filters are allowed on embedded resources":: Text),
"hint" .= JSON.Null]
toJSON (NoRelBetween parent child embedHint schema allRels) = JSON.object [
"code" .= SchemaCacheErrorCode00,
"message" .= ("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache" :: Text),
"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" .= noRelBetweenHint parent child schema allRels]
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [
"code" .= SchemaCacheErrorCode01,
"message" .= ("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'" :: Text),
"details" .= (compressedRel <$> rels),
"hint" .= ("Try changing '" <> child <> "' to one of the following: " <> relHint rels <> ". Find the desired relationship in the 'details' key." :: Text)]
toJSON (NoRpc schema procName argumentKeys hasPreferSingleObject contentType isInvPost allProcs overloadedProcs) =
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,
"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
(True, _, _) -> " with a single json/jsonb parameter"
(_, True, MTTextPlain) -> " with a single unnamed text parameter"
(_, True, MTTextXML) -> " with a single unnamed xml parameter"
(_, True, MTOctetStream) -> " with a single unnamed bytea parameter"
(_, True, MTApplicationJSON) -> fmtPrms prmsDet <> " or with a single unnamed json/jsonb parameter"
_ -> fmtPrms prmsDet) <>
", but no matches were found in the schema cache."),
-- The hint will be null in the case of single unnamed parameter functions
"hint" .= if onlySingleParams
then Nothing
else noRpcHint schema procName argumentKeys allProcs overloadedProcs ]
toJSON (AmbiguousRpc procs) = JSON.object [
"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]),
"details" .= JSON.Null,
"hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)]
toJSON (ColumnNotFound relName colName) = JSON.object [
"code" .= SchemaCacheErrorCode04,
"message" .= ("Column '" <> colName <> "' of relation '" <> relName <> "' does not exist" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
-- |
-- 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 = [Function {pdParams = [RoutineParam {ppName="val"}, RoutineParam {ppName="param"}, RoutineParam {ppName="name"}]}, Function {pdParams = [RoutineParam {ppName="id"}, RoutineParam {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] -> [Routine] -> 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
compressedRel ComputedRelationship{} = JSON.object mempty
compressedRel Relationship{..} = compressedRel Relationship{..} =
let let
fmtTbl Table{..} = tableSchema <> "." <> tableName fmtEls els = "(" <> T.intercalate ", " els <> ")"
fmtEls els = "[" <> T.intercalate ", " els <> "]"
in in
JSON.object $ JSON.object $
("embedding" .= (tableName relTable <> " with " <> tableName relForeignTable :: Text)) ("embedding" .= (qiName relTable <> " with " <> qiName relForeignTable :: Text))
: case relCardinality of : case relCardinality of
M2M Junction{..} -> [ M2M Junction{..} -> [
"cardinality" .= ("many-to-many" :: Text) "cardinality" .= ("many-to-many" :: Text)
, "relationship" .= (fmtTbl junTable <> fmtEls [junConstraint1] <> fmtEls [junConstraint2]) , "relationship" .= (qiName junTable <> " using " <> junConstraint1 <> fmtEls (snd <$> junColsSource) <> " and " <> junConstraint2 <> fmtEls (snd <$> junColsTarget))
] ]
M2O cons -> [ M2O cons relColumns -> [
"cardinality" .= ("many-to-one" :: Text) "cardinality" .= ("many-to-one" :: Text)
, "relationship" .= (cons <> fmtEls (colName <$> relColumns) <> fmtEls (colName <$> relForeignColumns)) , "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (fst <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (snd <$> relColumns))
] ]
O2M cons -> [ O2O cons relColumns -> [
"cardinality" .= ("one-to-one" :: Text)
, "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (fst <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (snd <$> relColumns))
]
O2M cons relColumns -> [
"cardinality" .= ("one-to-many" :: Text) "cardinality" .= ("one-to-many" :: Text)
, "relationship" .= (cons <> fmtEls (colName <$> relColumns) <> fmtEls (colName <$> relForeignColumns)) , "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (fst <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (snd <$> relColumns))
] ]
relHint :: [Relationship] -> Text relHint :: [Relationship] -> Text
relHint rels = T.intercalate ", " (hintList <$> rels) relHint rels = T.intercalate ", " (hintList <$> rels)
where where
hintList Relationship{..} = hintList Relationship{..} =
let buildHint rel = "'" <> tableName relForeignTable <> "!" <> rel <> "'" in let buildHint rel = "'" <> qiName relForeignTable <> "!" <> rel <> "'" in
case relCardinality of case relCardinality of
M2M Junction{..} -> buildHint (tableName junTable) M2M Junction{..} -> buildHint (qiName junTable)
M2O cons -> buildHint cons M2O cons _ -> buildHint cons
O2M cons -> buildHint cons O2O cons _ -> buildHint cons
O2M cons _ -> buildHint cons
-- An ambiguousness error cannot happen for computed relationships TODO refactor so this mempty is not needed
hintList ComputedRelationship{} = mempty
data PgError = PgError Authenticated SQL.UsageError data PgError = PgError Authenticated SQL.UsageError
type Authenticated = Bool type Authenticated = Bool
@@ -163,61 +361,55 @@ instance PgrstError PgError where
headers err = headers err =
if status err == HTTP.status401 if status err == HTTP.status401
then [ContentType.toHeader CTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header] then [MediaType.toContentType MTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header]
else [ContentType.toHeader CTApplicationJSON] else [MediaType.toContentType MTApplicationJSON]
instance JSON.ToJSON PgError where instance JSON.ToJSON PgError where
toJSON (PgError _ usageError) = JSON.toJSON usageError toJSON (PgError _ usageError) = JSON.toJSON usageError
instance JSON.ToJSON SQL.UsageError where instance JSON.ToJSON SQL.UsageError where
toJSON (SQL.ConnectionError e) = JSON.object [ toJSON (SQL.ConnectionUsageError e) = JSON.object [
"code" .= ("" :: Text), "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),
toJSON (SQL.SessionError e) = JSON.toJSON e -- SQL.Error "hint" .= JSON.Null]
toJSON (SQL.SessionUsageError e) = JSON.toJSON e -- SQL.Error
toJSON SQL.AcquisitionTimeoutUsageError = JSON.object [
"code" .= ConnectionErrorCode03,
"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)) = case BS.unpack c of toJSON (SQL.ResultError (SQL.ServerError c m d h _p)) = JSON.object [
'P':'T':_ -> JSON.object [
"details" .= fmap T.decodeUtf8 d,
"hint" .= fmap T.decodeUtf8 h]
_ -> 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 (SQL.UnexpectedResult m)) = JSON.object [ toJSON (SQL.ResultError resultError) = JSON.object [
"message" .= (m :: Text)] "code" .= InternalErrorCode00,
toJSON (SQL.ResultError (SQL.RowError i SQL.EndOfInput)) = JSON.object [ "message" .= (show resultError :: Text),
"message" .= ("Row error: end of input" :: Text), "details" .= JSON.Null,
"details" .= ("Attempt to parse more columns than there are in the result" :: Text), "hint" .= JSON.Null]
"hint" .= (("Row number " <> show i) :: Text)]
toJSON (SQL.ResultError (SQL.RowError i SQL.UnexpectedNull)) = JSON.object [
"message" .= ("Row error: unexpected null" :: Text),
"details" .= ("Attempt to parse a NULL as some value." :: Text),
"hint" .= (("Row number " <> show i) :: Text)]
toJSON (SQL.ResultError (SQL.RowError i (SQL.ValueError d))) = JSON.object [
"message" .= ("Row error: Wrong value parser used" :: Text),
"details" .= d,
"hint" .= (("Row number " <> show i) :: Text)]
toJSON (SQL.ResultError (SQL.UnexpectedAmountOfRows i)) = JSON.object [
"message" .= ("Unexpected amount of rows" :: Text),
"details" .= i]
toJSON (SQL.ClientError d) = JSON.object [ toJSON (SQL.ClientError d) = JSON.object [
"code" .= ConnectionErrorCode01,
"message" .= ("Database client error. Retrying the connection." :: Text), "message" .= ("Database client error. Retrying the connection." :: Text),
"details" .= (fmap T.decodeUtf8 d :: Maybe Text)] "details" .= (fmap T.decodeUtf8 d :: Maybe Text),
"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
@@ -236,6 +428,7 @@ pgErrorStatus authed (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError rErr
'5':'3':_ -> HTTP.status503 -- insufficient resources '5':'3':_ -> HTTP.status503 -- insufficient resources
'5':'4':_ -> HTTP.status413 -- too complex '5':'4':_ -> HTTP.status413 -- too complex
'5':'5':_ -> HTTP.status500 -- obj not on prereq state '5':'5':_ -> HTTP.status500 -- obj not on prereq state
'5':'7':'P':'0':'1':_ -> HTTP.status503 -- terminating connection due to administrator command
'5':'7':_ -> HTTP.status500 -- operator intervention '5':'7':_ -> HTTP.status500 -- operator intervention
'5':'8':_ -> HTTP.status500 -- system error '5':'8':_ -> HTTP.status500 -- system error
'F':'0':_ -> HTTP.status500 -- conf file error 'F':'0':_ -> HTTP.status500 -- conf file error
@@ -243,7 +436,9 @@ pgErrorStatus authed (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError rErr
"P0001" -> HTTP.status400 -- default code for "raise" "P0001" -> HTTP.status400 -- default code for "raise"
'P':'0':_ -> HTTP.status500 -- PL/pgSQL Error 'P':'0':_ -> HTTP.status500 -- PL/pgSQL Error
'X':'X':_ -> HTTP.status500 -- internal Error 'X':'X':_ -> HTTP.status500 -- internal Error
"42883" -> HTTP.status404 -- undefined function "42883"-> if BS.isPrefixOf "function xmlagg(" m
then HTTP.status406
else HTTP.status404 -- undefined function
"42P01" -> HTTP.status404 -- undefined table "42P01" -> HTTP.status404 -- undefined table
"42501" -> if authed then HTTP.status403 else HTTP.status401 -- insufficient privilege "42501" -> if authed then HTTP.status403 else HTTP.status401 -- insufficient privilege
'P':'T':n -> fromMaybe HTTP.status500 (HTTP.mkStatus <$> readMaybe n <*> pure m) 'P':'T':n -> fromMaybe HTTP.status500 (HTTP.mkStatus <$> readMaybe n <*> pure m)
@@ -251,89 +446,93 @@ pgErrorStatus authed (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError rErr
_ -> HTTP.status500 _ -> HTTP.status500
checkIsFatal :: PgError -> Maybe Text
checkIsFatal (PgError _ (SQL.ConnectionError e))
| isAuthFailureMessage = Just $ toS failureMessage
| otherwise = Nothing
where isAuthFailureMessage = "FATAL: password authentication failed" `isPrefixOf` failureMessage
failureMessage = BS.unpack $ fromMaybe mempty e
checkIsFatal (PgError _ (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError serverError))))
= 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.
SQL.ServerError "42601" _ _ _
-> 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).
-- This would mean that a connection pooler in transaction mode is being used
-- while prepared statements are enabled in the PostgREST configuration,
-- both of which are incompatible with each other.
SQL.ServerError "42P05" _ _ _
-> 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).
-- 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" _ _
-> Just "Hint: Connection poolers in statement mode are not supported."
_ -> Nothing
checkIsFatal _ = Nothing
data Error data Error
= GucHeadersError = ApiRequestError ApiRequestError
| GucHeadersError
| GucStatusError | GucStatusError
| BinaryFieldError ContentType
| ConnectionLostError
| PutMatchingPkError
| PutRangeNotAllowedError
| JwtTokenMissing
| JwtTokenInvalid Text | JwtTokenInvalid Text
| SingularityError Integer | JwtTokenMissing
| NotFound | JwtTokenRequired
| ApiRequestError ApiRequestError | NoSchemaCacheError
| OffLimitsChangesError Int64 Integer
| PgErr PgError | PgErr PgError
| PutMatchingPkError
| SingularityError Integer
instance PgrstError Error where instance PgrstError Error where
status (ApiRequestError err) = status err
status GucHeadersError = HTTP.status500 status GucHeadersError = HTTP.status500
status GucStatusError = HTTP.status500 status GucStatusError = HTTP.status500
status (BinaryFieldError _) = HTTP.status406 status JwtTokenInvalid{} = HTTP.unauthorized401
status ConnectionLostError = HTTP.status503
status PutMatchingPkError = HTTP.status400
status PutRangeNotAllowedError = HTTP.status400
status JwtTokenMissing = HTTP.status500 status JwtTokenMissing = HTTP.status500
status (JwtTokenInvalid _) = HTTP.unauthorized401 status JwtTokenRequired = HTTP.unauthorized401
status (SingularityError _) = HTTP.status406 status NoSchemaCacheError = HTTP.status503
status NotFound = HTTP.status404 status OffLimitsChangesError{} = HTTP.status400
status (PgErr err) = status err status (PgErr err) = status err
status (ApiRequestError err) = status err status PutMatchingPkError = HTTP.status400
status SingularityError{} = HTTP.status406
headers (SingularityError _) = [ContentType.toHeader CTSingularJSON]
headers (JwtTokenInvalid m) = [ContentType.toHeader CTApplicationJSON, invalidTokenHeader m]
headers (PgErr err) = headers err
headers (ApiRequestError err) = headers err headers (ApiRequestError err) = headers err
headers _ = [ContentType.toHeader CTApplicationJSON] headers (JwtTokenInvalid m) = [MediaType.toContentType MTApplicationJSON, invalidTokenHeader m]
headers JwtTokenRequired = [MediaType.toContentType MTApplicationJSON, requiredTokenHeader]
headers (PgErr err) = headers err
headers SingularityError{} = [MediaType.toContentType (MTSingularJSON False)]
headers _ = [MediaType.toContentType MTApplicationJSON]
instance JSON.ToJSON Error where instance JSON.ToJSON Error where
toJSON GucHeadersError = JSON.object [ toJSON NoSchemaCacheError = JSON.object [
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text)] "code" .= ConnectionErrorCode02,
toJSON GucStatusError = JSON.object [ "message" .= ("Could not query the database for the schema cache. Retrying." :: Text),
"message" .= ("response.status guc must be a valid status code" :: Text)] "details" .= JSON.Null,
toJSON (BinaryFieldError ct) = JSON.object [ "hint" .= JSON.Null]
"message" .= ((T.decodeUtf8 (ContentType.toMime ct) <> " requested but more than one column was selected") :: Text)]
toJSON ConnectionLostError = JSON.object [
"message" .= ("Database connection lost. Retrying the connection." :: Text)]
toJSON PutRangeNotAllowedError = JSON.object [
"message" .= ("Range header and limit/offset querystring parameters are not allowed for PUT" :: Text)]
toJSON PutMatchingPkError = JSON.object [
"message" .= ("Payload values do not match URL in primary key column(s)" :: Text)]
toJSON (SingularityError n) = JSON.object [
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),
"details" .= T.unwords ["Results contain", show n, "rows,", T.decodeUtf8 (ContentType.toMime CTSingularJSON), "requires 1 row"]]
toJSON JwtTokenMissing = JSON.object [ toJSON JwtTokenMissing = JSON.object [
"message" .= ("Server lacks JWT secret" :: Text)] "code" .= JWTErrorCode00,
"message" .= ("Server lacks JWT secret" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (JwtTokenInvalid message) = JSON.object [ toJSON (JwtTokenInvalid message) = JSON.object [
"message" .= (message :: Text)] "code" .= JWTErrorCode01,
toJSON NotFound = JSON.object [] "message" .= (message :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON JwtTokenRequired = JSON.object [
"code" .= JWTErrorCode02,
"message" .= ("Anonymous access is disabled" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (OffLimitsChangesError n maxs) = JSON.object [
"code" .= ApiRequestErrorCode10,
"message" .= ("The maximum number of rows allowed to change was surpassed" :: Text),
"details" .= T.unwords ["Results contain", show n, "rows changed but the maximum number allowed is", show maxs],
"hint" .= JSON.Null]
toJSON GucHeadersError = JSON.object [
"code" .= ApiRequestErrorCode11,
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON GucStatusError = JSON.object [
"code" .= ApiRequestErrorCode12,
"message" .= ("response.status guc must be a valid status code" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON PutMatchingPkError = JSON.object [
"code" .= ApiRequestErrorCode15,
"message" .= ("Payload values do not match URL in primary key column(s)" :: Text),
"details" .= JSON.Null,
"hint" .= JSON.Null]
toJSON (SingularityError n) = JSON.object [
"code" .= ApiRequestErrorCode16,
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),
"details" .= T.unwords ["The result contains", show n, "rows"],
"hint" .= JSON.Null]
toJSON (PgErr err) = JSON.toJSON err toJSON (PgErr err) = JSON.toJSON err
toJSON (ApiRequestError err) = JSON.toJSON err toJSON (ApiRequestError err) = JSON.toJSON err
@@ -341,5 +540,96 @@ invalidTokenHeader :: Text -> Header
invalidTokenHeader m = invalidTokenHeader m =
("WWW-Authenticate", "Bearer error=\"invalid_token\", " <> "error_description=" <> encodeUtf8 (show m)) ("WWW-Authenticate", "Bearer error=\"invalid_token\", " <> "error_description=" <> encodeUtf8 (show m))
requiredTokenHeader :: Header
requiredTokenHeader = ("WWW-Authenticate", "Bearer")
singularityError :: (Integral a) => a -> Error singularityError :: (Integral a) => a -> Error
singularityError = SingularityError . toInteger singularityError = SingularityError . toInteger
-- Error codes are grouped by common modules or characteristics
data ErrorCode
-- PostgreSQL connection errors
= ConnectionErrorCode00
| ConnectionErrorCode01
| ConnectionErrorCode02
| ConnectionErrorCode03
-- API Request errors
| ApiRequestErrorCode00
| ApiRequestErrorCode01
| ApiRequestErrorCode02
| ApiRequestErrorCode03
| ApiRequestErrorCode04 -- no longer used (used to be mapped to ParseRequestError)
| ApiRequestErrorCode05
| ApiRequestErrorCode06
| ApiRequestErrorCode07
| ApiRequestErrorCode08
| ApiRequestErrorCode09
| ApiRequestErrorCode10
| ApiRequestErrorCode11
| ApiRequestErrorCode12
| ApiRequestErrorCode13
| ApiRequestErrorCode14
| ApiRequestErrorCode15
| ApiRequestErrorCode16
| ApiRequestErrorCode17
| ApiRequestErrorCode18
| ApiRequestErrorCode19
| ApiRequestErrorCode20
-- Schema Cache errors
| SchemaCacheErrorCode00
| SchemaCacheErrorCode01
| SchemaCacheErrorCode02
| SchemaCacheErrorCode03
| SchemaCacheErrorCode04
-- JWT authentication errors
| JWTErrorCode00
| JWTErrorCode01
| JWTErrorCode02
-- Internal errors related to the Hasql library
| InternalErrorCode00
instance JSON.ToJSON ErrorCode where
toJSON e = JSON.toJSON (buildErrorCode e)
-- New group of errors will be added at the end of all the groups and will have the next prefix in the sequence
-- New errors are added at the end of the group they belong to and will have the next code in the sequence
buildErrorCode :: ErrorCode -> Text
buildErrorCode code = "PGRST" <> case code of
ConnectionErrorCode00 -> "000"
ConnectionErrorCode01 -> "001"
ConnectionErrorCode02 -> "002"
ConnectionErrorCode03 -> "003"
ApiRequestErrorCode00 -> "100"
ApiRequestErrorCode01 -> "101"
ApiRequestErrorCode02 -> "102"
ApiRequestErrorCode03 -> "103"
ApiRequestErrorCode04 -> "104"
ApiRequestErrorCode05 -> "105"
ApiRequestErrorCode06 -> "106"
ApiRequestErrorCode07 -> "107"
ApiRequestErrorCode08 -> "108"
ApiRequestErrorCode09 -> "109"
ApiRequestErrorCode10 -> "110"
ApiRequestErrorCode11 -> "111"
ApiRequestErrorCode12 -> "112"
ApiRequestErrorCode13 -> "113"
ApiRequestErrorCode14 -> "114"
ApiRequestErrorCode15 -> "115"
ApiRequestErrorCode16 -> "116"
ApiRequestErrorCode17 -> "117"
ApiRequestErrorCode18 -> "118"
ApiRequestErrorCode19 -> "119"
ApiRequestErrorCode20 -> "120"
SchemaCacheErrorCode00 -> "200"
SchemaCacheErrorCode01 -> "201"
SchemaCacheErrorCode02 -> "202"
SchemaCacheErrorCode03 -> "203"
SchemaCacheErrorCode04 -> "204"
JWTErrorCode00 -> "300"
JWTErrorCode01 -> "301"
JWTErrorCode02 -> "302"
InternalErrorCode00 -> "X00"
+30
View File
@@ -0,0 +1,30 @@
{-|
Module : PostgREST.Logger
Description : Wai Middleware to log requests to stdout.
-}
module PostgREST.Logger (middleware) where
import qualified Network.Wai as Wai
import qualified Network.Wai.Middleware.RequestLogger as Wai
import Network.HTTP.Types.Status (status400, status500)
import System.IO.Unsafe (unsafePerformIO)
import qualified PostgREST.Auth as Auth
import PostgREST.Config (LogLevel (..))
import Protolude
middleware :: LogLevel -> Wai.Middleware
middleware logLevel = case logLevel of
LogInfo -> requestLogger (const True)
LogWarn -> requestLogger (>= status400)
LogError -> requestLogger (>= status500)
LogCrit -> requestLogger (const False)
where
requestLogger filterStatus = unsafePerformIO $ Wai.mkRequestLogger Wai.defaultRequestLoggerSettings
{ Wai.outputFormat = Wai.ApacheWithSettings $
Wai.defaultApacheSettings
& Wai.setApacheRequestFilter (\_ res -> filterStatus $ Wai.responseStatus res)
& Wai.setApacheUserGetter Auth.getRole
}
+168
View File
@@ -0,0 +1,168 @@
{-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.MediaType
( MediaType(..)
, MTPlanOption (..)
, MTPlanFormat (..)
, toContentType
, toMime
, decodeMediaType
) where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BS (c2w)
import Network.HTTP.Types.Header (Header, hContentType)
import Protolude
-- | Enumeration of currently supported media types
data MediaType
= MTApplicationJSON
| MTArrayJSONStrip
| MTSingularJSON Bool
| MTGeoJSON
| MTTextCSV
| MTTextPlain
| MTTextXML
| MTOpenAPI
| MTUrlEncoded
| MTOctetStream
| MTAny
| MTOther ByteString
-- TODO MTPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes.
| MTPlan MediaType MTPlanFormat [MTPlanOption]
deriving Show
instance Eq MediaType where
MTApplicationJSON == MTApplicationJSON = True
MTArrayJSONStrip == MTArrayJSONStrip = True
MTSingularJSON x == MTSingularJSON y = x == y
MTGeoJSON == MTGeoJSON = True
MTTextCSV == MTTextCSV = True
MTTextPlain == MTTextPlain = True
MTTextXML == MTTextXML = True
MTOpenAPI == MTOpenAPI = True
MTUrlEncoded == MTUrlEncoded = True
MTOctetStream == MTOctetStream = True
MTAny == MTAny = True
MTOther x == MTOther y = x == y
MTPlan{} == MTPlan{} = True
_ == _ = False
data MTPlanOption
= PlanAnalyze | PlanVerbose | PlanSettings | PlanBuffers | PlanWAL
deriving (Eq, Show)
data MTPlanFormat
= PlanJSON | PlanText
deriving (Eq, Show)
-- | Convert MediaType to a Content-Type HTTP Header
toContentType :: MediaType -> Header
toContentType ct = (hContentType, toMime ct <> charset)
where
charset = case ct of
MTOctetStream -> mempty
MTOther _ -> mempty
_ -> "; charset=utf-8"
-- | Convert from MediaType to a ByteString representing the mime type
toMime :: MediaType -> ByteString
toMime MTApplicationJSON = "application/json"
toMime MTArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped"
toMime MTGeoJSON = "application/geo+json"
toMime MTTextCSV = "text/csv"
toMime MTTextPlain = "text/plain"
toMime MTTextXML = "text/xml"
toMime MTOpenAPI = "application/openapi+json"
toMime (MTSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped"
toMime (MTSingularJSON False) = "application/vnd.pgrst.object+json"
toMime MTUrlEncoded = "application/x-www-form-urlencoded"
toMime MTOctetStream = "application/octet-stream"
toMime MTAny = "*/*"
toMime (MTOther ct) = ct
toMime (MTPlan mt fmt opts) =
"application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <>
("; for=\"" <> toMime mt <> "\"") <>
(if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts))
toMimePlanOption :: MTPlanOption -> ByteString
toMimePlanOption PlanAnalyze = "analyze"
toMimePlanOption PlanVerbose = "verbose"
toMimePlanOption PlanSettings = "settings"
toMimePlanOption PlanBuffers = "buffers"
toMimePlanOption PlanWAL = "wal"
toMimePlanFormat :: MTPlanFormat -> ByteString
toMimePlanFormat PlanJSON = "json"
toMimePlanFormat PlanText = "text"
-- | Convert from ByteString to MediaType.
--
-- >>> decodeMediaType "application/json"
-- MTApplicationJSON
--
-- >>> decodeMediaType "application/vnd.pgrst.plan;"
-- MTPlan MTApplicationJSON PlanText []
--
-- >>> decodeMediaType "application/vnd.pgrst.plan;for=\"application/json\""
-- MTPlan MTApplicationJSON PlanText []
--
-- >>> decodeMediaType "application/vnd.pgrst.plan+json;for=\"text/csv\""
-- MTPlan MTTextCSV PlanJSON []
--
-- >>> decodeMediaType "application/vnd.pgrst.array+json;nulls=stripped"
-- MTArrayJSONStrip
--
-- >>> decodeMediaType "application/vnd.pgrst.array+json"
-- MTApplicationJSON
--
-- >>> decodeMediaType "application/vnd.pgrst.object+json;nulls=stripped"
-- MTSingularJSON True
--
-- >>> decodeMediaType "application/vnd.pgrst.object+json"
-- MTSingularJSON False
decodeMediaType :: BS.ByteString -> MediaType
decodeMediaType mt =
case BS.split (BS.c2w ';') mt of
"application/json":_ -> MTApplicationJSON
"application/geo+json":_ -> MTGeoJSON
"text/csv":_ -> MTTextCSV
"text/plain":_ -> MTTextPlain
"text/xml":_ -> MTTextXML
"application/openapi+json":_ -> MTOpenAPI
"application/x-www-form-urlencoded":_ -> MTUrlEncoded
"application/octet-stream":_ -> MTOctetStream
"application/vnd.pgrst.plan":rest -> getPlan PlanText rest
"application/vnd.pgrst.plan+text":rest -> getPlan PlanText rest
"application/vnd.pgrst.plan+json":rest -> getPlan PlanJSON rest
"application/vnd.pgrst.object+json":rest -> checkSingularNullStrip rest
"application/vnd.pgrst.object":rest -> checkSingularNullStrip rest
"application/vnd.pgrst.array+json":rest -> checkArrayNullStrip rest
"*/*":_ -> MTAny
other:_ -> MTOther other
_ -> MTAny
where
checkArrayNullStrip ["nulls=stripped"] = MTArrayJSONStrip
checkArrayNullStrip _ = MTApplicationJSON
checkSingularNullStrip ["nulls=stripped"] = MTSingularJSON True
checkSingularNullStrip _ = MTSingularJSON False
getPlan fmt rest =
let
opts = BS.split (BS.c2w '|') $ fromMaybe mempty (BS.stripPrefix "options=" =<< find (BS.isPrefixOf "options=") rest)
inOpts str = str `elem` opts
dropAround p = BS.dropWhile p . BS.dropWhileEnd p
mtFor = fromMaybe MTApplicationJSON $ do
foundFor <- find (BS.isPrefixOf "for=") rest
strippedFor <- BS.stripPrefix "for=" foundFor
pure . decodeMediaType $ dropAround (== BS.c2w '"') strippedFor
in
MTPlan mtFor fmt $
[PlanAnalyze | inOpts "analyze" ] ++
[PlanVerbose | inOpts "verbose" ] ++
[PlanSettings | inOpts "settings"] ++
[PlanBuffers | inOpts "buffers" ] ++
[PlanWAL | inOpts "wal" ]
-204
View File
@@ -1,204 +0,0 @@
{-|
Module : PostgREST.Middleware
Description : Sets CORS policy. Also the PostgreSQL GUCs, role, search_path and pre-request function.
-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Middleware
( runPgLocals
, pgrstFormat
, pgrstMiddleware
, defaultCorsPolicy
, corsPolicy
, optionalRollback
) where
import qualified Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as M
import qualified Data.Text as T
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 qualified Network.Wai.Logger as Wai
import qualified Network.Wai.Middleware.Cors as Wai
import qualified Network.Wai.Middleware.Gzip as Wai
import qualified Network.Wai.Middleware.RequestLogger as Wai
import qualified Network.Wai.Middleware.Static as Wai
import Control.Arrow ((***))
import Data.Function (id)
import Data.List (lookup)
import Data.Scientific (FPFormat (..), formatScientific,
isInteger)
import Network.HTTP.Types.Status (Status, status400, status500,
statusCode)
import System.IO.Unsafe (unsafePerformIO)
import System.Log.FastLogger (toLogStr)
import PostgREST.Config (AppConfig (..), LogLevel (..))
import PostgREST.Config.PgVersion (PgVersion (..), pgVersion140)
import PostgREST.Error (Error, errorResponseFor)
import PostgREST.GucHeader (addHeadersIfNotIncluded)
import PostgREST.Query.SqlFragment (fromQi, intercalateSnippet,
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 -> M.HashMap Text JSON.Value ->
(ApiRequest -> ExceptT Error SQL.Transaction Wai.Response) ->
ApiRequest -> ByteString -> PgVersion -> ExceptT Error SQL.Transaction Wai.Response
runPgLocals conf claims 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)
claimsWithRole =
let anon = JSON.String . toS $ configDbAnonRole conf in -- role claim defaults to anon if not specified in jwt
M.union claims (M.singleton "role" anon)
claimsSql = if usesLegacyGucs
then setConfigLocal "request.jwt.claim." <$> [(toUtf8 c, toUtf8 $ unquoted v) | (c,v) <- M.toList claimsWithRole]
else [setConfigLocal mempty ("request.jwt.claims", LBS.toStrict $ JSON.encode claimsWithRole)]
roleSql = maybeToList $ (\x -> setConfigLocal mempty ("role", toUtf8 $ unquoted x)) <$> M.lookup "role" claimsWithRole
appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings conf)
searchPathSql =
let schemas = T.intercalate ", " (iSchema req : configDbExtraSearchPath conf) in
setConfigLocal mempty ("search_path", toUtf8 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
-- | Log in apache format. Only requests that have a status greater than minStatus are logged.
-- | There's no way to filter logs in the apache format on wai-extra: https://hackage.haskell.org/package/wai-extra-3.0.29.2/docs/Network-Wai-Middleware-RequestLogger.html#t:OutputFormat.
-- | So here we copy wai-logger apacheLogStr function: https://github.com/kazu-yamamoto/logger/blob/a4f51b909a099c51af7a3f75cf16e19a06f9e257/wai-logger/Network/Wai/Logger/Apache.hs#L45
-- | TODO: Add the ability to filter apache logs on wai-extra and remove this function.
pgrstFormat :: Status -> Wai.OutputFormatter
pgrstFormat minStatus date req status responseSize =
if status < minStatus
then mempty
else toLogStr (getSourceFromSocket req)
<> " - - ["
<> toLogStr date
<> "] \""
<> toLogStr (Wai.requestMethod req)
<> " "
<> toLogStr (Wai.rawPathInfo req <> Wai.rawQueryString req)
<> " "
<> toLogStr (show (Wai.httpVersion req)::Text)
<> "\" "
<> toLogStr (show (statusCode status)::Text)
<> " "
<> toLogStr (maybe "-" show responseSize::Text)
<> " \""
<> toLogStr (fromMaybe mempty $ Wai.requestHeaderReferer req)
<> "\" \""
<> toLogStr (fromMaybe mempty $ Wai.requestHeaderUserAgent req)
<> "\"\n"
where
getSourceFromSocket = BS.pack . Wai.showSockAddr . Wai.remoteHost
pgrstMiddleware :: LogLevel -> Wai.Application -> Wai.Application
pgrstMiddleware logLevel =
logger
. Wai.cors corsPolicy
. Wai.staticPolicy (Wai.only [("favicon.ico", "static/favicon.ico")])
where
logger = case logLevel of
LogCrit -> id
LogError -> unsafePerformIO $ Wai.mkRequestLogger Wai.def { Wai.outputFormat = Wai.CustomOutputFormat $ pgrstFormat status500}
LogWarn -> unsafePerformIO $ Wai.mkRequestLogger Wai.def { Wai.outputFormat = Wai.CustomOutputFormat $ pgrstFormat status400}
LogInfo -> Wai.logStdout
defaultCorsPolicy :: Wai.CorsResourcePolicy
defaultCorsPolicy = Wai.CorsResourcePolicy Nothing
["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing
(Just $ 60*60*24) False False True
-- | CORS policy to be used in by Wai Cors middleware
corsPolicy :: Wai.Request -> Maybe Wai.CorsResourcePolicy
corsPolicy req = case lookup "origin" headers of
Just origin -> Just defaultCorsPolicy {
Wai.corsOrigins = Just ([origin], True)
, Wai.corsRequestHeaders = "Authentication" : accHeaders
, Wai.corsExposedHeaders = Just [
"Content-Encoding", "Content-Location", "Content-Range", "Content-Type"
, "Date", "Location", "Server", "Transfer-Encoding", "Range-Unit"
]
}
Nothing -> Nothing
where
headers = Wai.requestHeaders req
accHeaders = case lookup "access-control-request-headers" headers of
Just hdrs -> map (CI.mk . BS.strip) $ BS.split ',' hdrs
Nothing -> []
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 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 . M.fromList . arrayByteStringToText
arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)]
arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal
+875
View File
@@ -0,0 +1,875 @@
{-|
Module : PostgREST.Plan
Description : PostgREST Request Planner
This module is in charge of building an intermediate
representation between the HTTP request and the
final resulting SQL query.
A query tree is built in case of resource embedding. By inferring the
relationship between tables, join conditions are added for every embedded
resource.
-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Plan
( wrappedReadPlan
, mutateReadPlan
, callReadPlan
, WrappedReadPlan(..)
, MutateReadPlan(..)
, CallReadPlan(..)
, inspectPlanTxMode
) where
import qualified Data.ByteString.Lazy as LBS
import qualified Data.HashMap.Strict as HM
import qualified Data.HashMap.Strict.InsOrd as HMI
import qualified Data.List as L
import qualified Data.Set as S
import qualified PostgREST.SchemaCache.Routine as Routine
import Data.Either.Combinators (mapLeft, mapRight)
import Data.List (delete)
import Data.Tree (Tree (..))
import PostgREST.ApiRequest (Action (..),
ApiRequest (..),
InvokeMethod (..),
Mutation (..),
Payload (..))
import PostgREST.Config (AppConfig (..))
import PostgREST.Error (Error (..))
import PostgREST.MediaType (MediaType (..))
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.Relationship (Cardinality (..),
Junction (..),
Relationship (..),
RelationshipsMap,
relIsToOne)
import PostgREST.SchemaCache.Representations (DataRepresentation (..),
RepresentationsMap)
import PostgREST.SchemaCache.Routine (ResultAggregate (..),
Routine (..),
RoutineMap,
RoutineParam (..),
funcReturnsCompositeAlias,
funcReturnsScalar,
funcReturnsSetOfScalar)
import PostgREST.SchemaCache.Table (Column (..), Table (..),
TablesMap,
tableColumnsList,
tablePKCols)
import PostgREST.ApiRequest.Preferences
import PostgREST.ApiRequest.Types
import PostgREST.Plan.CallPlan
import PostgREST.Plan.MutatePlan
import PostgREST.Plan.ReadPlan as ReadPlan
import PostgREST.Plan.Types
import qualified Hasql.Transaction.Sessions as SQL
import qualified PostgREST.ApiRequest.QueryParams as QueryParams
import Protolude hiding (from)
-- $setup
-- Setup for doctests
-- >>> import Data.Ranged.Ranges (fullRange)
data WrappedReadPlan = WrappedReadPlan {
wrReadPlan :: ReadPlanTree
, wrTxMode :: SQL.Mode
, wrResAgg :: ResultAggregate
}
data MutateReadPlan = MutateReadPlan {
mrReadPlan :: ReadPlanTree
, mrMutatePlan :: MutatePlan
, mrTxMode :: SQL.Mode
, mrResAgg :: ResultAggregate
}
data CallReadPlan = CallReadPlan {
crReadPlan :: ReadPlanTree
, crCallPlan :: CallPlan
, crTxMode :: SQL.Mode
, crProc :: Routine
, crResAgg :: ResultAggregate
}
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error WrappedReadPlan
wrappedReadPlan identifier conf sCache apiRequest = do
rPlan <- readPlan identifier conf sCache apiRequest
binField <- mapLeft ApiRequestError $ binaryField conf (iAcceptMediaType apiRequest) Nothing rPlan
return $ WrappedReadPlan rPlan SQL.Read $ mediaToAggregate (iAcceptMediaType apiRequest) binField apiRequest
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error MutateReadPlan
mutateReadPlan mutation apiRequest identifier conf sCache = do
rPlan <- readPlan identifier conf sCache apiRequest
binField <- mapLeft ApiRequestError $ binaryField conf (iAcceptMediaType apiRequest) Nothing rPlan
mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan
return $ MutateReadPlan rPlan mPlan SQL.Write $ mediaToAggregate (iAcceptMediaType apiRequest) binField apiRequest
callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CallReadPlan
callReadPlan identifier conf sCache apiRequest invMethod = do
let paramKeys = case invMethod of
InvGet -> S.fromList $ fst <$> qsParams'
InvHead -> S.fromList $ fst <$> qsParams'
InvPost -> iColumns apiRequest
proc@Function{..} <- mapLeft ApiRequestError $
findProc identifier paramKeys (preferParameters == Just SingleObject) (dbRoutines sCache) (iContentMediaType apiRequest) (invMethod == InvPost)
let relIdentifier = QualifiedIdentifier pdSchema (fromMaybe pdName $ Routine.funcTableName proc) -- done so a set returning function can embed other relations
rPlan <- readPlan relIdentifier conf sCache apiRequest
let args = case (invMethod, iContentMediaType apiRequest) of
(InvGet, _) -> jsonRpcParams proc qsParams'
(InvHead, _) -> jsonRpcParams proc qsParams'
(InvPost, MTUrlEncoded) -> maybe mempty (jsonRpcParams proc . payArray) $ iPayload apiRequest
(InvPost, _) -> maybe mempty payRaw $ iPayload apiRequest
txMode = case (invMethod, pdVolatility) of
(InvGet, _) -> SQL.Read
(InvHead, _) -> SQL.Read
(InvPost, Routine.Stable) -> SQL.Read
(InvPost, Routine.Immutable) -> SQL.Read
(InvPost, Routine.Volatile) -> SQL.Write
cPlan = callPlan proc apiRequest paramKeys args rPlan
binField <- mapLeft ApiRequestError $ binaryField conf (iAcceptMediaType apiRequest) (Just proc) rPlan
return $ CallReadPlan rPlan cPlan txMode proc $ mediaToAggregate (iAcceptMediaType apiRequest) binField apiRequest
where
Preferences{..} = iPreferences apiRequest
qsParams' = QueryParams.qsParams (iQueryParams apiRequest)
{-|
Search a pg proc by matching name and arguments keys to parameters. Since a function can be overloaded,
the name is not enough to find it. An overloaded function can have a different volatility or even a different return type.
-}
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> RoutineMap -> MediaType -> Bool -> Either ApiRequestError Routine
findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPost =
case matchProc of
([], []) -> 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
([], [proc]) -> Right proc
([], procs) -> Left $ AmbiguousRpc (toList procs)
-- Matches the functions with named arguments
([proc], _) -> Right proc
(procs, _) -> Left $ AmbiguousRpc (toList procs)
where
matchProc = overloadedProcPartition lookupProcName
-- First find the proc by name
lookupProcName = HM.lookupDefault mempty qi allProcs
-- The partition obtained has the form (overloadedProcs,fallbackProcs)
-- where fallbackProcs are functions with a single unnamed parameter
overloadedProcPartition = foldr select ([],[])
select proc ~(ts,fs)
| matchesParams proc = (proc:ts,fs)
| hasSingleUnnamedParam proc = (ts,proc:fs)
| otherwise = (ts,fs)
-- If the function is called with post and has a single unnamed parameter
-- it can be called depending on content type and the parameter type
hasSingleUnnamedParam Function{pdParams=[RoutineParam{ppType}]} = isInvPost && case (contentMediaType, ppType) of
(MTApplicationJSON, "json") -> True
(MTApplicationJSON, "jsonb") -> True
(MTTextPlain, "text") -> True
(MTTextXML, "xml") -> True
(MTOctetStream, "bytea") -> True
_ -> False
hasSingleUnnamedParam _ = False
matchesParams proc =
let
params = pdParams proc
firstType = (ppType <$> headMay params)
in
-- exceptional case for Prefer: params=single-object
if paramsAsSingleObject
then length params == 1 && (firstType == Just "json" || firstType == Just "jsonb")
-- If the function has no parameters, the arguments keys must be empty as well
else if null params
then null argumentsKeys && not (isInvPost && contentMediaType `elem` [MTOctetStream, MTTextPlain, MTTextXML])
-- A function has optional and required parameters. Optional parameters have a default value and
-- don't require arguments for the function to be executed, required parameters must have an argument present.
else case L.partition ppReq params of
-- If the function only has required parameters, the arguments keys must match those parameters
(reqParams, []) -> argumentsKeys == S.fromList (ppName <$> reqParams)
-- If the function only has optional parameters, the arguments keys can match none or any of them(a subset)
([], optParams) -> argumentsKeys `S.isSubsetOf` S.fromList (ppName <$> optParams)
-- 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.
(reqParams, optParams) -> argumentsKeys `S.difference` S.fromList (ppName <$> optParams) == S.fromList (ppName <$> reqParams)
inspectPlanTxMode :: SQL.Mode
inspectPlanTxMode = SQL.Read
-- | During planning we need to resolve Field -> CoercibleField (finding the context specific target type and map function).
-- | ResolverContext facilitates this without the need to pass around a laundry list of parameters.
data ResolverContext = ResolverContext
{ tables :: TablesMap
, representations :: RepresentationsMap
, qi :: QualifiedIdentifier -- ^ The table we're currently attending; changes as we recurse into joins etc.
, outputType :: Text -- ^ The output type for the response payload; e.g. "csv", "json", "binary".
}
resolveColumnField :: Column -> CoercibleField
resolveColumnField col = CoercibleField (colName col) mempty False (colNominalType col) Nothing (colDefault col)
resolveTableFieldName :: Table -> FieldName -> CoercibleField
resolveTableFieldName table fieldName =
fromMaybe (unknownField fieldName []) $ HMI.lookup fieldName (tableColumns table) >>=
Just . resolveColumnField
resolveTableField :: Table -> Field -> CoercibleField
resolveTableField table (fieldName, []) = resolveTableFieldName table fieldName
resolveTableField table (fieldName, jp) =
case resolveTableFieldName table fieldName of
-- types that are already json/jsonb don't need to be converted with `to_jsonb` for using arrow operators `data->attr`
-- this prevents indexes not applying https://github.com/PostgREST/postgrest/issues/2594
cf@CoercibleField{cfIRType="json"} -> cf{cfJsonPath=jp}
cf@CoercibleField{cfIRType="jsonb"} -> cf{cfJsonPath=jp}
-- other types will get converted `to_jsonb(col)->attr`
cf -> cf{cfJsonPath=jp, cfToJson=True}
-- | Resolve a type within the context based on the given field name and JSON path. Although there are situations where failure to resolve a field is considered an error (see `resolveOrError`), there are also situations where we allow it (RPC calls). If it should be an error and `resolveOrError` doesn't fit, ensure to check the `cfIRType` isn't empty.
resolveTypeOrUnknown :: ResolverContext -> Field -> CoercibleField
resolveTypeOrUnknown ResolverContext{..} field@(fn, jp) =
fromMaybe (unknownField fn jp) $ HM.lookup qi tables >>=
Just . flip resolveTableField field
-- | Install any pre-defined data representation from source to target to coerce this reference.
--
-- Note that we change the IR type here. This might seem unintuitive. The short of it is that for a CoercibleField without a transformer, input type == output type. A transformer maps from a -> b, so by definition the input type will be a and the output type b after. And cfIRType is the *input* type.
--
-- It might feel odd that once a transformer is added we 'forget' the target type (because now a /= b). You might also note there's no obvious way to stack transforms (even if there was a stack, you erased what type you're working with so it's awkward). Alas as satisfying as it would be to engineer a layered mapping system with full type information, we just don't need it.
withTransformer :: ResolverContext -> Text -> Text -> CoercibleField -> CoercibleField
withTransformer ResolverContext{representations} sourceType targetType field =
fromMaybe field $ HM.lookup (sourceType, targetType) representations >>=
(\fieldRepresentation -> Just field{cfIRType=sourceType, cfTransform=Just (drFunction fieldRepresentation)})
-- | Map the intermediate representation type to the output type, if available.
withOutputFormat :: ResolverContext -> CoercibleField -> CoercibleField
withOutputFormat ctx@ResolverContext{outputType} field@CoercibleField{cfIRType} = withTransformer ctx cfIRType outputType field
-- | Map text into the intermediate representation type, if available.
withTextParse :: ResolverContext -> CoercibleField -> CoercibleField
withTextParse ctx field@CoercibleField{cfIRType} = withTransformer ctx "text" cfIRType field
-- | Map json into the intermediate representation type, if available.
withJsonParse :: ResolverContext -> CoercibleField -> CoercibleField
withJsonParse ctx field@CoercibleField{cfIRType} = withTransformer ctx "json" cfIRType field
-- | Map the intermediate representation type to the output type defined by the resolver context (normally json), if available.
resolveOutputField :: ResolverContext -> Field -> CoercibleField
resolveOutputField ctx field = withOutputFormat ctx $ resolveTypeOrUnknown ctx field
-- | Map the query string format of a value (text) into the intermediate representation type, if available.
resolveQueryInputField :: ResolverContext -> Field -> CoercibleField
resolveQueryInputField ctx field = withTextParse ctx $ resolveTypeOrUnknown ctx field
-- | Builds the ReadPlan tree on a number of stages.
-- | Adds filters, order, limits on its respective nodes.
-- | Adds joins conditions obtained from resource embedding.
readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows} SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest =
let
-- JSON output format hardcoded for now. In the future we might want to support other output mappings such as CSV.
ctx = ResolverContext dbTables dbRepresentations qi "json"
in
mapLeft ApiRequestError $
treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
addNullEmbedFilters =<<
validateSpreadEmbeds =<<
addRelatedOrders =<<
addDataRepresentationAliases =<<
expandStarsForDataRepresentations ctx =<<
addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<<
addLogicTrees ctx apiRequest =<<
addRanges apiRequest =<<
addOrders ctx apiRequest =<<
addFilters ctx apiRequest (initReadRequest ctx $ QueryParams.qsSelect $ iQueryParams apiRequest)
-- Build the initial read plan tree
initReadRequest :: ResolverContext -> [Tree SelectItem] -> ReadPlanTree
initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} =
foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} []
where
rootDepth = 0
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing False rootDepth
treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree
treeEntry depth (Node si fldForest) (Node q rForest) =
let nxtDepth = succ depth in
case si of
SelectRelation{..} ->
Node q $
foldr (treeEntry nxtDepth)
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relAlias=selAlias, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth} [])
fldForest:rForest
SpreadRelation{..} ->
Node q $
foldr (treeEntry nxtDepth)
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relIsSpread=True} [])
fldForest:rForest
SelectField{..} ->
Node q{select=(resolveOutputField ctx{qi=from q} selField, selCast, selAlias):select q} rForest
-- | Preserve the original field name if data representation is used to coerce the value.
addDataRepresentationAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
addDataRepresentationAliases rPlanTree = Right $ fmap (\rPlan@ReadPlan{select=sel} -> rPlan{select=map aliasSelectItem sel}) rPlanTree
where
aliasSelectItem :: (CoercibleField, Maybe Cast, Maybe Alias) -> (CoercibleField, Maybe Cast, Maybe Alias)
-- If there already is an alias, don't overwrite it.
aliasSelectItem (fld@(CoercibleField{cfName=fieldName, cfTransform=(Just _)}), Nothing, Nothing) = (fld, Nothing, Just fieldName)
aliasSelectItem fld = fld
knownColumnsInContext :: ResolverContext -> [Column]
knownColumnsInContext ResolverContext{..} =
fromMaybe [] $ HM.lookup qi tables >>=
Just . tableColumnsList
-- | Expand "select *" into explicit field names of the table, if necessary to apply data representations.
expandStarsForDataRepresentations :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
expandStarsForDataRepresentations ctx@ResolverContext{qi} rPlanTree = Right $ fmap expandStars rPlanTree
where
expandStars :: ReadPlan -> ReadPlan
-- When the schema is "" and the table is the source CTE, we assume the true source table is given in the from
-- alias and belongs to the request schema. See the bit in `addRels` with `newFrom = ...`.
expandStars rPlan@ReadPlan{from=(QualifiedIdentifier "" "pgrst_source"), fromAlias=(Just tblAlias)} =
expandStarsForTable ctx{qi=qi{qiName=tblAlias}} rPlan
expandStars rPlan@ReadPlan{from=fromTable} =
expandStarsForTable ctx{qi=fromTable} rPlan
expandStarsForTable :: ResolverContext -> ReadPlan -> ReadPlan
expandStarsForTable ctx@ResolverContext{representations, outputType} rplan@ReadPlan{select=selectItems} =
-- If we have a '*' select AND the target table has at least one data representation, expand.
if ("*" `elem` map (\(field, _, _) -> cfName field) selectItems) && any hasOutputRep knownColumns
then rplan{select=concatMap (expandStarSelectItem knownColumns) selectItems}
else rplan
where
knownColumns = knownColumnsInContext ctx
hasOutputRep :: Column -> Bool
hasOutputRep col = HM.member (colNominalType col, outputType) representations
expandStarSelectItem :: [Column] -> (CoercibleField, Maybe Cast, Maybe Alias) -> [(CoercibleField, Maybe Cast, Maybe Alias)]
expandStarSelectItem columns (CoercibleField{cfName="*", cfJsonPath=[]}, b, c) = map (\col -> (withOutputFormat ctx $ resolveColumnField col, b, c)) columns
expandStarSelectItem _ selectItem = [selectItem]
-- | Enforces the `max-rows` config on the result
treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
treeRestrictRange _ (ActionMutate _) request = Right request
treeRestrictRange maxRows _ request = pure $ nodeRestrictRange maxRows <$> request
where
nodeRestrictRange :: Maybe Integer -> ReadPlan -> ReadPlan
nodeRestrictRange m q@ReadPlan{range_=r} = q{range_= convertToLimitZeroRange r (restrictRange m r) }
-- 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)
-- also adds aliasing
addRels :: Schema -> Action -> RelationshipsMap -> Maybe ReadPlanTree -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,relAlias,depth} forest) =
case parentNode of
Just (Node ReadPlan{from=parentNodeQi, fromAlias=parentAlias} _) ->
let
newReadPlan = (\r ->
let newAlias = Just (qiName (relForeignTable r) <> "_" <> show depth)
aggAlias = qiName (relTable r) <> "_" <> fromMaybe relName relAlias <> "_" <> show depth in
case r of
Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r}
ComputedRelationship{} ->
rPlan{from=relForeignTable r, relToParent=Just r{relTable=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias}
_ ->
rPlan{from=relForeignTable r, relToParent=Just r, 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
Node <$> newReadPlan <*> (updateForest . hush $ Node <$> newReadPlan <*> pure forest)
Nothing -> -- root case
let
newFrom = QualifiedIdentifier mempty 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
updateForest :: Maybe ReadPlanTree -> Either ApiRequestError [ReadPlanTree]
updateForest rq = addRels schema action allRels rq `traverse` forest
getJoinConditions :: Maybe Alias -> Maybe Alias -> Relationship -> [JoinCondition]
getJoinConditions _ _ ComputedRelationship{} = []
getJoinConditions tblAlias parentAlias Relationship{relTable=qi,relForeignTable=fQi,relCardinality=card} =
case card of
M2M (Junction QualifiedIdentifier{qiName=jtn} _ _ jcols1 jcols2) ->
(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
QualifiedIdentifier{qiSchema=tSchema, qiName=tN} = qi
QualifiedIdentifier{qiName=ftN} = fQi
toJoinCondition :: Maybe Alias -> Maybe Alias -> Text -> Text -> (FieldName, FieldName) -> JoinCondition
toJoinCondition prAl newAl tb ftb (c, fc) =
let qi1 = QualifiedIdentifier tSchema ftb
qi2 = QualifiedIdentifier tSchema tb in
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) newAl, fc)
(maybe qi2 (QualifiedIdentifier mempty) prAl, c)
-- Finds a relationship between an origin and a target in the request:
-- /origin?select=target(*) If more than one relationship is found then the
-- request is ambiguous and we return an error. In that case the request can
-- be disambiguated by adding precision to the target or by using a hint:
-- /origin?select=target!hint(*). The origin can be a table or view.
findRel :: Schema -> RelationshipsMap -> NodeName -> NodeName -> Maybe Hint -> Either ApiRequestError Relationship
findRel schema allRels origin target hint =
case rels of
[] -> Left $ NoRelBetween origin target hint schema allRels
[r] -> Right r
rs -> Left $ AmbiguousRelBetween origin target rs
where
matchFKSingleCol hint_ card = case card of
O2M _ [(col, _)] -> hint_ == col
M2O _ [(col, _)] -> hint_ == col
O2O _ [(col, _)] -> hint_ == col
_ -> False
matchFKRefSingleCol hint_ card = case card of
O2M _ [(_, fCol)] -> hint_ == fCol
M2O _ [(_, fCol)] -> hint_ == fCol
O2O _ [(_, fCol)] -> hint_ == fCol
_ -> False
matchConstraint tar card = case card of
O2M cons _ -> tar == cons
M2O cons _ -> tar == cons
O2O cons _ -> tar == cons
_ -> False
matchJunction hint_ card = case card of
M2M Junction{junTable} -> hint_ == qiName junTable
_ -> False
isM2O card = case card of
M2O _ _ -> True
_ -> False
isO2M card = case card of
O2M _ _ -> True
_ -> False
rels = filter (\case
ComputedRelationship{relFunction} -> target == qiName relFunction
Relationship{..} ->
-- In a self-relationship we have a single foreign key but two relationships with different cardinalities: M2O/O2M. For disambiguation, we use the convention of getting:
-- TODO: handle one-to-one and many-to-many self-relationships
if relIsSelf
then case hint of
Nothing ->
-- The O2M by using the table name in the target
target == qiName relForeignTable && isO2M relCardinality -- /family_tree?select=children:family_tree(*)
||
-- The M2O by using the column name in the target
matchFKSingleCol target relCardinality && isM2O relCardinality -- /family_tree?select=parent(*)
Just hnt ->
-- /organizations?select=auditees:organizations!auditor(*)
target == qiName relForeignTable && isO2M relCardinality
&& matchFKRefSingleCol hnt relCardinality -- auditor
else case hint of
-- DEPRECATED(remove after 2 major releases since v11.1.0): remove target
-- target = table / view / constraint / column-from-origin (constraint/column-from-origin can only come from tables https://github.com/PostgREST/postgrest/issues/2277)
-- DEPRECATED(remove after 2 major releases since v11.1.0): remove hint as table/view/columns and only leave it as constraint
-- hint = table / view / constraint / column-from-origin / column-from-target (hint can take table / view values to aid in finding the junction in an m2m relationship)
Nothing ->
-- /projects?select=clients(*)
target == qiName relForeignTable -- clients
||
-- /projects?select=projects_client_id_fkey(*)
matchConstraint target relCardinality -- projects_client_id_fkey
&& not relFTableIsView
||
-- /projects?select=client_id(*)
matchFKSingleCol target relCardinality -- client_id
&& not relFTableIsView
Just hnt ->
-- /projects?select=clients(*)
target == qiName relForeignTable -- clients
&& (
-- /projects?select=clients!projects_client_id_fkey(*)
matchConstraint hnt relCardinality || -- projects_client_id_fkey
-- /projects?select=clients!client_id(*) or /projects?select=clients!id(*)
matchFKSingleCol hnt relCardinality || -- client_id
matchFKRefSingleCol hnt relCardinality || -- id
-- /users?select=tasks!users_tasks(*) many-to-many between users and tasks
matchJunction hnt relCardinality -- users_tasks
)
) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier schema origin, schema) allRels
addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addFilters ctx ApiRequest{..} rReq =
foldr addFilterToNode (Right rReq) flts
where
QueryParams.QueryParams{..} = iQueryParams
flts =
case iAction of
ActionInvoke _ -> qsFilters
ActionRead _ -> qsFilters
_ -> qsFiltersNotRoot
addFilterToNode :: (EmbedPath, Filter) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
addFilterToNode =
updateNode (\flt (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=addFilterToLogicForest (resolveFilter ctx{qi=fromTable} flt) lf} f)
addOrders :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addOrders ctx ApiRequest{..} rReq =
case iAction of
ActionMutate _ -> Right rReq
_ -> foldr addOrderToNode (Right rReq) qsOrder
where
QueryParams.QueryParams{..} = iQueryParams
addOrderToNode :: (EmbedPath, [OrderTerm]) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
addOrderToNode = updateNode (\o (Node q f) -> Node q{order=resolveOrder ctx <$> o} f)
resolveOrder :: ResolverContext -> OrderTerm -> CoercibleOrderTerm
resolveOrder _ (OrderRelationTerm a b c d) = CoercibleOrderRelationTerm a b c d
resolveOrder ctx (OrderTerm fld dir nulls) = CoercibleOrderTerm (resolveTypeOrUnknown ctx fld) dir nulls
-- Validates that the related resource on the order is an embedded resource,
-- e.g. if `clients` is inside the `select` in /projects?order=clients(id)&select=*,clients(*),
-- and if it's a to-one relationship, it adds the right alias to the OrderRelationTerm so the generated query can succeed.
addRelatedOrders :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
newOrder <- newRelOrder `traverse` order
Node rp{order=newOrder} <$> addRelatedOrders `traverse` forest
where
newRelOrder cot@CoercibleOrderTerm{} = Right cot
newRelOrder cot@CoercibleOrderRelationTerm{coRelation} =
let foundRP = rootLabel <$> find (\(Node ReadPlan{relName, relAlias} _) -> coRelation == fromMaybe relName relAlias) forest in
case foundRP of
Just ReadPlan{relName,relAlias,relAggAlias,relToParent} ->
let isToOne = relIsToOne <$> relToParent
name = fromMaybe relName relAlias in
if isToOne == Just True
then Right $ cot{coRelation=relAggAlias}
else Left $ RelatedOrderNotToOne (qiName from) name
Nothing ->
Left $ NotEmbedded coRelation
-- | Searches for null filters on embeds, e.g. `projects=not.is.null` on `GET /clients?select=*,projects(*)&projects=not.is.null`
--
-- (It doesn't err but uses an Either ApiRequestError type so it can combine with the other functions that modify the read plan tree)
--
-- Setup:
--
-- >>> let nullOp = OpExpr True (Is TriNull)
-- >>> let nonNullOp = OpExpr False (Is TriNull)
-- >>> let notEqOp = OpExpr True (Op OpNotEqual "val")
-- >>> :{
-- -- this represents the `projects(*)` part on `/clients?select=*,projects(*)`
-- let
-- subForestPlan =
-- [
-- Node {
-- rootLabel = ReadPlan {
-- select = [], -- there will be fields at this stage but we just omit them for brevity
-- from = QualifiedIdentifier {qiSchema = "test", qiName = "projects"},
-- fromAlias = Just "projects_1", where_ = [], order = [], range_ = fullRange,
-- relName = "projects",
-- relToParent = Nothing,
-- relJoinConds = [],
-- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relIsSpread = False, depth = 1
-- },
-- subForest = []
-- }
-- ]
-- :}
--
-- >>> :{
-- -- this represents the full URL `/clients?select=*,projects(*)&projects=not.is.null`, if subForst takes the above subForestPlan and nullOp
-- let
-- readPlanTree op subForst =
-- Node {
-- rootLabel = ReadPlan {
-- select = [], -- there will be fields at this stage but we just omit them for brevity
-- from = QualifiedIdentifier { qiSchema = "test", qiName = "clients"},
-- fromAlias = Nothing,
-- where_ = [
-- CoercibleStmnt (
-- CoercibleFilter {
-- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfIRType = "", cfTransform = Nothing, cfDefault = Nothing},
-- opExpr = op
-- }
-- )
-- ],
-- order = [], range_ = fullRange, relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing,
-- relJoinType = Nothing, relIsSpread = False, depth = 0
-- },
-- subForest = subForst
-- }
-- :}
--
-- Don't do anything to the filter if there's no embedding (a subtree) on projects. Assume it's a normal filter.
--
-- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nullOp [])
-- Right [CoercibleStmnt (CoercibleFilter {field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson = False, cfIRType = "", cfTransform = Nothing, cfDefault = Nothing}, opExpr = OpExpr True (Is TriNull)})]
--
-- If there's an embedding on projects, then change the filter to use the internal aggregate name (`clients_projects_1`) so the filter can succeed later.
--
-- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nullOp subForestPlan)
-- Right [CoercibleStmnt (CoercibleFilterNullEmbed True "clients_projects_1")]
--
-- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nonNullOp subForestPlan)
-- Right [CoercibleStmnt (CoercibleFilterNullEmbed False "clients_projects_1")]
addNullEmbedFilters :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
addNullEmbedFilters (Node rp@ReadPlan{where_=curLogic} forest) = do
let forestReadPlans = rootLabel <$> forest
newLogic <- newNullFilters forestReadPlans `traverse` curLogic
Node rp{ReadPlan.where_= newLogic} <$> (addNullEmbedFilters `traverse` forest)
where
newNullFilters :: [ReadPlan] -> CoercibleLogicTree -> Either ApiRequestError CoercibleLogicTree
newNullFilters rPlans = \case
(CoercibleExpr b lOp trees) ->
CoercibleExpr b lOp <$> (newNullFilters rPlans `traverse` trees)
flt@(CoercibleStmnt (CoercibleFilter (CoercibleField fld [] _ _ _ _) opExpr)) ->
let foundRP = find (\ReadPlan{relName, relAlias} -> fld == fromMaybe relName relAlias) rPlans in
case (foundRP, opExpr) of
(Just ReadPlan{relAggAlias}, OpExpr b (Is TriNull)) -> Right $ CoercibleStmnt $ CoercibleFilterNullEmbed b relAggAlias
_ -> Right flt
flt@(CoercibleStmnt _) ->
Right flt
addRanges :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addRanges ApiRequest{..} rReq =
case iAction of
ActionMutate _ -> Right rReq
_ -> foldr addRangeToNode (Right rReq) =<< ranges
where
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
ranges = first QueryParamError $ QueryParams.pRequestRange `traverse` HM.toList iRange
addRangeToNode :: (EmbedPath, NonnegRange) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
addRangeToNode = updateNode (\r (Node q f) -> Node q{range_=r} f)
addLogicTrees :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addLogicTrees ctx ApiRequest{..} rReq =
foldr addLogicTreeToNode (Right rReq) qsLogic
where
QueryParams.QueryParams{..} = iQueryParams
addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f)
resolveLogicTree :: ResolverContext -> LogicTree -> CoercibleLogicTree
resolveLogicTree ctx (Stmnt flt) = CoercibleStmnt $ resolveFilter ctx flt
resolveLogicTree ctx (Expr b op lts) = CoercibleExpr b op (map (resolveLogicTree ctx) lts)
resolveFilter :: ResolverContext -> Filter -> CoercibleFilter
resolveFilter ctx (Filter fld opExpr) = CoercibleFilter{field=resolveQueryInputField ctx fld, opExpr=opExpr}
-- Validates that spread embeds are only done on to-one relationships
validateSpreadEmbeds :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
validateSpreadEmbeds (Node rp@ReadPlan{relToParent=Nothing} forest) = Node rp <$> validateSpreadEmbeds `traverse` forest
validateSpreadEmbeds (Node rp@ReadPlan{relIsSpread,relToParent=Just rel,relName} forest) = do
validRP <- if relIsSpread && not (relIsToOne rel)
then Left $ SpreadNotToOne (qiName $ relTable rel) relName -- TODO using relTable is not entirely right because ReadPlan might have an alias, need to store the parent alias on ReadPlan
else Right rp
Node validRP <$> validateSpreadEmbeds `traverse` forest
-- Find a Node of the Tree and apply a function to it
updateNode :: (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
updateNode f ([], a) rr = f a <$> rr
updateNode _ _ (Left e) = Left e
updateNode f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) =
case findNode of
Nothing -> Left $ NotEmbedded targetNodeName
Just target ->
(\node -> Node rootNode $ node : delete target forest) <$>
updateNode f (remainingPath, a) (Right target)
where
findNode :: Maybe ReadPlanTree
findNode = find (\(Node ReadPlan{relName, relAlias} _) -> relName == targetNodeName || relAlias == Just targetNodeName) forest
mutatePlan :: Mutation -> QualifiedIdentifier -> ApiRequest -> SchemaCache -> ReadPlanTree -> Either Error MutatePlan
mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{dbTables, dbRepresentations} readReq = mapLeft ApiRequestError $
case mutation of
MutationCreate ->
mapRight (\typedColumns -> Insert qi typedColumns body ((,) <$> preferResolution <*> Just confCols) [] returnings pkCols applyDefaults) typedColumnsOrError
MutationUpdate ->
mapRight (\typedColumns -> Update qi typedColumns body combinedLogic iTopLevelRange rootOrder returnings applyDefaults) typedColumnsOrError
MutationSingleUpsert ->
if null qsLogic &&
qsFilterFields == S.fromList pkCols &&
not (null (S.fromList pkCols)) &&
all (\case
Filter _ (OpExpr False (OpQuant OpEqual Nothing _)) -> True
_ -> False) qsFiltersRoot
then mapRight (\typedColumns -> Insert qi typedColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings mempty False) typedColumnsOrError
else
Left InvalidFilters
MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings
where
ctx = ResolverContext dbTables dbRepresentations qi "json"
confCols = fromMaybe pkCols qsOnConflict
QueryParams.QueryParams{..} = iQueryParams
returnings =
if preferRepresentation == Just None || isNothing preferRepresentation
then []
else inferColsEmbedNeeds readReq pkCols
tbl = HM.lookup qi dbTables
pkCols = maybe mempty tablePKCols tbl
logic = map (resolveLogicTree ctx . snd) qsLogic
rootOrder = resolveOrder ctx <$> maybe [] snd (find (\(x, _) -> null x) qsOrder)
combinedLogic = foldr (addFilterToLogicForest . resolveFilter ctx) logic qsFiltersRoot
body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates)
applyDefaults = preferMissing == Just ApplyDefaults
typedColumnsOrError = resolveOrError ctx tbl `traverse` S.toList iColumns
resolveOrError :: ResolverContext -> Maybe Table -> FieldName -> Either ApiRequestError CoercibleField
resolveOrError _ Nothing _ = Left NotFound
resolveOrError ctx (Just table) field =
case resolveTableFieldName table field of
CoercibleField{cfIRType=""} -> Left $ ColumnNotFound (tableName table) field
cf -> Right $ withJsonParse ctx cf
callPlan :: Routine -> ApiRequest -> S.Set FieldName -> LBS.ByteString -> ReadPlanTree -> CallPlan
callPlan proc ApiRequest{iPreferences=Preferences{..}} paramKeys args readReq = FunctionCall {
funCQi = QualifiedIdentifier (pdSchema proc) (pdName proc)
, funCParams = callParams
, funCArgs = Just args
, funCScalar = funcReturnsScalar proc
, funCSetOfScalar = funcReturnsSetOfScalar proc
, funCRetCompositeAlias = funcReturnsCompositeAlias proc
, funCReturning = inferColsEmbedNeeds readReq []
}
where
paramsAsSingleObject = preferParameters == Just SingleObject
specifiedParams = filter (\x -> ppName x `S.member` paramKeys)
callParams = case pdParams proc of
[prm] | paramsAsSingleObject -> OnePosParam prm
| ppName prm == mempty -> OnePosParam prm
| otherwise -> KeyParams $ specifiedParams [prm]
prms -> KeyParams $ specifiedParams prms
-- | Infers the columns needed for an embed to be successful after a mutation or a function call.
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 -
-- otherwise those would be selected and output twice
| "*" `elem` fldNames = ["*"]
| otherwise = returnings
where
fldNames = cfName . (\(f, _, _) -> f) <$> select
-- Without fkCols, when a mutatePlan to
-- /projects?select=name,clients(name) occurs, the RETURNING SQL part would
-- be `RETURNING name`(see QueryBuilder). This would make the embedding
-- fail because the following JOIN would need the "client_id" column from
-- projects. So this adds the foreign key columns to ensure the embedding
-- succeeds, result would be `RETURNING name, client_id`.
fkCols = concat $ mapMaybe (\case
Node ReadPlan{relToParent=Just Relationship{relCardinality=O2M _ cols}} _ ->
Just $ fst <$> cols
Node ReadPlan{relToParent=Just Relationship{relCardinality=M2O _ cols}} _ ->
Just $ fst <$> cols
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
hasComputedRel = isJust $ find (\case
Node ReadPlan{relToParent=Just ComputedRelationship{}} _ -> True
_ -> False
) forest
-- However if the "client_id" is present, e.g. mutatePlan to
-- /projects?select=client_id,name,clients(name) we would get `RETURNING
-- client_id, name, client_id` and then we would produce the "column
-- reference \"client_id\" is ambiguous" error from PostgreSQL. So we
-- deduplicate with Set: We are adding the primary key columns as well to
-- make sure, that a proper location header can always be built for
-- INSERT/POST
returnings =
if not hasComputedRel
then S.toList . S.fromList $ fldNames ++ fkCols ++ pkCols
else ["*"] -- on computed relationships we cannot know the required columns for an embedding to succeed, so we just return all
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
-- they are later concatenated with AND in the QueryBuilder
addFilterToLogicForest :: CoercibleFilter -> [CoercibleLogicTree] -> [CoercibleLogicTree]
addFilterToLogicForest flt lf = CoercibleStmnt flt : lf
-- | 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 -> Maybe Routine -> ReadPlanTree -> Either ApiRequestError (Maybe FieldName)
binaryField AppConfig{configRawMediaTypes} acceptMediaType proc rpTree
| isRawMediaType =
if (funcReturnsScalar <$> proc) == Just True ||
(funcReturnsSetOfScalar <$> proc) == Just True
then Right $ Just "pgrst_scalar"
else
let
fieldName = fstFieldName rpTree
in
case fieldName of
Just fld -> Right $ Just fld
Nothing -> Left $ BinaryFieldError acceptMediaType
| otherwise =
Right Nothing
where
isRawMediaType = acceptMediaType `elem` configRawMediaTypes `L.union` [MTOctetStream, MTTextPlain, MTTextXML] || isRawPlan acceptMediaType
isRawPlan mt = case mt of
MTPlan MTOctetStream _ _ -> True
MTPlan MTTextPlain _ _ -> True
MTPlan MTTextXML _ _ -> True
_ -> False
fstFieldName :: ReadPlanTree -> Maybe FieldName
fstFieldName (Node ReadPlan{select=(CoercibleField{cfName="*", cfJsonPath=[]}, _, _):_} []) = Nothing
fstFieldName (Node ReadPlan{select=[(CoercibleField{cfName=fld, cfJsonPath=[]}, _, _)]} []) = Just fld
fstFieldName _ = Nothing
mediaToAggregate :: MediaType -> Maybe FieldName -> ApiRequest -> ResultAggregate
mediaToAggregate mt binField apiReq@ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} =
if noAgg then NoAgg
else case mt of
MTApplicationJSON -> BuiltinAggJson
MTSingularJSON strip -> BuiltinAggSingleJson strip
MTArrayJSONStrip -> BuiltinAggArrayJsonStrip
MTGeoJSON -> BuiltinAggGeoJson
MTTextCSV -> BuiltinAggCsv
MTAny -> BuiltinAggJson
MTOpenAPI -> BuiltinAggJson
MTUrlEncoded -> NoAgg -- TODO: unreachable since a previous step (producedMediaTypes) whitelists the media types that can become aggregates.
-- binary types
MTTextPlain -> BuiltinAggBinary binField
MTTextXML -> BuiltinAggXml binField
MTOctetStream -> BuiltinAggBinary binField
MTOther _ -> BuiltinAggBinary binField
-- Doing `Accept: application/vnd.pgrst.plan; for="application/vnd.pgrst.plan"` doesn't make sense, so we just empty the body.
-- TODO: fail instead to be more strict
MTPlan (MTPlan{}) _ _ -> NoAgg
MTPlan media _ _ -> mediaToAggregate media binField apiReq
where
noAgg = case act of
ActionMutate _ -> rep == Just HeadersOnly || rep == Just None || isNothing rep
ActionRead _isHead -> _isHead -- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849
ActionInvoke invMethod -> invMethod == InvHead
_ -> False
+57
View File
@@ -0,0 +1,57 @@
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.Plan.CallPlan
( CallPlan(..)
, CallParams(..)
, jsonRpcParams
)
where
import qualified Data.Aeson as JSON
import qualified Data.ByteString.Lazy as LBS
import qualified Data.HashMap.Strict as HM
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier)
import PostgREST.SchemaCache.Routine (Routine (..),
RoutineParam (..))
import Protolude
data CallPlan = FunctionCall
{ funCQi :: QualifiedIdentifier
, funCParams :: CallParams
, funCArgs :: Maybe LBS.ByteString
, funCScalar :: Bool
, funCSetOfScalar :: Bool
, funCRetCompositeAlias :: Bool
, funCReturning :: [FieldName]
}
data CallParams
= KeyParams [RoutineParam] -- ^ Call with key params: func(a := val1, b:= val2)
| OnePosParam RoutineParam -- ^ Call with positional params(only one supported): func(val)
-- | Convert rpc params `/rpc/func?a=val1&b=val2` to json `{"a": "val1", "b": "val2"}
jsonRpcParams :: Routine -> [(Text, Text)] -> LBS.ByteString
jsonRpcParams proc prms =
if not $ pdHasVariadic proc then -- if proc has no variadic param, save steps and directly convert to json
JSON.encode $ HM.fromList $ second JSON.toJSON <$> prms
else
let paramsMap = HM.fromListWith mergeParams $ toRpcParamValue proc <$> prms in
JSON.encode paramsMap
where
mergeParams :: RpcParamValue -> RpcParamValue -> RpcParamValue
mergeParams (Variadic a) (Variadic b) = Variadic $ b ++ a
mergeParams v _ = v -- repeated params for non-variadic parameters are not merged
toRpcParamValue :: Routine -> (Text, Text) -> (Text, RpcParamValue)
toRpcParamValue proc (k, v) | prmIsVariadic k = (k, Variadic [v])
| otherwise = (k, Fixed v)
where
prmIsVariadic prm = isJust $ find (\RoutineParam{ppName, ppVar} -> ppName == prm && ppVar) $ pdParams proc
-- | RPC query param value `/rpc/func?v=<value>`, used for VARIADIC functions on form-urlencoded POST and GETs
-- | It can be fixed `?v=1` or repeated `?v=1&v=2&v=3.
data RpcParamValue = Fixed Text | Variadic [Text]
instance JSON.ToJSON RpcParamValue where
toJSON (Fixed v) = JSON.toJSON v
toJSON (Variadic v) = JSON.toJSON v
+46
View File
@@ -0,0 +1,46 @@
module PostgREST.Plan.MutatePlan
( MutatePlan(..)
)
where
import qualified Data.ByteString.Lazy as LBS
import PostgREST.ApiRequest.Preferences (PreferResolution)
import PostgREST.Plan.Types (CoercibleField,
CoercibleLogicTree,
CoercibleOrderTerm)
import PostgREST.RangeQuery (NonnegRange)
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier)
import Protolude
data MutatePlan
= Insert
{ in_ :: QualifiedIdentifier
, insCols :: [CoercibleField]
, insBody :: Maybe LBS.ByteString
, onConflict :: Maybe (PreferResolution, [FieldName])
, where_ :: [CoercibleLogicTree]
, returning :: [FieldName]
, insPkCols :: [FieldName]
, applyDefs :: Bool
}
| Update
{ in_ :: QualifiedIdentifier
, updCols :: [CoercibleField]
, updBody :: Maybe LBS.ByteString
, where_ :: [CoercibleLogicTree]
, mutRange :: NonnegRange
, mutOrder :: [CoercibleOrderTerm]
, returning :: [FieldName]
, applyDefs :: Bool
}
| Delete
{ in_ :: QualifiedIdentifier
, where_ :: [CoercibleLogicTree]
, mutRange :: NonnegRange
, mutOrder :: [CoercibleOrderTerm]
, returning :: [FieldName]
}
+48
View File
@@ -0,0 +1,48 @@
module PostgREST.Plan.ReadPlan
( ReadPlanTree
, ReadPlan(..)
, JoinCondition(..)
) where
import Data.Tree (Tree (..))
import PostgREST.ApiRequest.Types (Alias, Cast, Depth, Hint,
JoinType, NodeName)
import PostgREST.Plan.Types (CoercibleField (..),
CoercibleLogicTree,
CoercibleOrderTerm)
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, Show)
data ReadPlan = ReadPlan
{ select :: [(CoercibleField, Maybe Cast, Maybe Alias)]
, from :: QualifiedIdentifier
, fromAlias :: Maybe Alias
, where_ :: [CoercibleLogicTree]
, order :: [CoercibleOrderTerm]
, range_ :: NonnegRange
, relName :: NodeName
, relToParent :: Maybe Relationship
, relJoinConds :: [JoinCondition]
, relAlias :: Maybe Alias
, relAggAlias :: Alias
, relHint :: Maybe Hint
, relJoinType :: Maybe JoinType
, relIsSpread :: Bool
, depth :: Depth
-- ^ used for aliasing
}
deriving (Eq, Show)
+67
View File
@@ -0,0 +1,67 @@
module PostgREST.Plan.Types
( CoercibleField(..)
, unknownField
, CoercibleLogicTree(..)
, CoercibleFilter(..)
, TransformerProc
, CoercibleOrderTerm(..)
) where
import PostgREST.ApiRequest.Types (Field, JsonPath, LogicOperator,
OpExpr, OrderDirection, OrderNulls)
import PostgREST.SchemaCache.Identifiers (FieldName)
import Protolude
type TransformerProc = Text
-- | A CoercibleField pairs the name of a query element with any type coercion information we need for some specific use case.
-- |
-- | As suggested by the name, it's often a reference to a field in a table but really it can be any nameable element (function parameter, calculation with an alias, etc) with a knowable type.
-- |
-- | In the simplest case, it allows us to parse JSON payloads with `json_to_recordset`, for which we need to know both the name and the type of each thing we'd like to extract. At a higher level, CoercibleField generalises to reflect that any value we work with in a query may need type specific handling.
-- |
-- | CoercibleField is the foundation for the Data Representations feature. This feature allow user-definable mappings between database types so that the same data can be presented or interpreted in various ways as needed. Sometimes the way Postgres coerces data implicitly isn't right for the job. Different mappings might be appropriate for different situations: parsing a filter from a query string requires one function (text -> field type) while parsing a payload from JSON takes another (json -> field type). And the reverse, outputting a field as JSON, requires yet a third (field type -> json). CoercibleField is that "job specific" reference to an element paired with the type we desire for that particular purpose and the function we'll use to get there, if any.
-- |
-- | In the planning phase, we "resolve" generic named elements into these specialised CoercibleFields. Again this is context specific: two different CoercibleFields both representing the exact same table column in the database, even in the same query, might have two different target types and mapping functions. For example, one might represent a column in a filter, and another the very same column in an output role to be sent in the response body.
-- |
-- | The type value is allowed to be the empty string. The analog here is soft type checking in programming languages: sometimes we don't need a variable to have a specified type and things will work anyhow. So the empty type variant is valid when we don't know and *don't need to know* about the specific type in some context. Note that this variation should not be used if it guarantees failure: in that case you should instead raise an error at the planning stage and bail out. For example, we can't parse JSON with `json_to_recordset` without knowing the types of each recipient field, and so error out. Using the empty string for the type would be incorrect and futile. On the other hand we use the empty type for RPC calls since type resolution isn't implemented for RPC, but it's fine because the query still works with Postgres' implicit coercion. In the future, hopefully we will support data representations across the board and then the empty type may be permanently retired.
data CoercibleField = CoercibleField
{ cfName :: FieldName
, cfJsonPath :: JsonPath
, cfToJson :: Bool
, cfIRType :: Text -- ^ The native Postgres type of the field, the intermediate (IR) type before mapping.
, cfTransform :: Maybe TransformerProc -- ^ The optional mapping from irType -> targetType.
, cfDefault :: Maybe Text
} deriving (Eq, Show)
unknownField :: FieldName -> JsonPath -> CoercibleField
unknownField name path = CoercibleField name path False "" Nothing Nothing
-- | Like an API request LogicTree, but with coercible field information.
data CoercibleLogicTree
= CoercibleExpr Bool LogicOperator [CoercibleLogicTree]
| CoercibleStmnt CoercibleFilter
deriving (Eq, Show)
data CoercibleFilter = CoercibleFilter
{ field :: CoercibleField
, opExpr :: OpExpr
}
| CoercibleFilterNullEmbed Bool FieldName
deriving (Eq, Show)
data CoercibleOrderTerm
= CoercibleOrderTerm
{ coField :: CoercibleField
, coDirection :: Maybe OrderDirection
, coNullOrder :: Maybe OrderNulls
}
| CoercibleOrderRelationTerm
{ coRelation :: FieldName
, coRelTerm :: Field
, coDirection :: Maybe OrderDirection
, coNullOrder :: Maybe OrderNulls
}
deriving (Eq, Show)
+274
View File
@@ -0,0 +1,274 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Query
( createQuery
, deleteQuery
, invokeQuery
, openApiQuery
, readQuery
, singleUpsertQuery
, updateQuery
, setPgLocals
, runPreReq
, 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 as BS
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 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 Data.Scientific (FPFormat (..), formatScientific, isInteger)
import PostgREST.ApiRequest (ApiRequest (..))
import PostgREST.ApiRequest.Preferences (PreferCount (..),
PreferTransaction (..),
Preferences (..),
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 (..),
WrappedReadPlan (..))
import PostgREST.Plan.MutatePlan (MutatePlan (..))
import PostgREST.Query.SqlFragment (escapeIdentList, fromQi,
intercalateSnippet,
setConfigLocal,
setConfigLocalJson)
import PostgREST.Query.Statements (ResultSet (..))
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
Schema)
import PostgREST.SchemaCache.Routine (Routine (..), RoutineMap)
import PostgREST.SchemaCache.Table (TablesMap)
import Protolude hiding (Handler)
type DbHandler = ExceptT Error SQL.Transaction
readQuery :: WrappedReadPlan -> AppConfig -> ApiRequest -> DbHandler ResultSet
readQuery WrappedReadPlan{wrReadPlan, wrResAgg} conf@AppConfig{..} apiReq@ApiRequest{iPreferences=Preferences{..}, ..} = do
let countQuery = QueryBuilder.readPlanToCountQuery wrReadPlan
resultSet <-
lift . SQL.statement mempty $
Statements.prepareRead
(QueryBuilder.readPlanToQuery wrReadPlan)
(if preferCount == Just EstimatedCount then
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
QueryBuilder.limitedQuery countQuery ((+ 1) <$> configDbMaxRows)
else
countQuery
)
(shouldCount preferCount)
iAcceptMediaType
wrResAgg
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{iPreferences=Preferences{..}} rs@RSStandard{rsTableTotal=tableTotal} countQuery =
case preferCount 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 :: Routine -> CallReadPlan -> ApiRequest -> AppConfig -> PgVersion -> DbHandler ResultSet
invokeQuery rout CallReadPlan{crReadPlan, crCallPlan, crResAgg} apiReq@ApiRequest{iPreferences=Preferences{..}, ..} conf@AppConfig{..} pgVer = do
resultSet <-
lift . SQL.statement mempty $
Statements.prepareCall
rout
(QueryBuilder.callPlanToQuery crCallPlan pgVer)
(QueryBuilder.readPlanToQuery crReadPlan)
(QueryBuilder.readPlanToCountQuery crReadPlan)
(shouldCount preferCount)
iAcceptMediaType
crResAgg
configDbPreparedStatements
optionalRollback conf apiReq
failNotSingular iAcceptMediaType resultSet
pure resultSet
openApiQuery :: SchemaCache -> PgVersion -> AppConfig -> Schema -> DbHandler (Maybe (TablesMap, RoutineMap, 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.accessibleFuncs 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.dbRoutines sCache)
<$> SQL.statement tSchema (SchemaCache.schemaDescription configDbPreparedStatements))
OADisabled ->
pure Nothing
writeQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
writeQuery MutateReadPlan{mrReadPlan, mrMutatePlan, mrResAgg} apiReq@ApiRequest{iPreferences=Preferences{..}} 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)
mrResAgg
preferRepresentation
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 (elem mediaType [MTSingularJSON True,MTSingularJSON False] && 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{iPreferences=Preferences{..}} = do
lift $ when (shouldRollback || (configDbTxRollbackAll && not shouldCommit)) $ do
SQL.sql "SET CONSTRAINTS ALL IMMEDIATE"
SQL.condemn
where
shouldCommit =
configDbTxAllowOverride && preferTransaction == Just Commit
shouldRollback =
configDbTxAllowOverride && preferTransaction == Just Rollback
-- | Runs local (transaction scoped) GUCs for every request.
setPgLocals :: AppConfig -> KM.KeyMap JSON.Value -> BS.ByteString -> [(ByteString, ByteString)] ->
ApiRequest -> PgVersion -> DbHandler ()
setPgLocals AppConfig{..} claims role roleSettings req actualPgVersion = lift $
SQL.statement mempty $ SQL.dynamicallyParameterized
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ roleSettingsSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql))
HD.noResult configDbPreparedStatements
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", role)]
roleSettingsSql = setConfigLocal mempty <$> roleSettings
appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings)
searchPathSql =
let schemas = escapeIdentList (iSchema req : configDbExtraSearchPath) in
setConfigLocal mempty ("search_path", schemas)
usesLegacyGucs = configDbUseLegacyGucs && actualPgVersion < pgVersion140
unquoted :: JSON.Value -> Text
unquoted (JSON.String t) = t
unquoted (JSON.Number n) =
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
unquoted (JSON.Bool b) = show b
unquoted v = T.decodeUtf8 . LBS.toStrict $ JSON.encode v
-- | Runs the pre-request function.
runPreReq :: AppConfig -> DbHandler ()
runPreReq conf = lift $ traverse_ (SQL.statement mempty . stmt) (configDbPreRequest conf)
where
stmt req = SQL.dynamicallyParameterized
("select " <> fromQi req <> "()")
HD.noResult
(configDbPreparedStatements conf)
+169 -137
View File
@@ -1,179 +1,192 @@
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-| {-|
Module : PostgREST.Query.QueryBuilder 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
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.Set as S
import qualified Hasql.DynamicStatements.Snippet as SQL 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.Config.PgVersion (PgVersion, pgVersion110,
import PostgREST.DbStructure.Relationship (Cardinality (..), pgVersion130)
Relationship (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.DbStructure.Table (Table (..)) import PostgREST.SchemaCache.Relationship (Cardinality (..),
import PostgREST.Request.Preferences (PreferResolution (..)) Junction (..),
Relationship (..),
relIsToOne)
import PostgREST.SchemaCache.Routine (RoutineParam (..))
import PostgREST.ApiRequest.Types
import PostgREST.Plan.CallPlan
import PostgREST.Plan.MutatePlan
import PostgREST.Plan.ReadPlan
import PostgREST.Plan.Types
import PostgREST.Query.SqlFragment import PostgREST.Query.SqlFragment
import PostgREST.Request.Types import PostgREST.RangeQuery (allRange)
import Protolude import Protolude
readRequestToQuery :: ReadRequest -> SQL.Snippet readPlanToQuery :: ReadPlanTree -> SQL.Snippet
readRequestToQuery (Node (Select colSelects mainQi tblAlias implJoins logicForest joinConditions_ ordts range, _) 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 <$> (if null select && null forest then defSelect else select)) ++ selects) <> " " <>
"FROM " <> SQL.sql (BS.intercalate ", " (tabl : implJs)) <> " " <> fromFrag <> " " <>
intercalateSnippet " " joins <> " " <> intercalateSnippet " " joins <> " " <>
(if null logicForest && null joinConditions_ then mempty else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_)) (if null logicForest && null relJoinConds
<> " " <> then mempty
(if null ordts then mempty else "ORDER BY " <> intercalateSnippet ", " (map (pgFmtOrderTerm qi) ordts)) <> " " <> else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition relJoinConds)) <> " " <>
limitOffsetF range orderF qi order <> " " <>
limitOffsetF readRange
where where
implJs = fromQi <$> implJoins fromFrag = fromF relToParent mainQi fromAlias
tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias qi = getQualifiedIdentifier relToParent mainQi fromAlias
qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias defSelect = [(unknownField "*" [], Nothing, Nothing)] -- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage
(joins, selects) = foldr getJoinsSelects ([],[]) forest (selects, joins) = foldr getSelectsJoins ([],[]) forest
getJoinsSelects :: ReadRequest -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet]) getSelectsJoins :: ReadPlanTree -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet])
getJoinsSelects rr@(Node (_, (name, Just Relationship{relCardinality=card,relTable=Table{tableName=table}}, alias, _, joinType, _)) _) (joins,selects) = getSelectsJoins (Node ReadPlan{relToParent=Nothing} _) _ = ([], [])
let subquery = readRequestToQuery rr in getSelectsJoins rr@(Node ReadPlan{select, relName, relToParent=Just rel, relAggAlias, relAlias, relJoinType, relIsSpread} forest) (selects,joins) =
case card of let
M2O _ -> subquery = readPlanToQuery rr
let aliasOrName = fromMaybe name alias aliasOrName = pgFmtIdent $ fromMaybe relName relAlias
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName aggAlias = pgFmtIdent relAggAlias
sel = SQL.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName) correlatedSubquery sub al cond =
joi = (if joinType == Just JTInner then " INNER" else " LEFT") (if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond
<> " JOIN LATERAL( " <> subquery <> " ) AS " <> SQL.sql localTableName <> " ON TRUE " in (sel, joi) = if relIsToOne rel
(joi:joins,sel:selects) then
_ -> case joinType of ( if relIsSpread
Just JTInner -> then aggAlias <> ".*"
let aliasOrName = fromMaybe name alias else "row_to_json(" <> aggAlias <> ".*) AS " <> aliasOrName
locTblName = table <> "_" <> aliasOrName , correlatedSubquery subquery aggAlias "TRUE")
localTableName = pgFmtIdent locTblName else
internalTableName = pgFmtIdent $ "_" <> locTblName ( "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> aliasOrName
sel = SQL.sql $ localTableName <> "." <> internalTableName <> " AS " <> pgFmtIdent aliasOrName , correlatedSubquery (
joi = "INNER JOIN LATERAL(" <> "SELECT json_agg(" <> aggAlias <> ") AS " <> aggAlias <>
"SELECT json_agg(" <> SQL.sql internalTableName <> ") AS " <> SQL.sql internalTableName <> "FROM (" <> subquery <> " ) AS " <> aggAlias
"FROM (" <> subquery <> " ) AS " <> SQL.sql internalTableName <> ) aggAlias $ if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE")
") AS " <> SQL.sql localTableName <> " ON " <> SQL.sql localTableName <> "IS NOT NULL" in in
(joi:joins,sel:selects) (if null select && null forest then selects else sel:selects, joi:joins)
_ ->
let sel = "COALESCE (("
<> "SELECT json_agg(" <> SQL.sql (pgFmtIdent table) <> ".*) "
<> "FROM (" <> subquery <> ") " <> SQL.sql (pgFmtIdent table) <> " "
<> "), '[]') AS " <> SQL.sql (pgFmtIdent (fromMaybe name alias)) in
(joins,sel:selects)
getJoinsSelects (Node (_, (_, Nothing, _, _, _, _)) _) _ = ([], [])
mutateRequestToQuery :: MutateRequest -> SQL.Snippet mutatePlanToQuery :: MutatePlan -> SQL.Snippet
mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returnings) = mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _ applyDefaults) =
"WITH " <> normalizedBody body <> " " <> "INSERT INTO " <> fromQi mainQi <> (if null iCols then " " else "(" <> cols <> ") ") <>
"INSERT INTO " <> SQL.sql (fromQi mainQi) <> SQL.sql (if S.null iCols then " " else "(" <> cols <> ") ") <> fromJsonBodyF body iCols True False applyDefaults <>
"SELECT " <> SQL.sql cols <> " " <>
SQL.sql ("FROM json_populate_recordset (null::" <> fromQi mainQi <> ", " <> selectBody <> ") _ ") <>
-- Only used for PUT -- Only used for PUT
(if null putConditions then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) <> (if null putConditions then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "pgrst_body") <$> putConditions)) <>
SQL.sql (BS.unwords [ maybe mempty (\(oncDo, oncCols) ->
maybe "" (\(oncDo, oncCols) ->
if null oncCols then if null oncCols then
mempty mempty
else else
"ON CONFLICT(" <> BS.intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of " ON CONFLICT(" <> intercalateSnippet ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
IgnoreDuplicates -> IgnoreDuplicates ->
"DO NOTHING" "DO NOTHING"
MergeDuplicates -> MergeDuplicates ->
if S.null iCols if null iCols
then "DO NOTHING" then "DO NOTHING"
else "DO UPDATE SET " <> BS.intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols) else "DO UPDATE SET " <> intercalateSnippet ", " ((pgFmtIdent . cfName) <> const " = EXCLUDED." <> (pgFmtIdent . cfName) <$> iCols)
) onConflct, ) onConflct <> " " <>
returningF mainQi returnings returningF mainQi returnings
])
where where
cols = BS.intercalate ", " $ pgFmtIdent <$> S.toList iCols cols = intercalateSnippet ", " $ pgFmtIdent . cfName <$> iCols
mutateRequestToQuery (Update mainQi uCols body logicForest returnings) =
if S.null uCols -- An update without a limit is always filtered with a WHERE
mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings applyDefaults)
| 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=
-- the select has to be based on "returnings" to make computed overloaded functions not throw -- the select has to be based on "returnings" to make computed overloaded functions not throw
then SQL.sql ("SELECT " <> emptyBodyReturnedColumns <> " FROM " <> fromQi mainQi <> " WHERE false") "SELECT " <> emptyBodyReturnedColumns <> " FROM " <> fromQi mainQi <> " WHERE false"
else
"WITH " <> normalizedBody body <> " " <>
"UPDATE " <> SQL.sql (fromQi mainQi) <> " SET " <> SQL.sql cols <> " " <>
"FROM (SELECT * FROM json_populate_recordset (null::" <> SQL.sql (fromQi mainQi) <> " , " <> SQL.sql selectBody <> " )) _ " <>
(if null logicForest then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)) <> " " <>
SQL.sql (returningF mainQi returnings)
where
cols = BS.intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)
emptyBodyReturnedColumns :: SqlFragment
emptyBodyReturnedColumns
| null returnings = "NULL"
| otherwise = BS.intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings)
mutateRequestToQuery (Delete mainQi logicForest returnings) =
"DELETE FROM " <> SQL.sql (fromQi mainQi) <> " " <>
(if null logicForest then mempty else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree mainQi) logicForest)) <> " " <>
SQL.sql (returningF mainQi returnings)
requestToCallProcQuery :: CallRequest -> SQL.Snippet | range == allRange =
requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall returnings) = "UPDATE " <> mainTbl <> " SET " <> nonRangeCols <> " " <>
prmsCTE <> argsBody fromJsonBodyF body uCols False False applyDefaults <>
where whereLogic <> " " <>
(prmsCTE, argFrag) = case params of returningF mainQi returnings
OnePosParam prm -> ("WITH pgrst_args AS (SELECT NULL)", singleParameter args (encodeUtf8 $ ppType prm))
KeyParams [] -> (mempty, mempty)
KeyParams prms -> (
"WITH " <> normalizedBody args <> ", " <>
SQL.sql (
BS.unwords [
"pgrst_args AS (",
"SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <> fmtParams prms (const mempty) (\a -> " " <> encodeUtf8 (ppType a)) <> ")",
")"])
, SQL.sql $ if multipleCall
then fmtParams prms varadicPrefix (\a -> " := pgrst_args." <> pgFmtIdent (ppName a))
else fmtParams prms varadicPrefix (\a -> " := (SELECT " <> pgFmtIdent (ppName a) <> " FROM pgrst_args LIMIT 1)")
)
fmtParams :: [ProcParam] -> (ProcParam -> SqlFragment) -> (ProcParam -> SqlFragment) -> SqlFragment
fmtParams prms prmFragPre prmFragSuf = BS.intercalate ", "
((\a -> prmFragPre a <> pgFmtIdent (ppName a) <> prmFragSuf a) <$> prms)
varadicPrefix :: ProcParam -> SqlFragment
varadicPrefix a = if ppVar a then "VARIADIC " else mempty
argsBody :: SQL.Snippet
argsBody
| multipleCall =
if returnsScalar
then "SELECT " <> callIt <> " AS pgrst_scalar FROM pgrst_args"
else "SELECT pgrst_lat_args.* FROM pgrst_args, " <>
"LATERAL ( SELECT " <> returnedColumns <> " FROM " <> callIt <> " ) pgrst_lat_args"
| otherwise = | otherwise =
if returnsScalar "WITH " <>
then "SELECT " <> callIt <> " AS pgrst_scalar" "pgrst_update_body AS (" <> fromJsonBodyF body uCols True True applyDefaults <> "), " <>
else "SELECT " <> returnedColumns <> " FROM " <> callIt "pgrst_affected_rows AS (" <>
"SELECT " <> rangeIdF <> " FROM " <> mainTbl <>
whereLogic <> " " <>
orderF mainQi ordts <> " " <>
limitOffsetF range <>
") " <>
"UPDATE " <> mainTbl <> " SET " <> rangeCols <>
"FROM pgrst_affected_rows " <>
"WHERE " <> whereRangeIdF <> " " <>
returningF mainQi returnings
callIt :: SQL.Snippet where
callIt = SQL.sql (fromQi qi) <> "(" <> argFrag <> ")" whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
mainTbl = fromQi mainQi
emptyBodyReturnedColumns = if null returnings then "NULL" else intercalateSnippet ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings)
nonRangeCols = intercalateSnippet ", " (pgFmtIdent . cfName <> const " = " <> pgFmtColumn (QualifiedIdentifier mempty "pgrst_body") . cfName <$> uCols)
rangeCols = intercalateSnippet ", " ((\col -> pgFmtIdent (cfName col) <> " = (SELECT " <> pgFmtIdent (cfName col) <> " FROM pgrst_update_body) ") <$> uCols)
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (cfName . coField <$> ordts)
mutatePlanToQuery (Delete mainQi logicForest range ordts returnings)
| range == allRange =
"DELETE FROM " <> fromQi mainQi <> " " <>
whereLogic <> " " <>
returningF mainQi returnings
| otherwise =
"WITH " <>
"pgrst_affected_rows AS (" <>
"SELECT " <> rangeIdF <> " FROM " <> fromQi mainQi <>
whereLogic <> " " <>
orderF mainQi ordts <> " " <>
limitOffsetF range <>
") " <>
"DELETE FROM " <> fromQi mainQi <> " " <>
"USING pgrst_affected_rows " <>
"WHERE " <> whereRangeIdF <> " " <>
returningF mainQi returnings
where
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (cfName . coField <$> ordts)
callPlanToQuery :: CallPlan -> PgVersion -> SQL.Snippet
callPlanToQuery (FunctionCall qi params args returnsScalar returnsSetOfScalar returnsCompositeAlias returnings) pgVer =
"SELECT " <> (if returnsScalar || returnsSetOfScalar then "pgrst_call.pgrst_scalar" else returnedColumns) <> " " <>
fromCall
where
fromCall = case params of
OnePosParam prm -> "FROM " <> callIt (singleParameter args $ encodeUtf8 $ ppType prm)
KeyParams [] -> "FROM " <> callIt mempty
KeyParams prms -> fromJsonBodyF args ((\p -> CoercibleField (ppName p) mempty False (ppTypeMaxLength p) Nothing Nothing) <$> prms) False True False <> ", " <>
"LATERAL " <> callIt (fmtParams prms)
callIt :: SQL.Snippet -> SQL.Snippet
callIt argument | pgVer < pgVersion130 && pgVer >= pgVersion110 && returnsCompositeAlias = "(SELECT (" <> fromQi qi <> "(" <> argument <> ")).*) pgrst_call"
| returnsScalar || returnsSetOfScalar = "(SELECT " <> fromQi qi <> "(" <> argument <> ") pgrst_scalar) pgrst_call"
| otherwise = fromQi qi <> "(" <> argument <> ") pgrst_call"
fmtParams :: [RoutineParam] -> SQL.Snippet
fmtParams prms = intercalateSnippet ", "
((\a -> (if ppVar a then "VARIADIC " else mempty) <> pgFmtIdent (ppName a) <> " := pgrst_body." <> pgFmtIdent (ppName a)) <$> prms)
returnedColumns :: SQL.Snippet returnedColumns :: SQL.Snippet
returnedColumns returnedColumns
| null returnings = "*" | null returnings = "*"
| otherwise = SQL.sql $ BS.intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName qi) <$> returnings) | otherwise = intercalateSnippet ", " (pgFmtColumn (QualifiedIdentifier mempty "pgrst_call") <$> returnings)
-- | SQL query meant for COUNTing the root node of the Tree. -- | SQL query meant for COUNTing the root node of the Tree.
-- It only takes WHERE into account and doesn't include LIMIT/OFFSET because it would reduce the COUNT. -- It only takes WHERE into account and doesn't include LIMIT/OFFSET because it would reduce the COUNT.
@@ -183,24 +196,43 @@ 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=qi, implicitJoins=implJoins, where_=logicForest, joinConditions=joinConditions_}, _) forest) = readPlanToCountQuery (Node ReadPlan{from=mainQi, fromAlias=tblAlias, where_=logicForest, relToParent=rel, relJoinConds} forest) =
"SELECT 1 FROM " <> SQL.sql (BS.intercalate ", " (fromQi qi:(fromQi <$> implJoins))) <> "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
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 mempty 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 ReadPlan type
getQualifiedIdentifier :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> QualifiedIdentifier
getQualifiedIdentifier rel mainQi tblAlias = case rel of
Just ComputedRelationship{relFunction} -> QualifiedIdentifier mempty $ fromMaybe (qiName relFunction) tblAlias
_ -> maybe mainQi (QualifiedIdentifier mempty) tblAlias
-- FROM clause plus implicit joins
fromF :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> SQL.Snippet
fromF rel mainQi tblAlias = "FROM " <>
(case rel of
Just ComputedRelationship{relFunction,relTable} -> fromQi relFunction <> "(" <> pgFmtIdent (qiName relTable) <> ")"
_ -> fromQi mainQi) <>
maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias <>
(case rel of
Just Relationship{relCardinality=M2M Junction{junTable=jt}} -> ", " <> fromQi jt
_ -> mempty)
+299 -128
View File
@@ -1,127 +1,128 @@
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-| {-|
Module : PostgREST.Query.SqlFragment Module : PostgREST.Query.SqlFragment
Description : Helper functions for PostgREST.QueryBuilder. Description : Helper functions for PostgREST.QueryBuilder.
Any function that outputs a SqlFragment should be in this module.
-} -}
module PostgREST.Query.SqlFragment module PostgREST.Query.SqlFragment
( noLocationF ( noLocationF
, SqlFragment , aggF
, asBinaryF
, asCsvF
, asJsonF
, asJsonSingleF
, countF , countF
, fromQi , fromQi
, ftsOperators
, limitOffsetF , limitOffsetF
, locationF , locationF
, normalizedBody , mutRangeF
, operators , orderF
, pgFmtColumn , pgFmtColumn
, pgFmtIdent , pgFmtIdent
, pgFmtJoinCondition , pgFmtJoinCondition
, pgFmtLogicTree , pgFmtLogicTree
, pgFmtOrderTerm , pgFmtOrderTerm
, pgFmtSelectItem , pgFmtSelectItem
, fromJsonBodyF
, responseHeadersF , responseHeadersF
, responseStatusF , responseStatusF
, returningF , returningF
, selectBody
, singleParameter , singleParameter
, sourceCTE
, sourceCTEName , sourceCTEName
, unknownEncoder , unknownEncoder
, intercalateSnippet , intercalateSnippet
, explainF
, setConfigLocal
, setConfigLocalJson
, escapeIdent
, escapeIdentList
) 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 M 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,
QualifiedIdentifier (..)) FtsOperator (..),
import PostgREST.RangeQuery (NonnegRange, allRange,
rangeLimit, rangeOffset)
import PostgREST.Request.Types (Alias, Field, Filter (..),
JoinCondition (..),
JsonOperand (..), JsonOperand (..),
JsonOperation (..), JsonOperation (..),
JsonPath, JsonPath,
LogicOperator (..), LogicOperator (..),
LogicTree (..), OpExpr (..), OpExpr (..),
OpQuantifier (..),
Operation (..), Operation (..),
OrderDirection (..), OrderDirection (..),
OrderNulls (..), OrderNulls (..),
OrderTerm (..), SelectItem, QuantOperator (..),
SimpleOperator (..),
TrileanVal (..)) TrileanVal (..))
import PostgREST.MediaType (MTPlanFormat (..),
MTPlanOption (..))
import PostgREST.Plan.ReadPlan (JoinCondition (..))
import PostgREST.Plan.Types (CoercibleField (..),
CoercibleFilter (..),
CoercibleLogicTree (..),
CoercibleOrderTerm (..),
unknownField)
import PostgREST.RangeQuery (NonnegRange, allRange,
rangeLimit, rangeOffset)
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..))
import PostgREST.SchemaCache.Routine (ResultAggregate (..),
Routine (..),
funcReturnsScalar,
funcReturnsSetOfScalar,
funcReturnsSingleComposite)
import Protolude hiding (cast) import Protolude hiding (cast)
sourceCTEName :: Text
-- | A part of a SQL query that cannot be executed independently
type SqlFragment = ByteString
noLocationF :: SqlFragment
noLocationF = "array[]::text[]"
sourceCTEName :: SqlFragment
sourceCTEName = "pgrst_source" sourceCTEName = "pgrst_source"
operators :: M.HashMap Text SqlFragment sourceCTE :: SQL.Snippet
operators = M.union (M.fromList [ sourceCTE = "pgrst_source"
("eq", "="),
("gte", ">="),
("gt", ">"),
("lte", "<="),
("lt", "<"),
("neq", "<>"),
("like", "LIKE"),
("ilike", "ILIKE"),
("in", "IN"),
("is", "IS"),
("cs", "@>"),
("cd", "<@"),
("ov", "&&"),
("sl", "<<"),
("sr", ">>"),
("nxr", "&<"),
("nxl", "&>"),
("adj", "-|-")]) ftsOperators
ftsOperators :: M.HashMap Text SqlFragment noLocationF :: SQL.Snippet
ftsOperators = M.fromList [ noLocationF = "array[]::text[]"
("fts", "@@ to_tsquery"),
("plfts", "@@ plainto_tsquery"),
("phfts", "@@ phraseto_tsquery"),
("wfts", "@@ websearch_to_tsquery")
]
-- | simpleOperator :: SimpleOperator -> SQL.Snippet
-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads simpleOperator = \case
-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays OpNotEqual -> "<>"
-- We do this in SQL to avoid processing the JSON in application code OpContains -> "@>"
-- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body OpContained -> "<@"
normalizedBody :: Maybe LBS.ByteString -> SQL.Snippet OpOverlap -> "&&"
normalizedBody body = OpStrictlyLeft -> "<<"
"pgrst_payload AS (SELECT " <> jsonPlaceHolder <> " AS json_data), " <> OpStrictlyRight -> ">>"
SQL.sql (BS.unwords [ OpNotExtendsRight -> "&<"
"pgrst_body AS (", OpNotExtendsLeft -> "&>"
"SELECT", OpAdjacent -> "-|-"
"CASE WHEN json_typeof(json_data) = 'array'",
"THEN json_data", quantOperator :: QuantOperator -> SQL.Snippet
"ELSE json_build_array(json_data)", quantOperator = \case
"END AS val", OpEqual -> "="
"FROM pgrst_payload)"]) OpGreaterThanEqual -> ">="
where OpGreaterThan -> ">"
jsonPlaceHolder = SQL.encoderAndParam (HE.nullable HE.unknown) (LBS.toStrict <$> body) <> "::json" OpLessThanEqual -> "<="
OpLessThan -> "<"
OpLike -> "like"
OpILike -> "ilike"
OpMatch -> "~"
OpIMatch -> "~*"
ftsOperator :: FtsOperator -> SQL.Snippet
ftsOperator = \case
FilterFts -> "@@ to_tsquery"
FilterFtsPlain -> "@@ plainto_tsquery"
FilterFtsPhrase -> "@@ phraseto_tsquery"
FilterFtsWebsearch -> "@@ websearch_to_tsquery"
singleParameter :: Maybe LBS.ByteString -> ByteString -> SQL.Snippet singleParameter :: Maybe LBS.ByteString -> ByteString -> SQL.Snippet
singleParameter body typ = singleParameter body typ =
@@ -130,9 +131,6 @@ singleParameter body typ =
then SQL.encoderAndParam (HE.nullable HE.bytea) (LBS.toStrict <$> body) then SQL.encoderAndParam (HE.nullable HE.bytea) (LBS.toStrict <$> body)
else SQL.encoderAndParam (HE.nullable HE.unknown) (LBS.toStrict <$> body) <> "::" <> SQL.sql typ else SQL.encoderAndParam (HE.nullable HE.unknown) (LBS.toStrict <$> body) <> "::" <> SQL.sql typ
selectBody :: SqlFragment
selectBody = "(SELECT val FROM pgrst_body)"
-- Here we build the pg array literal, e.g '{"Hebdon, John","Other","Another"}', manually. -- Here we build the pg array literal, e.g '{"Hebdon, John","Other","Another"}', manually.
-- This is necessary to pass an "unknown" array and let pg infer the type. -- This is necessary to pass an "unknown" array and let pg infer the type.
-- There are backslashes here, but since this value is parametrized and is not a string constant -- There are backslashes here, but since this value is parametrized and is not a string constant
@@ -147,13 +145,34 @@ pgBuildArrayLiteral vals =
"{" <> T.intercalate "," (escaped <$> vals) <> "}" "{" <> T.intercalate "," (escaped <$> vals) <> "}"
-- TODO: refactor by following https://github.com/PostgREST/postgrest/pull/1631#issuecomment-711070833 -- TODO: refactor by following https://github.com/PostgREST/postgrest/pull/1631#issuecomment-711070833
pgFmtIdent :: Text -> SqlFragment pgFmtIdent :: Text -> SQL.Snippet
pgFmtIdent x = encodeUtf8 $ "\"" <> T.replace "\"" "\"\"" (trimNullChars x) <> "\"" pgFmtIdent x = SQL.sql $ escapeIdent x
escapeIdent :: Text -> ByteString
escapeIdent x = encodeUtf8 $ "\"" <> T.replace "\"" "\"\"" (trimNullChars x) <> "\""
-- Only use it if the input comes from the database itself, like on `jsonb_build_object('column_from_a_table', val)..`
pgFmtLit :: Text -> Text
pgFmtLit x =
let trimmed = trimNullChars x
escaped = "'" <> T.replace "'" "''" trimmed <> "'"
slashed = T.replace "\\" "\\\\" escaped in
if "\\" `T.isInfixOf` escaped
then "E" <> slashed
else slashed
trimNullChars :: Text -> Text trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0') trimNullChars = T.takeWhile (/= '\x0')
asCsvF :: SqlFragment -- |
-- Format a list of identifiers and separate them by commas.
--
-- >>> escapeIdentList ["schema_1", "schema_2", "SPECIAL \"@/\\#~_-"]
-- "\"schema_1\", \"schema_2\", \"SPECIAL \"\"@/\\#~_-\""
escapeIdentList :: [Text] -> ByteString
escapeIdentList schemas = BS.intercalate ", " $ escapeIdent <$> schemas
asCsvF :: SQL.Snippet
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
where where
asCsvHeaderF = asCsvHeaderF =
@@ -161,26 +180,49 @@ asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
" FROM (" <> " FROM (" <>
" SELECT json_object_keys(r)::text as k" <> " SELECT json_object_keys(r)::text as k" <>
" FROM ( " <> " FROM ( " <>
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <> " SELECT row_to_json(hh) as r from " <> sourceCTE <> " as hh limit 1" <>
" ) s" <> " ) s" <>
" ) a" <> " ) a" <>
")" ")"
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')" asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
asJsonF :: Bool -> SqlFragment addNullsToSnip :: Bool -> SQL.Snippet -> SQL.Snippet
asJsonF returnsScalar addNullsToSnip strip snip =
| returnsScalar = "coalesce(json_agg(_postgrest_t.pgrst_scalar), '[]')::character varying" if strip then "json_strip_nulls(" <> snip <> ")" else snip
| otherwise = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
asJsonSingleF :: Bool -> SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element asJsonSingleF :: Maybe Routine -> Bool -> SQL.Snippet
asJsonSingleF returnsScalar asJsonSingleF rout strip
| returnsScalar = "coalesce(string_agg(to_json(_postgrest_t.pgrst_scalar)::text, ','), 'null')::character varying" | returnsScalar = "coalesce(" <> addNullsToSnip strip "json_agg(_postgrest_t.pgrst_scalar)->0" <> ", 'null')"
| otherwise = "coalesce(string_agg(to_json(_postgrest_t)::text, ','), '')::character varying" | otherwise = "coalesce(" <> addNullsToSnip strip "json_agg(_postgrest_t)->0" <> ", 'null')"
where
returnsScalar = maybe False funcReturnsScalar rout
asBinaryF :: FieldName -> SqlFragment asJsonF :: Maybe Routine -> Bool -> SQL.Snippet
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')" asJsonF rout strip
| returnsSingleComposite = "coalesce(" <> addNullsToSnip strip "json_agg(_postgrest_t)->0" <> ", 'null')"
| returnsScalar = "coalesce(" <> addNullsToSnip strip "json_agg(_postgrest_t.pgrst_scalar)->0" <> ", 'null')"
| returnsSetOfScalar = "coalesce(" <> addNullsToSnip strip "json_agg(_postgrest_t.pgrst_scalar)" <> ", '[]')"
| otherwise = "coalesce(" <> addNullsToSnip strip "json_agg(_postgrest_t)" <> ", '[]')"
where
(returnsSingleComposite, returnsScalar, returnsSetOfScalar) = case rout of
Just r -> (funcReturnsSingleComposite r, funcReturnsScalar r, funcReturnsSetOfScalar r)
Nothing -> (False, False, False)
locationF :: [Text] -> SqlFragment
asXmlF :: Maybe FieldName -> SQL.Snippet
asXmlF (Just fieldName) = "coalesce(xmlagg(_postgrest_t." <> pgFmtIdent fieldName <> "), '')"
-- TODO unreachable because a previous step(binaryField) will validate that there's a field. This will be cleared once custom media types are implemented.
asXmlF Nothing = "coalesce(xmlagg(_postgrest_t), '')"
asGeoJsonF :: SQL.Snippet
asGeoJsonF = "json_build_object('type', 'FeatureCollection', 'features', coalesce(json_agg(ST_AsGeoJSON(_postgrest_t)::json), '[]'))"
asBinaryF :: Maybe FieldName -> SQL.Snippet
asBinaryF (Just fieldName) = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
-- TODO unreachable because a previous step(binaryField) will validate that there's a field. This will be cleared once custom media types are implemented.
asBinaryF Nothing = "coalesce(string_agg(_postgrest_t, ''), '')"
locationF :: [Text] -> SQL.Snippet
locationF pKeys = [qc|( locationF pKeys = [qc|(
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1) WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null')) SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
@@ -190,85 +232,161 @@ locationF pKeys = [qc|(
where where
fmtPKeys = T.intercalate "','" pKeys fmtPKeys = T.intercalate "','" pKeys
fromQi :: QualifiedIdentifier -> SqlFragment fromQi :: QualifiedIdentifier -> SQL.Snippet
fromQi t = (if T.null s then mempty else pgFmtIdent s <> ".") <> pgFmtIdent n fromQi t = (if T.null s then mempty else pgFmtIdent s <> ".") <> pgFmtIdent n
where where
n = qiName t n = qiName t
s = qiSchema t s = qiSchema t
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment pgFmtColumn :: QualifiedIdentifier -> Text -> SQL.Snippet
pgFmtColumn table "*" = fromQi table <> ".*" pgFmtColumn table "*" = fromQi table <> ".*"
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
pgFmtField :: QualifiedIdentifier -> Field -> SQL.Snippet pgFmtCallUnary :: Text -> SQL.Snippet -> SQL.Snippet
pgFmtField table (c, jp) = SQL.sql (pgFmtColumn table c) <> pgFmtJsonPath jp pgFmtCallUnary f x = SQL.sql (encodeUtf8 f) <> "(" <> x <> ")"
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SQL.Snippet pgFmtField :: QualifiedIdentifier -> CoercibleField -> SQL.Snippet
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _, _) = pgFmtField table f <> SQL.sql (pgFmtAs fName jp alias) pgFmtField table CoercibleField{cfName=fn, cfJsonPath=[]} = pgFmtColumn table fn
pgFmtField table CoercibleField{cfName=fn, cfToJson=doToJson, cfJsonPath=jp} | doToJson = "to_jsonb(" <> pgFmtColumn table fn <> ")" <> pgFmtJsonPath jp
| otherwise = pgFmtColumn table fn <> pgFmtJsonPath jp
-- Select the value of a named element from a table, applying its optional coercion mapping if any.
pgFmtTableCoerce :: QualifiedIdentifier -> CoercibleField -> SQL.Snippet
pgFmtTableCoerce table fld@(CoercibleField{cfTransform=(Just formatterProc)}) = pgFmtCallUnary formatterProc (pgFmtField table fld)
pgFmtTableCoerce table f = pgFmtField table f
-- | Like the previous but now we just have a name so no namespace or JSON paths.
pgFmtCoerceNamed :: CoercibleField -> SQL.Snippet
pgFmtCoerceNamed CoercibleField{cfName=fn, cfTransform=(Just formatterProc)} = pgFmtCallUnary formatterProc (pgFmtIdent fn) <> " AS " <> pgFmtIdent fn
pgFmtCoerceNamed CoercibleField{cfName=fn} = pgFmtIdent fn
pgFmtSelectItem :: QualifiedIdentifier -> (CoercibleField, Maybe Cast, Maybe Alias) -> SQL.Snippet
pgFmtSelectItem table (fld, Nothing, alias) = pgFmtTableCoerce table fld <> pgFmtAs (cfName fld) (cfJsonPath fld) 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 (fld, Just cast, alias) = "CAST (" <> pgFmtTableCoerce table fld <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> pgFmtAs (cfName fld) (cfJsonPath fld) alias
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SQL.Snippet -- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body
pgFmtOrderTerm qi ot = fromJsonBodyF :: Maybe LBS.ByteString -> [CoercibleField] -> Bool -> Bool -> Bool -> SQL.Snippet
pgFmtField qi (otTerm ot) <> " " <> fromJsonBodyF body fields includeSelect includeLimitOne includeDefaults =
SQL.sql (BS.unwords [ (if includeSelect then "SELECT " <> namedCols <> " " else mempty) <>
maybe mempty direction $ otDirection ot, "FROM (SELECT " <> jsonPlaceHolder <> " AS json_data) pgrst_payload, " <>
maybe mempty nullOrder $ otNullOrder ot]) -- convert a json object into a json array, this way we can use json_to_recordset for all json payloads
-- Otherwise we'd have to use json_to_record for json objects and json_to_recordset for json arrays
-- We do this in SQL to avoid processing the JSON in application code
"LATERAL (SELECT CASE WHEN " <> jsonTypeofF <> "(pgrst_payload.json_data) = 'array' THEN pgrst_payload.json_data ELSE " <> jsonBuildArrayF <> "(pgrst_payload.json_data) END AS val) pgrst_uniform_json, " <>
(if includeDefaults
then "LATERAL (SELECT jsonb_agg(jsonb_build_object(" <> defsJsonb <> ") || elem) AS val from jsonb_array_elements(pgrst_uniform_json.val) elem) pgrst_json_defs, "
else mempty) <>
"LATERAL (SELECT " <> parsedCols <> " FROM " <>
(if null fields
-- When we are inserting no columns (e.g. using default values), we can't use our ordinary `json_to_recordset`
-- because it can't extract records with no columns (there's no valid syntax for the `AS (colName colType,...)`
-- part). But we still need to ensure as many rows are created as there are array elements.
then SQL.sql $ jsonArrayElementsF <> "(" <> finalBodyF <> ") _ "
else jsonToRecordsetF <> "(" <> SQL.sql finalBodyF <> ") AS _(" <> typedCols <> ") " <> if includeLimitOne then "LIMIT 1" else mempty
) <>
") pgrst_body "
where where
namedCols = intercalateSnippet ", " $ fromQi . QualifiedIdentifier "pgrst_body" . cfName <$> fields
parsedCols = intercalateSnippet ", " $ pgFmtCoerceNamed <$> fields
typedCols = intercalateSnippet ", " $ pgFmtIdent . cfName <> const " " <> SQL.sql . encodeUtf8 . cfIRType <$> fields
defsJsonb = SQL.sql $ BS.intercalate "," fieldsWDefaults
fieldsWDefaults = mapMaybe (\case
CoercibleField{cfName=nam, cfDefault=Just def} -> Just $ encodeUtf8 (pgFmtLit nam <> ", " <> def)
CoercibleField{cfDefault=Nothing} -> Nothing
) fields
(finalBodyF, jsonTypeofF, jsonBuildArrayF, jsonArrayElementsF, jsonToRecordsetF) =
if includeDefaults
then ("pgrst_json_defs.val", "jsonb_typeof", "jsonb_build_array", "jsonb_array_elements", "jsonb_to_recordset")
else ("pgrst_uniform_json.val", "json_typeof", "json_build_array", "json_array_elements", "json_to_recordset")
jsonPlaceHolder = SQL.encoderAndParam (HE.nullable $ if includeDefaults then HE.jsonbLazyBytes else HE.jsonLazyBytes) body
pgFmtOrderTerm :: QualifiedIdentifier -> CoercibleOrderTerm -> SQL.Snippet
pgFmtOrderTerm qi ot =
fmtOTerm ot <> " " <>
SQL.sql (BS.unwords [
maybe mempty direction $ coDirection ot,
maybe mempty nullOrder $ coNullOrder ot])
where
fmtOTerm = \case
CoercibleOrderTerm{coField=cof} -> pgFmtField qi cof
CoercibleOrderRelationTerm{coRelation, coRelTerm=(fn, jp)} -> pgFmtField (QualifiedIdentifier mempty coRelation) (unknownField fn jp)
direction OrderAsc = "ASC" direction OrderAsc = "ASC"
direction OrderDesc = "DESC" direction OrderDesc = "DESC"
nullOrder OrderNullsFirst = "NULLS FIRST" nullOrder OrderNullsFirst = "NULLS FIRST"
nullOrder OrderNullsLast = "NULLS LAST" nullOrder OrderNullsLast = "NULLS LAST"
-- | Interpret a literal in the way the planner indicated through the CoercibleField.
pgFmtUnknownLiteralForField :: SQL.Snippet -> CoercibleField -> SQL.Snippet
pgFmtUnknownLiteralForField value CoercibleField{cfTransform=(Just parserProc)} = pgFmtCallUnary parserProc value
-- But when no transform is requested, we just use the literal as-is.
pgFmtUnknownLiteralForField value _ = value
pgFmtFilter :: QualifiedIdentifier -> Filter -> SQL.Snippet -- | Array version of the above, used by ANY().
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of pgFmtArrayLiteralForField :: [Text] -> CoercibleField -> SQL.Snippet
Op op val -> pgFmtFieldOp op <> " " <> case op of -- When a transformation is requested, we need to apply the transformation to each element of the array. This could be done by just making a query with `parser(value)` for each value, but may lead to huge query lengths. Imagine `data_representations.color_from_text('...'::text)` for repeated for a hundred values. Instead we use `unnest()` to unpack a standard array literal and then apply the transformation to each element, like a map.
"like" -> unknownLiteral (T.map star val) -- Note the literals will be treated as text since in every case when we use ANY() the parameters are textual (coming from a query string). We want to rely on the `text->domain` parser to do the right thing.
"ilike" -> unknownLiteral (T.map star val) pgFmtArrayLiteralForField values CoercibleField{cfTransform=(Just parserProc)} = SQL.sql "(SELECT " <> pgFmtCallUnary parserProc (SQL.sql "unnest(" <> unknownLiteral (pgBuildArrayLiteral values) <> "::text[])") <> ")"
_ -> unknownLiteral val -- When no transformation is requested, we don't need a subquery.
pgFmtArrayLiteralForField values _ = unknownLiteral (pgBuildArrayLiteral values)
pgFmtFilter :: QualifiedIdentifier -> CoercibleFilter -> SQL.Snippet
pgFmtFilter _ (CoercibleFilterNullEmbed hasNot fld) = pgFmtIdent fld <> " IS " <> (if hasNot then "NOT" else mempty) <> " NULL"
pgFmtFilter _ (CoercibleFilter _ (NoOpExpr _)) = mempty -- TODO unreachable because NoOpExpr is filtered on QueryParams
pgFmtFilter table (CoercibleFilter fld (OpExpr hasNot oper)) = notOp <> " " <> pgFmtField table fld <> case oper of
Op op val -> " " <> simpleOperator op <> " " <> pgFmtUnknownLiteralForField (unknownLiteral val) fld
OpQuant op quant val -> " " <> quantOperator op <> " " <> case op of
OpLike -> fmtQuant quant $ unknownLiteral (T.map star val)
OpILike -> fmtQuant quant $ unknownLiteral (T.map star val)
_ -> fmtQuant quant $ pgFmtUnknownLiteralForField (unknownLiteral val) fld
-- IS cannot be prepared. `PREPARE boolplan AS SELECT * FROM projects where id IS $1` will give a syntax error. -- IS cannot be prepared. `PREPARE boolplan AS SELECT * FROM projects where id IS $1` will give a syntax error.
-- The above can be fixed by using `PREPARE boolplan AS SELECT * FROM projects where id IS NOT DISTINCT FROM $1;` -- The above can be fixed by using `PREPARE boolplan AS SELECT * FROM projects where id IS NOT DISTINCT FROM $1;`
-- However that would not accept the TRUE/FALSE/NULL/UNKNOWN keywords. See: https://stackoverflow.com/questions/6133525/proper-way-to-set-preparedstatement-parameter-to-null-under-postgres. -- However that would not accept the TRUE/FALSE/NULL/UNKNOWN keywords. See: https://stackoverflow.com/questions/6133525/proper-way-to-set-preparedstatement-parameter-to-null-under-postgres.
-- This is why `IS` operands are whitelisted at the Parsers.hs level -- This is why `IS` operands are whitelisted at the Parsers.hs level
Is triVal -> pgFmtField table fld <> " IS " <> case triVal of Is triVal -> " IS " <> case triVal of
TriTrue -> "TRUE" TriTrue -> "TRUE"
TriFalse -> "FALSE" TriFalse -> "FALSE"
TriNull -> "NULL" TriNull -> "NULL"
TriUnknown -> "UNKNOWN" TriUnknown -> "UNKNOWN"
IsDistinctFrom val -> " IS DISTINCT FROM " <> unknownLiteral val
-- We don't use "IN", we use "= ANY". IN has the following disadvantages: -- We don't use "IN", we use "= ANY". IN has the following disadvantages:
-- + No way to use an empty value on IN: "col IN ()" is invalid syntax. With ANY we can do "= ANY('{}')" -- + No way to use an empty value on IN: "col IN ()" is invalid syntax. With ANY we can do "= ANY('{}')"
-- + Can invalidate prepared statements: multiple parameters on an IN($1, $2, $3) will lead to using different prepared statements and not take advantage of caching. -- + Can invalidate prepared statements: multiple parameters on an IN($1, $2, $3) will lead to using different prepared statements and not take advantage of caching.
In vals -> pgFmtField table fld <> " " <> case vals of In vals -> " " <> case vals of
[""] -> "= ANY('{}') " [""] -> "= ANY('{}') "
_ -> "= ANY (" <> unknownLiteral (pgBuildArrayLiteral vals) <> ") " _ -> "= ANY (" <> pgFmtArrayLiteralForField vals fld <> ") "
Fts op lang val -> Fts op lang val -> " " <> ftsOperator op <> "(" <> ftsLang lang <> unknownLiteral val <> ") "
pgFmtFieldOp op <> "(" <> ftsLang lang <> unknownLiteral val <> ") "
where where
ftsLang = maybe mempty (\l -> unknownLiteral l <> ", ") ftsLang = maybe mempty (\l -> unknownLiteral l <> ", ")
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
sqlOperator o = SQL.sql $ M.lookupDefault "=" o operators
notOp = if hasNot then "NOT" else mempty notOp = if hasNot then "NOT" else mempty
star c = if c == '*' then '%' else c star c = if c == '*' then '%' else c
fmtQuant q val = case q of
Just QuantAny -> "ANY(" <> val <> ")"
Just QuantAll -> "ALL(" <> val <> ")"
Nothing -> val
pgFmtJoinCondition :: JoinCondition -> SQL.Snippet pgFmtJoinCondition :: JoinCondition -> SQL.Snippet
pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) = pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) =
SQL.sql $ pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 col2 pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 col2
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SQL.Snippet pgFmtLogicTree :: QualifiedIdentifier -> CoercibleLogicTree -> SQL.Snippet
pgFmtLogicTree qi (Expr hasNot op forest) = SQL.sql notOp <> " (" <> intercalateSnippet (opSql op) (pgFmtLogicTree qi <$> forest) <> ")" pgFmtLogicTree qi (CoercibleExpr hasNot op forest) = SQL.sql notOp <> " (" <> intercalateSnippet (opSql op) (pgFmtLogicTree qi <$> forest) <> ")"
where where
notOp = if hasNot then "NOT" else mempty notOp = if hasNot then "NOT" else mempty
opSql And = " AND " opSql And = " AND "
opSql Or = " OR " opSql Or = " OR "
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt pgFmtLogicTree qi (CoercibleStmnt flt) = pgFmtFilter qi flt
pgFmtJsonPath :: JsonPath -> SQL.Snippet pgFmtJsonPath :: JsonPath -> SQL.Snippet
pgFmtJsonPath = \case pgFmtJsonPath = \case
@@ -279,7 +397,7 @@ pgFmtJsonPath = \case
pgFmtJsonOperand (JKey k) = unknownLiteral k pgFmtJsonOperand (JKey k) = unknownLiteral k
pgFmtJsonOperand (JIdx i) = unknownLiteral i <> "::int" pgFmtJsonOperand (JIdx i) = unknownLiteral i <> "::int"
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SQL.Snippet
pgFmtAs _ [] Nothing = mempty pgFmtAs _ [] Nothing = mempty
pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
Just (JKey key) -> " AS " <> pgFmtIdent key Just (JKey key) -> " AS " <> pgFmtIdent key
@@ -291,7 +409,7 @@ pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
Nothing -> mempty Nothing -> mempty
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
countF :: SQL.Snippet -> Bool -> (SQL.Snippet, SqlFragment) countF :: SQL.Snippet -> Bool -> (SQL.Snippet, SQL.Snippet)
countF countQuery shouldCount = countF countQuery shouldCount =
if shouldCount if shouldCount
then ( then (
@@ -301,11 +419,11 @@ countF countQuery shouldCount =
mempty mempty
, "null::bigint") , "null::bigint")
returningF :: QualifiedIdentifier -> [FieldName] -> SqlFragment returningF :: QualifiedIdentifier -> [FieldName] -> SQL.Snippet
returningF qi returnings = returningF qi returnings =
if null returnings if null returnings
then "RETURNING 1" -- For mutation cases where there's no ?select, we return 1 to know how many rows were modified then "RETURNING 1" -- For mutation cases where there's no ?select, we return 1 to know how many rows were modified
else "RETURNING " <> BS.intercalate ", " (pgFmtColumn qi <$> returnings) else "RETURNING " <> intercalateSnippet ", " (pgFmtColumn qi <$> returnings)
limitOffsetF :: NonnegRange -> SQL.Snippet limitOffsetF :: NonnegRange -> SQL.Snippet
limitOffsetF range = limitOffsetF range =
@@ -314,17 +432,28 @@ limitOffsetF range =
limit = maybe "ALL" (\l -> unknownEncoder (BS.pack $ show l)) $ rangeLimit range limit = maybe "ALL" (\l -> unknownEncoder (BS.pack $ show l)) $ rangeLimit range
offset = unknownEncoder (BS.pack . show $ rangeOffset range) offset = unknownEncoder (BS.pack . show $ rangeOffset range)
responseHeadersF :: SqlFragment responseHeadersF :: SQL.Snippet
responseHeadersF = currentSettingF "response.headers" responseHeadersF = currentSettingF "response.headers"
responseStatusF :: SqlFragment responseStatusF :: SQL.Snippet
responseStatusF = currentSettingF "response.status" responseStatusF = currentSettingF "response.status"
currentSettingF :: SqlFragment -> SqlFragment currentSettingF :: SQL.Snippet -> SQL.Snippet
currentSettingF setting = currentSettingF setting =
-- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15 -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
"nullif(current_setting('" <> setting <> "', true), '')" "nullif(current_setting('" <> setting <> "', true), '')"
mutRangeF :: QualifiedIdentifier -> [FieldName] -> (SQL.Snippet, SQL.Snippet)
mutRangeF mainQi rangeId =
(
intercalateSnippet " AND " $ (\col -> pgFmtColumn mainQi col <> " = " <> pgFmtColumn (QualifiedIdentifier mempty "pgrst_affected_rows") col) <$> rangeId
, intercalateSnippet ", " (pgFmtColumn mainQi <$> rangeId)
)
orderF :: QualifiedIdentifier -> [CoercibleOrderTerm] -> SQL.Snippet
orderF _ [] = mempty
orderF qi ordts = "ORDER BY " <> intercalateSnippet ", " (pgFmtOrderTerm qi <$> ordts)
-- Hasql Snippet utilities -- Hasql Snippet utilities
unknownEncoder :: ByteString -> SQL.Snippet unknownEncoder :: ByteString -> SQL.Snippet
unknownEncoder = SQL.encoderAndParam (HE.nonNullable HE.unknown) unknownEncoder = SQL.encoderAndParam (HE.nonNullable HE.unknown)
@@ -335,3 +464,45 @@ unknownLiteral = unknownEncoder . encodeUtf8
intercalateSnippet :: ByteString -> [SQL.Snippet] -> SQL.Snippet intercalateSnippet :: ByteString -> [SQL.Snippet] -> SQL.Snippet
intercalateSnippet _ [] = mempty intercalateSnippet _ [] = mempty
intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets
explainF :: MTPlanFormat -> [MTPlanOption] -> SQL.Snippet -> SQL.Snippet
explainF fmt opts snip =
"EXPLAIN (" <>
SQL.sql (BS.intercalate ", " (fmtPlanFmt fmt : (fmtPlanOpt <$> opts))) <>
") " <> snip
where
fmtPlanOpt :: MTPlanOption -> BS.ByteString
fmtPlanOpt PlanAnalyze = "ANALYZE"
fmtPlanOpt PlanVerbose = "VERBOSE"
fmtPlanOpt PlanSettings = "SETTINGS"
fmtPlanOpt PlanBuffers = "BUFFERS"
fmtPlanOpt PlanWAL = "WAL"
fmtPlanFmt PlanText = "FORMAT TEXT"
fmtPlanFmt PlanJSON = "FORMAT JSON"
-- | 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
aggF :: Maybe Routine -> ResultAggregate -> SQL.Snippet
aggF rout = \case
BuiltinAggJson -> asJsonF rout False
BuiltinAggArrayJsonStrip -> asJsonF rout True
BuiltinAggSingleJson strip -> asJsonSingleF rout strip
BuiltinAggGeoJson -> asGeoJsonF
BuiltinAggCsv -> asCsvF
BuiltinAggXml bField -> asXmlF bField
BuiltinAggBinary bField -> asBinaryF bField
NoAgg -> "''::text"
+106 -134
View File
@@ -6,192 +6,164 @@ This module constructs single SQL statements that can be parametrized and prepar
- It consumes the SqlQuery types generated by the QueryBuilder module. - It consumes the SqlQuery types generated by the QueryBuilder module.
- It generates the body format and some headers of the final HTTP response. - It generates the body format and some headers of the final HTTP response.
TODO: Currently, createReadStatement is not using prepared statements. See https://github.com/PostgREST/postgrest/issues/718.
-} -}
module PostgREST.Query.Statements module PostgREST.Query.Statements
( createWriteStatement ( prepareWrite
, createReadStatement , prepareRead
, callProcStatement , prepareCall
, createExplainStatement , preparePlanRows
, 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.Text.Read (decimal)
import Network.HTTP.Types.Status (Status)
import PostgREST.Error (Error (..)) import PostgREST.ApiRequest.Preferences
import PostgREST.GucHeader (GucHeader) import PostgREST.MediaType (MTPlanFormat (..),
MediaType (..))
import PostgREST.DbStructure.Identifiers (FieldName)
import PostgREST.Query.SqlFragment import PostgREST.Query.SqlFragment
import PostgREST.Request.Preferences import PostgREST.SchemaCache.Routine (ResultAggregate (..),
Routine)
import Protolude import Protolude
{-| The generic query result format used by API responses. The location header -- | Standard result set format used for all queries
is represented as a list of strings containing variable bindings like data ResultSet
@"k1=eq.42"@, or the empty list if there is no location header. = RSStandard
-} { rsTableTotal :: Maybe Int64
type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString, Either Error [GucHeader], Either Error (Maybe Status)) -- ^ count of all the table rows
, rsQueryTotal :: Int64
-- ^ count of the query rows
, rsLocation :: [(BS.ByteString, BS.ByteString)]
-- ^ The Location header(only used for inserts) is represented as a list of strings containing
-- variable bindings like @"k1=eq.42"@, or the empty list if there is no location header.
, rsBody :: BS.ByteString
-- ^ the aggregated body of the query
, rsGucHeaders :: Maybe BS.ByteString
-- ^ the HTTP headers to be added to the response
, rsGucStatus :: Maybe Text
-- ^ the HTTP status to be added to the response
}
| RSPlan BS.ByteString -- ^ the plan of the query
createWriteStatement :: SQL.Snippet -> SQL.Snippet -> Bool -> Bool -> Bool ->
PreferRepresentation -> [Text] -> Bool -> prepareWrite :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> ResultAggregate ->
SQL.Statement () ResultsWithCount Maybe PreferRepresentation -> [Text] -> Bool -> SQL.Statement () ResultSet
createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys = prepareWrite selectQuery mutateQuery isInsert mt rAgg rep pKeys =
SQL.dynamicallyParameterized snippet decodeStandard SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
snippet = snippet =
"WITH " <> SQL.sql sourceCTEName <> " AS (" <> mutateQuery <> ") " <> "WITH " <> sourceCTE <> " AS (" <> mutateQuery <> ") " <>
SQL.sql (
"SELECT " <> "SELECT " <>
"'' AS total_result_set, " <> "'' AS total_result_set, " <>
"pg_catalog.count(_postgrest_t) AS page_total, " <> "pg_catalog.count(_postgrest_t) AS page_total, " <>
locF <> " AS header, " <> locF <> " AS header, " <>
bodyF <> " AS body, " <> aggF Nothing rAgg <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <> responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status " responseStatusF <> " AS response_status " <>
) <>
"FROM (" <> selectF <> ") _postgrest_t" "FROM (" <> selectF <> ") _postgrest_t"
locF = locF =
if isInsert && rep `elem` [Full, HeadersOnly] if isInsert && rep == Just HeadersOnly
then BS.unwords [ then
"CASE WHEN pg_catalog.count(_postgrest_t) = 1", "CASE WHEN pg_catalog.count(_postgrest_t) = 1 " <>
"THEN coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")", "THEN coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ") " <>
"ELSE " <> noLocationF, "ELSE " <> noLocationF <> " " <>
"END"] "END"
else noLocationF else noLocationF
bodyF
| rep `elem` [None, HeadersOnly] = "''"
| asCsv = asCsvF
| wantSingle = asJsonSingleF False
| otherwise = asJsonF False
selectF selectF
-- prevent using any of the column names in ?select= when no response is returned from the CTE -- prevent using any of the column names in ?select= when no response is returned from the CTE
| rep `elem` [None, HeadersOnly] = SQL.sql ("SELECT * FROM " <> sourceCTEName) | rAgg == NoAgg = "SELECT * FROM " <> sourceCTE
| otherwise = selectQuery | otherwise = selectQuery
decodeStandard :: HD.Result ResultsWithCount decodeIt :: HD.Result ResultSet
decodeStandard = decodeIt = case mt of
fromMaybe (Nothing, 0, [], mempty, Right [], Right Nothing) <$> HD.rowMaybe standardRow MTPlan{} -> planRow
_ -> fromMaybe (RSStandard Nothing 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow False)
createReadStatement :: SQL.Snippet -> SQL.Snippet -> Bool -> Bool -> Bool -> Maybe FieldName -> Bool -> prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> ResultAggregate -> Bool -> SQL.Statement () ResultSet
SQL.Statement () ResultsWithCount prepareRead selectQuery countQuery countTotal mt rAgg =
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField = SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
SQL.dynamicallyParameterized snippet decodeStandard
where where
snippet = snippet =
"WITH " <> "WITH " <> sourceCTE <> " AS ( " <> selectQuery <> " ) " <>
SQL.sql sourceCTEName <> " AS ( " <> selectQuery <> " ) " <>
countCTEF <> " " <> countCTEF <> " " <>
SQL.sql ("SELECT " <>
countResultF <> " AS total_result_set, " <>
"pg_catalog.count(_postgrest_t) AS page_total, " <>
noLocationF <> " AS header, " <>
bodyF <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status " <>
"FROM ( SELECT * FROM " <> sourceCTEName <> " ) _postgrest_t")
(countCTEF, countResultF) = countF countQuery countTotal
bodyF
| asCsv = asCsvF
| isSingle = asJsonSingleF False
| isJust binaryField = asBinaryF $ fromJust binaryField
| otherwise = asJsonF False
decodeStandard :: HD.Result ResultsWithCount
decodeStandard =
HD.singleRow standardRow
{-| Read and Write api requests use a similar response format which includes
various record counts and possible location header. This is the decoder
for that common type of query.
-}
standardRow :: HD.Row ResultsWithCount
standardRow = (,,,,,) <$> nullableColumn HD.int8 <*> column HD.int8
<*> arrayColumn HD.bytea <*> column HD.bytea
<*> (fromMaybe (Right []) <$> nullableColumn decodeGucHeaders)
<*> (fromMaybe (Right Nothing) <$> nullableColumn decodeGucStatus)
type ProcResults = (Maybe Int64, Int64, ByteString, Either Error [GucHeader], Either Error (Maybe Status))
callProcStatement :: Bool -> Bool -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool ->
Bool -> Bool -> Bool -> Maybe FieldName -> Bool ->
SQL.Statement () ProcResults
callProcStatement returnsScalar returnsSingle callProcQuery selectQuery countQuery countTotal asSingle asCsv multObjects binaryField =
SQL.dynamicallyParameterized snippet decodeProc
where
snippet =
"WITH " <> SQL.sql sourceCTEName <> " AS (" <> callProcQuery <> ") " <>
countCTEF <>
SQL.sql (
"SELECT " <> "SELECT " <>
countResultF <> " AS total_result_set, " <> countResultF <> " AS total_result_set, " <>
"pg_catalog.count(_postgrest_t) AS page_total, " <> "pg_catalog.count(_postgrest_t) AS page_total, " <>
bodyF <> " AS body, " <> aggF Nothing rAgg <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <> responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status ") <> responseStatusF <> " AS response_status " <>
"FROM ( SELECT * FROM " <> sourceCTE <> " ) _postgrest_t"
(countCTEF, countResultF) = countF countQuery countTotal
decodeIt :: HD.Result ResultSet
decodeIt = case mt of
MTPlan{} -> planRow
_ -> HD.singleRow $ standardRow True
prepareCall :: Routine -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool ->
MediaType -> ResultAggregate -> Bool ->
SQL.Statement () ResultSet
prepareCall rout callProcQuery selectQuery countQuery countTotal mt rAgg =
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where
snippet =
"WITH " <> sourceCTE <> " AS (" <> callProcQuery <> ") " <>
countCTEF <>
"SELECT " <>
countResultF <> " AS total_result_set, " <>
"pg_catalog.count(_postgrest_t) AS page_total, " <>
aggF (Just rout) rAgg <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status " <>
"FROM (" <> selectQuery <> ") _postgrest_t" "FROM (" <> selectQuery <> ") _postgrest_t"
(countCTEF, countResultF) = countF countQuery countTotal (countCTEF, countResultF) = countF countQuery countTotal
bodyF decodeIt :: HD.Result ResultSet
| asSingle = asJsonSingleF returnsScalar decodeIt = case mt of
| asCsv = asCsvF MTPlan{} -> planRow
| isJust binaryField = asBinaryF $ fromJust binaryField _ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow True)
| returnsSingle
&& not multObjects = asJsonSingleF returnsScalar
| otherwise = asJsonF returnsScalar
decodeProc :: HD.Result ProcResults preparePlanRows :: SQL.Snippet -> Bool -> SQL.Statement () (Maybe Int64)
decodeProc = preparePlanRows countQuery =
fromMaybe (Just 0, 0, mempty, defGucHeaders, defGucStatus) <$> HD.rowMaybe procRow SQL.dynamicallyParameterized snippet decodeIt
where where
defGucHeaders = Right [] snippet = explainF PlanJSON mempty countQuery
defGucStatus = Right Nothing decodeIt :: HD.Result (Maybe Int64)
procRow = (,,,,) <$> nullableColumn HD.int8 <*> column HD.int8 decodeIt =
<*> column HD.bytea
<*> (fromMaybe defGucHeaders <$> nullableColumn decodeGucHeaders)
<*> (fromMaybe defGucStatus <$> nullableColumn decodeGucStatus)
createExplainStatement :: SQL.Snippet -> Bool -> SQL.Statement () (Maybe Int64)
createExplainStatement countQuery =
SQL.dynamicallyParameterized snippet decodeExplain
where
snippet = "EXPLAIN (FORMAT JSON) " <> countQuery
-- |
-- An `EXPLAIN (FORMAT JSON) select * from items;` output looks like this:
-- [{
-- "Plan": {
-- "Node Type": "Seq Scan", "Parallel Aware": false, "Relation Name": "items",
-- "Alias": "items", "Startup Cost": 0.00, "Total Cost": 32.60,
-- "Plan Rows": 2260,"Plan Width": 8} }]
-- We only obtain the Plan Rows here.
decodeExplain :: HD.Result (Maybe Int64)
decodeExplain =
let row = HD.singleRow $ column HD.bytea in let row = HD.singleRow $ column HD.bytea in
(^? L.nth 0 . L.key "Plan" . L.key "Plan Rows" . L._Integral) <$> row (^? L.nth 0 . L.key "Plan" . L.key "Plan Rows" . L._Integral) <$> row
decodeGucHeaders :: HD.Value (Either Error [GucHeader]) standardRow :: Bool -> HD.Row ResultSet
decodeGucHeaders = first (const GucHeadersError) . JSON.eitherDecode . LBS.fromStrict <$> HD.bytea standardRow noLocation =
RSStandard <$> nullableColumn HD.int8 <*> column HD.int8
<*> (if noLocation then pure mempty else fmap splitKeyValue <$> arrayColumn HD.bytea) <*> column HD.bytea
<*> nullableColumn HD.bytea
<*> nullableColumn HD.text
where
splitKeyValue :: ByteString -> (ByteString, ByteString)
splitKeyValue kv =
let (k, v) = BS.break (== '=') kv in
(k, BS.tail v)
decodeGucStatus :: HD.Value (Either Error (Maybe Status)) mtSnippet :: MediaType -> SQL.Snippet -> SQL.Snippet
decodeGucStatus = first (const GucStatusError) . fmap (Just . toEnum . fst) . decimal <$> HD.text mtSnippet mediaType snippet = case mediaType of
MTPlan _ fmt opts -> explainF fmt opts snippet
_ -> snippet
-- | We use rowList because when doing EXPLAIN (FORMAT TEXT), the result comes as many rows. FORMAT JSON comes as one.
planRow :: HD.Result ResultSet
planRow = RSPlan . BS.unlines <$> HD.rowList (column HD.bytea)
column :: HD.Value a -> HD.Row a column :: HD.Value a -> HD.Row a
column = HD.column . HD.nonNullable column = HD.column . HD.nonNullable
+25 -6
View File
@@ -10,6 +10,9 @@ module PostgREST.RangeQuery (
, restrictRange , restrictRange
, rangeGeq , rangeGeq
, allRange , allRange
, limitZeroRange
, hasLimitZero
, convertToLimitZeroRange
, NonnegRange , NonnegRange
, rangeStatusHeader , rangeStatusHeader
, contentRangeH , contentRangeH
@@ -34,13 +37,14 @@ rangeParse :: BS.ByteString -> NonnegRange
rangeParse range = do rangeParse range = do
let rangeRegex = "^([0-9]+)-([0-9]*)$" :: BS.ByteString let rangeRegex = "^([0-9]+)-([0-9]*)$" :: BS.ByteString
case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of case range =~ rangeRegex :: [[BS.ByteString]] of
Just parsedRange -> [[_, l, u]] ->
let [_, mLower, mUpper] = readMaybe . BS.unpack <$> parsedRange let lower = maybe emptyRange rangeGeq (readInteger l)
lower = maybe emptyRange rangeGeq mLower upper = maybe allRange rangeLeq (readInteger u) in
upper = maybe allRange rangeLeq mUpper in
rangeIntersection lower upper rangeIntersection lower upper
Nothing -> allRange _ -> allRange
where
readInteger = readMaybe . BS.unpack
rangeRequested :: RequestHeaders -> NonnegRange rangeRequested :: RequestHeaders -> NonnegRange
rangeRequested headers = maybe allRange rangeParse $ lookup hRange headers rangeRequested headers = maybe allRange rangeParse $ lookup hRange headers
@@ -74,6 +78,21 @@ rangeLeq :: Integer -> NonnegRange
rangeLeq n = rangeLeq n =
Range BoundaryBelowAll (BoundaryAbove n) Range BoundaryBelowAll (BoundaryAbove n)
-- Special case to allow limit 0 queries
-- https://github.com/PostgREST/postgrest/issues/1121
-- 0 <= x <= -1
limitZeroRange :: Range Integer
limitZeroRange = Range (BoundaryBelow 0) (BoundaryAbove (-1))
hasLimitZero :: Range Integer -> Bool
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
-518
View File
@@ -1,518 +0,0 @@
{-|
Module : PostgREST.Request.ApiRequest
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Request.ApiRequest
( ApiRequest(..)
, InvokeMethod(..)
, ContentType(..)
, Action(..)
, Target(..)
, Payload(..)
, userApiRequest
) where
import qualified Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.CaseInsensitive as CI
import qualified Data.Csv as CSV
import qualified Data.HashMap.Strict as M
import qualified Data.List as L
import qualified Data.List.NonEmpty as NonEmptyList
import qualified Data.Set as S
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Vector as V
import Control.Arrow ((***))
import Data.Aeson.Types (emptyArray, emptyObject)
import Data.List (last, lookup, partition, union)
import Data.Maybe (fromJust)
import Data.Ranged.Boundaries (Boundary (..))
import Data.Ranged.Ranges (Range (..), emptyRange,
rangeIntersection)
import Network.HTTP.Base (urlEncodeVars)
import Network.HTTP.Types.Header (hAuthorization, hCookie)
import Network.HTTP.Types.URI (parseQueryReplacePlus,
parseSimpleQuery)
import Network.Wai (Request (..))
import Network.Wai.Parse (parseHttpAccept)
import Web.Cookie (parseCookies)
import PostgREST.Config (AppConfig (..),
OpenAPIMode (..))
import PostgREST.ContentType (ContentType (..))
import PostgREST.DbStructure (DbStructure (..))
import PostgREST.DbStructure.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema)
import PostgREST.DbStructure.Proc (ProcDescription (..),
ProcParam (..), ProcsMap)
import PostgREST.Error (ApiRequestError (..))
import PostgREST.Query.SqlFragment (ftsOperators, operators)
import PostgREST.RangeQuery (NonnegRange, allRange,
rangeGeq, rangeLimit,
rangeOffset, rangeRequested,
restrictRange)
import PostgREST.Request.Parsers (pRequestColumns)
import PostgREST.Request.Preferences (PreferCount (..),
PreferParameters (..),
PreferRepresentation (..),
PreferResolution (..),
PreferTransaction (..))
import qualified PostgREST.ContentType as ContentType
import qualified PostgREST.Request.Preferences as Preferences
import Protolude
type RequestBody = LBS.ByteString
data Payload
= ProcessedJSON -- ^ Cached attributes of a JSON payload
{ payRaw :: LBS.ByteString
-- ^ This is the raw ByteString that comes from the request body. We
-- cache this instead of an Aeson Value because it was detected that for
-- large payloads the encoding had high memory usage, see
-- https://github.com/PostgREST/postgrest/pull/1005 for more details
, payKeys :: S.Set Text
-- ^ Keys of the object or if it's an array these keys are guaranteed to
-- be the same across all its objects
}
| RawJSON { payRaw :: LBS.ByteString }
| RawPay { payRaw :: LBS.ByteString }
data InvokeMethod = InvHead | InvGet | InvPost deriving Eq
-- | Types of things a user wants to do to tables/views/procs
data Action = ActionCreate | ActionRead{isHead :: Bool}
| ActionUpdate | ActionDelete
| ActionSingleUpsert | ActionInvoke InvokeMethod
| ActionInfo | ActionInspect{isHead :: Bool}
deriving Eq
-- | The path info that will be mapped to a target (used to handle validations and errors before defining the Target)
data Path
= PathInfo
{ pSchema :: Schema,
pName :: Text,
pHasRpc :: Bool,
pIsDefaultSpec :: Bool,
pIsRootSpec :: Bool
}
| PathUnknown
-- | The target db object of a user action
data Target = TargetIdent QualifiedIdentifier
| TargetProc{tProc :: ProcDescription, tpIsRootSpec :: Bool}
| TargetDefaultSpec{tdsSchema :: Schema} -- The default spec offered at root "/"
| TargetUnknown
-- | RPC query param value `/rpc/func?v=<value>`, used for VARIADIC functions on form-urlencoded POST and GETs
-- | It can be fixed `?v=1` or repeated `?v=1&v=2&v=3.
data RpcParamValue = Fixed Text | Variadic [Text]
instance JSON.ToJSON RpcParamValue where
toJSON (Fixed v) = JSON.toJSON v
toJSON (Variadic v) = JSON.toJSON v
toRpcParamValue :: ProcDescription -> (Text, Text) -> (Text, RpcParamValue)
toRpcParamValue proc (k, v) | prmIsVariadic k = (k, Variadic [v])
| otherwise = (k, Fixed v)
where
prmIsVariadic prm = isJust $ find (\ProcParam{ppName, ppVar} -> ppName == prm && ppVar) $ pdParams proc
-- | Convert rpc params `/rpc/func?a=val1&b=val2` to json `{"a": "val1", "b": "val2"}
jsonRpcParams :: ProcDescription -> [(Text, Text)] -> Payload
jsonRpcParams proc prms =
if not $ pdHasVariadic proc then -- if proc has no variadic param, save steps and directly convert to json
ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> prms) (S.fromList $ fst <$> prms)
else
let paramsMap = M.fromListWith mergeParams $ toRpcParamValue proc <$> prms in
ProcessedJSON (JSON.encode paramsMap) (S.fromList $ M.keys paramsMap)
where
mergeParams :: RpcParamValue -> RpcParamValue -> RpcParamValue
mergeParams (Variadic a) (Variadic b) = Variadic $ b ++ a
mergeParams v _ = v -- repeated params for non-variadic parameters are not merged
targetToJsonRpcParams :: Maybe Target -> [(Text, Text)] -> Maybe Payload
targetToJsonRpcParams target params =
case target of
Just TargetProc{tProc} -> Just $ jsonRpcParams tProc params
_ -> Nothing
{-|
Describes what the user wants to do. This data type is a
translation of the raw elements of an HTTP request into domain
specific language. There is no guarantee that the intent is
sensible, it is up to a later stage of processing to determine
if it is an action we are able to perform.
-}
data ApiRequest = ApiRequest {
iAction :: Action -- ^ Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
, iRange :: M.HashMap Text NonnegRange -- ^ Requested range of rows within response
, iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level
, iTarget :: Target -- ^ The target, be it calling a proc or accessing a table
, iPayload :: Maybe Payload -- ^ Data sent by client and used for mutation actions
, iPreferRepresentation :: PreferRepresentation -- ^ If client wants created items echoed back
, iPreferParameters :: Maybe PreferParameters -- ^ How to pass parameters to a stored procedure
, iPreferCount :: Maybe PreferCount -- ^ Whether the client wants a result count
, iPreferResolution :: Maybe PreferResolution -- ^ Whether the client wants to UPSERT or ignore records on PK conflict
, iPreferTransaction :: Maybe PreferTransaction -- ^ Whether the clients wants to commit or rollback the transaction
, iFilters :: [(Text, Text)] -- ^ Filters on the result ("id", "eq.10")
, iLogic :: [(Text, Text)] -- ^ &and and &or parameters used for complex boolean logic
, iSelect :: Maybe Text -- ^ &select parameter used to shape the response
, iOnConflict :: Maybe Text -- ^ &on_conflict parameter used to upsert on specific unique keys
, iColumns :: S.Set FieldName -- ^ parsed colums from &columns parameter and payload
, iOrder :: [(Text, Text)] -- ^ &order parameters for each level
, iCanonicalQS :: ByteString -- ^ Alphabetized (canonical) request query string for response URLs
, iJWT :: Text -- ^ JSON Web Token
, iHeaders :: [(ByteString, ByteString)] -- ^ HTTP request headers
, iCookies :: [(ByteString, ByteString)] -- ^ Request Cookies
, iPath :: ByteString -- ^ Raw request path
, iMethod :: ByteString -- ^ Raw request method
, iProfile :: Maybe Schema -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/.
, iSchema :: Schema -- ^ The request schema. Can vary depending on iProfile.
, iAcceptContentType :: ContentType
}
-- | Examines HTTP request and translates it into user intent.
userApiRequest :: AppConfig -> DbStructure -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest conf@AppConfig{..} dbStructure req reqBody
| isJust profile && fromJust profile `notElem` configDbSchemas = Left $ UnacceptableSchema $ toList configDbSchemas
| isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate
| topLevelRange == emptyRange = Left InvalidRange
| shouldParsePayload && isLeft payload = either (Left . InvalidBody) witness payload
| isLeft parsedColumns = either Left witness parsedColumns
| otherwise = do
acceptContentType <- findAcceptContentType conf action path accepts
checkedTarget <- target
return ApiRequest {
iAction = action
, iTarget = checkedTarget
, iRange = ranges
, iTopLevelRange = topLevelRange
, iPayload = relevantPayload
, iPreferRepresentation = fromMaybe None preferRepresentation
, iPreferParameters = preferParameters
, iPreferCount = preferCount
, iPreferResolution = preferResolution
, iPreferTransaction = preferTransaction
, iFilters = filters
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
, iSelect = toS <$> join (lookup "select" qParams)
, iOnConflict = toS <$> join (lookup "on_conflict" qParams)
, iColumns = payloadColumns
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
, iCanonicalQS = BS.pack $ urlEncodeVars
. L.sortOn fst
. map (join (***) BS.unpack . second (fromMaybe mempty))
$ qString
, iJWT = tokenStr
, iHeaders = [ (CI.foldedCase k, v) | (k,v) <- hdrs, k /= hCookie]
, iCookies = maybe [] parseCookies $ lookupHeader "Cookie"
, iPath = rawPathInfo req
, iMethod = method
, iProfile = profile
, iSchema = schema
, iAcceptContentType = acceptContentType
}
where
accepts = maybe [CTAny] (map ContentType.decodeContentType . parseHttpAccept) $ lookupHeader "accept"
-- queryString with '+' converted to ' '(space)
qString = parseQueryReplacePlus True $ rawQueryString req
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
(filters, rpcQParams) =
case action of
ActionInvoke InvGet -> partitionFlts
ActionInvoke InvHead -> partitionFlts
_ -> (flts, [])
partitionFlts = partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
flts =
[ (toS k, toS $ fromJust v) |
(k,v) <- qParams, isJust v,
k `notElem` ["select", "columns"],
not (endingIn ["order", "limit", "offset", "and", "or"] k) ]
hasOperator val = any (`T.isPrefixOf` val) $
((<> ".") <$> "not":M.keys operators) ++
((<> "(") <$> M.keys ftsOperators)
isEmbedPath = T.isInfixOf "."
isTargetingProc = case path of
PathInfo{pHasRpc, pIsRootSpec} -> pHasRpc || pIsRootSpec
_ -> False
isTargetingDefaultSpec = case path of
PathInfo{pIsDefaultSpec=True} -> True
_ -> False
contentType = maybe CTApplicationJSON ContentType.decodeContentType $ lookupHeader "content-type"
columns
| action `elem` [ActionCreate, ActionUpdate, ActionInvoke InvPost] = toS <$> join (lookup "columns" qParams)
| otherwise = Nothing
parsedColumns = pRequestColumns columns
payloadColumns =
case (contentType, action) of
(_, ActionInvoke InvGet) -> S.fromList $ fst <$> rpcQParams
(_, ActionInvoke InvHead) -> S.fromList $ fst <$> rpcQParams
(CTUrlEncoded, _) -> S.fromList $ map (T.decodeUtf8 . fst) $ parseSimpleQuery $ LBS.toStrict reqBody
_ -> case (relevantPayload, fromRight Nothing parsedColumns) of
(Just ProcessedJSON{payKeys}, _) -> payKeys
(Just RawJSON{}, Just cls) -> cls
_ -> S.empty
payload :: Either ByteString Payload
payload = case contentType of
CTApplicationJSON ->
if isJust columns
then Right $ RawJSON reqBody
else note "All object keys must match" . payloadAttributes reqBody
=<< if LBS.null reqBody && isTargetingProc
then Right emptyObject
else first BS.pack $ JSON.eitherDecode reqBody
CTTextCSV -> do
json <- csvToJson <$> first BS.pack (CSV.decodeByName reqBody)
note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
CTUrlEncoded ->
let paramsMap = M.fromList $ (T.decodeUtf8 *** JSON.String . T.decodeUtf8) <$> parseSimpleQuery (LBS.toStrict reqBody) in
Right $ ProcessedJSON (JSON.encode paramsMap) $ S.fromList (M.keys paramsMap)
ct ->
if isTargetingProc && ct `elem` [CTTextPlain, CTOctetStream]
then Right $ RawPay reqBody
else Left $ "Content-Type not acceptable: " <> ContentType.toMime ct
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges -- if no limit is specified, get all the request rows
action =
case method of
-- The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response
-- From https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
"HEAD" | isTargetingDefaultSpec -> ActionInspect{isHead=True}
| isTargetingProc -> ActionInvoke InvHead
| otherwise -> ActionRead{isHead=True}
"GET" | isTargetingDefaultSpec -> ActionInspect{isHead=False}
| isTargetingProc -> ActionInvoke InvGet
| otherwise -> ActionRead{isHead=False}
"POST" -> if isTargetingProc
then ActionInvoke InvPost
else ActionCreate
"PATCH" -> ActionUpdate
"PUT" -> ActionSingleUpsert
"DELETE" -> ActionDelete
"OPTIONS" -> ActionInfo
_ -> ActionInspect{isHead=False}
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 action of
-- POST/PATCH/PUT/DELETE don't use the same header as per the spec
ActionCreate -> contentProfile
ActionUpdate -> contentProfile
ActionSingleUpsert -> contentProfile
ActionDelete -> contentProfile
ActionInvoke InvPost -> 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 =
let
callFindProc procSch procNam = findProc
(QualifiedIdentifier procSch procNam) payloadColumns (preferParameters == Just SingleObject) (dbProcs dbStructure)
contentType (action == ActionInvoke InvPost)
in
case path of
PathInfo{pSchema, pName, pHasRpc, pIsRootSpec, pIsDefaultSpec}
| pHasRpc || pIsRootSpec -> (`TargetProc` pIsRootSpec) <$> callFindProc pSchema pName
| pIsDefaultSpec -> Right $ TargetDefaultSpec pSchema
| otherwise -> Right $ TargetIdent $ QualifiedIdentifier pSchema pName
PathUnknown -> Right TargetUnknown
shouldParsePayload = case (contentType, action) of
(CTUrlEncoded, ActionInvoke InvPost) -> False
(_, act) -> act `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke InvPost]
relevantPayload = case (contentType, action) of
-- Though ActionInvoke GET/HEAD doesn't really have a payload, we use the payload variable as a way
-- to store the query string arguments to the function.
(_, ActionInvoke InvGet) -> targetToJsonRpcParams (rightToMaybe target) rpcQParams
(_, ActionInvoke InvHead) -> targetToJsonRpcParams (rightToMaybe target) rpcQParams
(CTUrlEncoded, ActionInvoke InvPost) -> targetToJsonRpcParams (rightToMaybe target) $ (T.decodeUtf8 *** T.decodeUtf8) <$> parseSimpleQuery (LBS.toStrict reqBody)
_ | shouldParsePayload -> rightToMaybe payload
| otherwise -> Nothing
path =
case pathInfo req of
[] -> case configDbRootSpec of
Just (QualifiedIdentifier pSch pName) -> PathInfo (if pSch == mempty then schema else pSch) pName False False True
Nothing | configOpenApiMode == OADisabled -> PathUnknown
| otherwise -> PathInfo schema "" False True False
[table] -> PathInfo schema table False False False
["rpc", pName] -> PathInfo schema pName True False False
_ -> PathUnknown
method = requestMethod req
hdrs = requestHeaders req
qParams = [(T.decodeUtf8 k, T.decodeUtf8 <$> v)|(k,v) <- qString]
lookupHeader = flip lookup hdrs
Preferences.Preferences{..} = Preferences.fromHeaders hdrs
auth = fromMaybe "" $ lookupHeader hAuthorization
tokenStr = case T.split (== ' ') (T.decodeUtf8 auth) of
("Bearer" : t : _) -> t
("bearer" : t : _) -> t
_ -> ""
endingIn:: [Text] -> Text -> Bool
endingIn xx key = lastWord `elem` xx
where lastWord = last $ T.split (=='.') key
headerRange = rangeRequested hdrs
replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) ++ [x]
limitParams :: M.HashMap Text NonnegRange
limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe . toS =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]
offsetParams :: M.HashMap Text NonnegRange
offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe . toS =<< v)) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]
urlRange = M.unionWith f limitParams offsetParams
where
f rl ro = Range (BoundaryBelow o) (BoundaryAbove $ o + l - 1)
where
l = fromMaybe 0 $ rangeLimit rl
o = rangeOffset ro
ranges = M.insert "limit" (rangeIntersection headerRange (fromMaybe allRange (M.lookup "limit" urlRange))) urlRange
{-|
Find the best match from a list of content types accepted by the
client in order of decreasing preference and a list of types
producible by the server. If there is no match but the client
accepts */* then return the top server pick.
-}
mutuallyAgreeable :: [ContentType] -> [ContentType] -> Maybe ContentType
mutuallyAgreeable sProduces cAccepts =
let exact = listToMaybe $ L.intersect cAccepts sProduces in
if isNothing exact && CTAny `elem` cAccepts
then listToMaybe sProduces
else exact
type CsvData = V.Vector (M.HashMap Text LBS.ByteString)
{-|
Converts CSV like
a,b
1,hi
2,bye
into a JSON array like
[ {"a": "1", "b": "hi"}, {"a": 2, "b": "bye"} ]
The reason for its odd signature is so that it can compose
directly with CSV.decodeByName
-}
csvToJson :: (CSV.Header, CsvData) -> JSON.Value
csvToJson (_, vals) =
JSON.Array $ V.map rowToJsonObj vals
where
rowToJsonObj = JSON.Object .
M.map (\str ->
if str == "NULL"
then JSON.Null
else JSON.String . T.decodeUtf8 $ LBS.toStrict str
)
payloadAttributes :: RequestBody -> JSON.Value -> Maybe Payload
payloadAttributes raw json =
-- Test that Array contains only Objects having the same keys
case json of
JSON.Array arr ->
case arr V.!? 0 of
Just (JSON.Object o) ->
let canonicalKeys = S.fromList $ M.keys o
areKeysUniform = all (\case
JSON.Object x -> S.fromList (M.keys x) == canonicalKeys
_ -> False) arr in
if areKeysUniform
then Just $ ProcessedJSON raw canonicalKeys
else Nothing
Just _ -> Nothing
Nothing -> Just emptyPJArray
JSON.Object o -> Just $ ProcessedJSON raw (S.fromList $ M.keys o)
-- truncate everything else to an empty array.
_ -> Just emptyPJArray
where
emptyPJArray = ProcessedJSON (JSON.encode emptyArray) S.empty
findAcceptContentType :: AppConfig -> Action -> Path -> [ContentType] -> Either ApiRequestError ContentType
findAcceptContentType conf action path accepts =
case mutuallyAgreeable (requestContentTypes conf action path) accepts of
Just ct ->
Right ct
Nothing ->
Left . ContentTypeError $ map ContentType.toMime accepts
requestContentTypes :: AppConfig -> Action -> Path -> [ContentType]
requestContentTypes conf action path =
case action of
ActionRead _ -> defaultContentTypes ++ rawContentTypes conf
ActionInvoke _ -> invokeContentTypes
ActionInspect _ -> [CTOpenAPI, CTApplicationJSON]
ActionInfo -> [CTTextCSV]
_ -> defaultContentTypes
where
invokeContentTypes =
defaultContentTypes
++ rawContentTypes conf
++ [CTOpenAPI | pIsRootSpec path]
defaultContentTypes =
[CTApplicationJSON, CTSingularJSON, CTTextCSV]
rawContentTypes :: AppConfig -> [ContentType]
rawContentTypes AppConfig{..} =
(ContentType.decodeContentType <$> configRawMediaTypes) `union` [CTOctetStream, CTTextPlain]
{-|
Search a pg proc by matching name and arguments keys to parameters. Since a function can be overloaded,
the name is not enough to find it. An overloaded function can have a different volatility or even a different return type.
-}
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> ContentType -> Bool -> Either ApiRequestError ProcDescription
findProc qi argumentsKeys paramsAsSingleObject allProcs contentType isInvPost =
case matchProc of
([], []) -> Left $ NoRpc (qiSchema qi) (qiName qi) (S.toList argumentsKeys) paramsAsSingleObject contentType isInvPost
-- If there are no functions with named arguments, fallback to the single unnamed argument function
([], [proc]) -> Right proc
([], procs) -> Left $ AmbiguousRpc (toList procs)
-- Matches the functions with named arguments
([proc], _) -> Right proc
(procs, _) -> Left $ AmbiguousRpc (toList procs)
where
matchProc = overloadedProcPartition $ M.lookupDefault mempty qi allProcs -- first find the proc by name
-- The partition obtained has the form (overloadedProcs,fallbackProcs)
-- where fallbackProcs are functions with a single unnamed parameter
overloadedProcPartition procs = foldr select ([],[]) procs
select proc ~(ts,fs)
| matchesParams proc = (proc:ts,fs)
| hasSingleUnnamedParam proc = (ts,proc:fs)
| otherwise = (ts,fs)
-- If the function is called with post and has a single unnamed parameter
-- it can be called depending on content type and the parameter type
hasSingleUnnamedParam proc = isInvPost && case pdParams proc of
[ProcParam "" ppType _ _]
| contentType == CTApplicationJSON -> ppType `elem` ["json", "jsonb"]
| contentType == CTTextPlain -> ppType == "text"
| contentType == CTOctetStream -> ppType == "bytea"
| otherwise -> False
_ -> False
matchesParams proc =
let params = pdParams proc in
-- exceptional case for Prefer: params=single-object
if paramsAsSingleObject
then length params == 1 && (ppType <$> headMay params) `elem` [Just "json", Just "jsonb"]
-- If the function has no parameters, the arguments keys must be empty as well
else if null params
then null argumentsKeys && contentType `notElem` [CTTextPlain, CTOctetStream]
-- A function has optional and required parameters. Optional parameters have a default value and
-- don't require arguments for the function to be executed, required parameters must have an argument present.
else case L.partition ppReq params of
-- If the function only has required parameters, the arguments keys must match those parameters
(reqParams, []) -> argumentsKeys == S.fromList (ppName <$> reqParams)
-- If the function only has optional parameters, the arguments keys can match none or any of them(a subset)
([], optParams) -> argumentsKeys `S.isSubsetOf` S.fromList (ppName <$> optParams)
-- 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.
(reqParams, optParams) -> argumentsKeys `S.difference` S.fromList (ppName <$> optParams) == S.fromList (ppName <$> reqParams)
-397
View File
@@ -1,397 +0,0 @@
{-|
Module : PostgREST.Request.DbRequestBuilder
Description : PostgREST database request builder
This module is in charge of building an intermediate
representation(ReadRequest, MutateRequest) between the HTTP request and the
final resulting SQL query.
A query tree is built in case of resource embedding. By inferring the
relationship between tables, join conditions are added for every embedded
resource.
-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Request.DbRequestBuilder
( readRequest
, mutateRequest
, callRequest
) where
import qualified Data.HashMap.Strict as M
import qualified Data.Set as S
import Control.Arrow ((***))
import Data.Either.Combinators (mapLeft)
import Data.List (delete)
import Data.Text (isInfixOf)
import Data.Tree (Tree (..))
import PostgREST.DbStructure.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema, TableName)
import PostgREST.DbStructure.Proc (ProcDescription (..),
ProcParam (..),
procReturnsScalar)
import PostgREST.DbStructure.Relationship (Cardinality (..),
Junction (..),
Relationship (..))
import PostgREST.DbStructure.Table (Column (..), Table (..),
tableQi)
import PostgREST.Error (ApiRequestError (..),
Error (..))
import PostgREST.Query.SqlFragment (sourceCTEName)
import PostgREST.RangeQuery (NonnegRange, allRange,
restrictRange)
import PostgREST.Request.ApiRequest (Action (..),
ApiRequest (..),
Payload (..))
import PostgREST.Request.Parsers
import PostgREST.Request.Preferences
import PostgREST.Request.Types
import qualified PostgREST.DbStructure.Relationship as Relationship
import Protolude hiding (from)
-- | Builds the ReadRequest tree on a number of stages.
-- | Adds filters, order, limits on its respective nodes.
-- | Adds joins conditions obtained from resource embedding.
readRequest :: Schema -> TableName -> Maybe Integer -> [Relationship] -> ApiRequest -> Either Error ReadRequest
readRequest schema rootTableName maxRows allRels apiRequest =
mapLeft ApiRequestError $
treeRestrictRange maxRows =<<
augmentRequestWithJoin schema rootRels =<<
(addFiltersOrdersRanges apiRequest . initReadRequest rootName =<< pRequestSelect sel)
where
sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param
(rootName, rootRels) = rootWithRels schema rootTableName allRels (iAction apiRequest)
-- Get the root table name with its relationships according to the Action type.
-- This is done because of the shape of the final SQL Query. The mutation cases
-- are wrapped in a WITH {sourceCTEName}(see Statements.hs). So we need a FROM
-- {sourceCTEName} instead of FROM {tableName}.
rootWithRels :: Schema -> TableName -> [Relationship] -> Action -> (QualifiedIdentifier, [Relationship])
rootWithRels schema rootTableName allRels action = case action of
ActionRead _ -> (QualifiedIdentifier schema rootTableName, allRels) -- normal read case
_ -> (QualifiedIdentifier mempty _sourceCTEName, mapMaybe toSourceRel allRels ++ allRels) -- mutation cases and calling proc
where
_sourceCTEName = decodeUtf8 sourceCTEName
-- To enable embedding in the sourceCTEName cases we need to replace the
-- foreign key tableName in the Relationship with {sourceCTEName}. This way
-- findRel can find relationships with sourceCTEName.
toSourceRel :: Relationship -> Maybe Relationship
toSourceRel r@Relationship{relTable=t}
| rootTableName == tableName t = Just $ r {relTable=t {tableName=_sourceCTEName}}
| otherwise = Nothing
-- Build the initial tree with a Depth attribute so when a self join occurs we
-- can differentiate the parent and child tables by having an alias like
-- "table_depth", this is related to
-- http://github.com/PostgREST/postgrest/issues/987.
initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadRequest
initReadRequest rootQi =
foldr (treeEntry rootDepth) initial
where
rootDepth = 0
rootSchema = qiSchema rootQi
rootName = qiName rootQi
initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, Nothing, rootDepth)) []
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
treeEntry depth (Node fld@((fn, _),_,alias, hint, joinType) fldForest) (Node (q, i) rForest) =
let nxtDepth = succ depth in
case fldForest of
[] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) $
foldr (treeEntry nxtDepth)
(Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,
(fn, Nothing, alias, hint, joinType, nxtDepth)) [])
fldForest:rForest
-- | Enforces the `max-rows` config on the result
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
treeRestrictRange maxRows request = pure $ nodeRestrictRange maxRows <$> request
where
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
augmentRequestWithJoin :: Schema -> [Relationship] -> ReadRequest -> Either ApiRequestError ReadRequest
augmentRequestWithJoin schema allRels request =
addRels schema allRels Nothing request
>>= addJoinConditions Nothing
addRels :: Schema -> [Relationship] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, joinType, depth)) forest) =
case parentNode of
Just (Node (Select{from=parentNodeQi}, _) _) ->
let newFrom r = if qiName tbl == nodeName then tableQi (relForeignTable r) else tbl
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, hint, joinType, depth))) <$> rel
rel = findRel schema allRels (qiName parentNodeQi) nodeName hint
in
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
_ ->
let rn = (query, (nodeName, Nothing, alias, Nothing, joinType, depth)) in
Node rn <$> updateForest (Just $ Node rn forest)
where
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
updateForest rq = addRels schema allRels rq `traverse` forest
-- Finds a relationship between an origin and a target in the request:
-- /origin?select=target(*) If more than one relationship is found then the
-- request is ambiguous and we return an error. In that case the request can
-- be disambiguated by adding precision to the target or by using a hint:
-- /origin?select=target!hint(*) The elements will be matched according to
-- these rules:
-- origin = table / view
-- target = table / view / constraint / column-from-origin
-- hint = table / view / constraint / column-from-origin / column-from-target
-- (hint can take table / view values to aid in finding the junction in an m2m relationship)
findRel :: Schema -> [Relationship] -> NodeName -> NodeName -> Maybe Hint -> Either ApiRequestError Relationship
findRel schema allRels origin target hint =
case rel of
[] -> Left $ NoRelBetween origin target
[r] -> Right r
-- Here we handle a self reference relationship to not cause a breaking
-- change: In a self reference we get two relationships with the same
-- foreign key and relTable/relFtable but with different
-- cardinalities(m2o/o2m) We output the O2M rel, the M2O rel can be
-- obtained by using the origin column as an embed hint.
rs@[rel0, rel1] -> case (relCardinality rel0, relCardinality rel1, relTable rel0 == relTable rel1 && relForeignTable rel0 == relForeignTable rel1) of
(O2M cons1, M2O cons2, True) -> if cons1 == cons2 then Right rel0 else Left $ AmbiguousRelBetween origin target rs
(M2O cons1, O2M cons2, True) -> if cons1 == cons2 then Right rel1 else Left $ AmbiguousRelBetween origin target rs
_ -> Left $ AmbiguousRelBetween origin target rs
rs -> Left $ AmbiguousRelBetween origin target rs
where
matchFKSingleCol hint_ cols = length cols == 1 && hint_ == (colName <$> head cols)
matchConstraint tar card = case card of
O2M cons -> tar == Just cons
M2O cons -> tar == Just cons
_ -> False
matchJunction hint_ card = case card of
M2M Junction{junTable} -> hint_ == Just (tableName junTable)
_ -> False
rel = filter (
\Relationship{..} ->
-- Both relationship ends need to be on the exposed schema
schema == tableSchema relTable && schema == tableSchema relForeignTable &&
(
-- /projects?select=clients(*)
origin == tableName relTable && -- projects
target == tableName relForeignTable || -- clients
-- /projects?select=projects_client_id_fkey(*)
(
origin == tableName relTable && -- projects
matchConstraint (Just target) relCardinality -- projects_client_id_fkey
) ||
-- /projects?select=client_id(*)
(
origin == tableName relTable && -- projects
matchFKSingleCol (Just target) relColumns -- client_id
)
) && (
isNothing hint || -- hint is optional
-- /projects?select=clients!projects_client_id_fkey(*)
matchConstraint hint relCardinality || -- projects_client_id_fkey
-- /projects?select=clients!client_id(*) or /projects?select=clients!id(*)
matchFKSingleCol hint relColumns || -- client_id
matchFKSingleCol hint relForeignColumns || -- id
-- /users?select=tasks!users_tasks(*) many-to-many between users and tasks
matchJunction hint relCardinality -- users_tasks
)
) allRels
-- previousAlias is only used for the case of self joins
addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, _, depth)) forest) =
case rel of
Just r@Relationship{relCardinality=M2M Junction{junTable}} ->
let rq = augmentQuery r in
Node (rq{implicitJoins=tableQi junTable:implicitJoins rq}, nodeProps) <$> updatedForest
Just r -> Node (augmentQuery r, nodeProps) <$> updatedForest
Nothing -> Node node <$> updatedForest
where
newAlias = case Relationship.isSelfReference <$> rel of
Just True
| depth /= 0 -> Just (qiName tbl <> "_" <> show depth) -- root node doesn't get aliased
| otherwise -> Nothing
_ -> Nothing
augmentQuery r =
foldr
(\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})
query{fromAlias=newAlias}
(getJoinConditions previousAlias newAlias r)
updatedForest = addJoinConditions newAlias `traverse` forest
-- previousAlias and newAlias are used in the case of self joins
getJoinConditions :: Maybe Alias -> Maybe Alias -> Relationship -> [JoinCondition]
getJoinConditions previousAlias newAlias (Relationship Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols card) =
case card of
M2M (Junction Table{tableName=jtn} _ jc1 _ jc2) ->
zipWith (toJoinCondition tN jtn) cols jc1 ++ zipWith (toJoinCondition ftN jtn) fCols jc2
_ ->
zipWith (toJoinCondition tN ftN) cols fCols
where
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
toJoinCondition tb ftb c fc =
let qi1 = removeSourceCTESchema tSchema tb
qi2 = removeSourceCTESchema tSchema ftb in
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, colName c)
(maybe qi2 (QualifiedIdentifier mempty) newAlias, colName fc)
-- On mutation and calling proc cases we wrap the target table in a WITH
-- {sourceCTEName} if this happens remove the schema `FROM
-- "schema"."{sourceCTEName}"` and use only the `FROM "{sourceCTEName}"`.
-- If the schema remains the FROM would be invalid.
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == decodeUtf8 sourceCTEName then mempty else schema) tbl
addFiltersOrdersRanges :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest
addFiltersOrdersRanges apiRequest rReq = do
rFlts <- foldr addFilter rReq <$> filters
rOrds <- foldr addOrder rFlts <$> orders
rRngs <- foldr addRange rOrds <$> ranges
foldr addLogicTree rRngs <$> logicForest
where
filters :: Either ApiRequestError [(EmbedPath, Filter)]
filters = pRequestFilter `traverse` flts
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
orders = pRequestOrder `traverse` iOrder apiRequest
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
ranges = pRequestRange `traverse` M.toList (iRange apiRequest)
logicForest :: Either ApiRequestError [(EmbedPath, LogicTree)]
logicForest = pRequestLogicTree `traverse` logFrst
action = iAction apiRequest
-- there can be no filters on the root table when we are doing insert/update/delete
(flts, logFrst) =
case action of
ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)
ActionRead _ -> (iFilters apiRequest, iLogic apiRequest)
_ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
addFilterToNode :: Filter -> ReadRequest -> ReadRequest
addFilterToNode flt (Node (q@Select {where_=lf}, i) f) = Node (q{where_=addFilterToLogicForest flt lf}::ReadQuery, i) f
addFilter :: (EmbedPath, Filter) -> ReadRequest -> ReadRequest
addFilter = addProperty addFilterToNode
addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest
addOrderToNode o (Node (q,i) f) = Node (q{order=o}, i) f
addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest
addOrder = addProperty addOrderToNode
addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest
addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f
addRange :: (EmbedPath, NonnegRange) -> ReadRequest -> ReadRequest
addRange = addProperty addRangeToNode
addLogicTreeToNode :: LogicTree -> ReadRequest -> ReadRequest
addLogicTreeToNode t (Node (q@Select{where_=lf},i) f) = Node (q{where_=t:lf}::ReadQuery, i) f
addLogicTree :: (EmbedPath, LogicTree) -> ReadRequest -> ReadRequest
addLogicTree = addProperty addLogicTreeToNode
addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest
addProperty f ([], a) rr = f a rr
addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
case pathNode of
Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path
Just tn -> Node rn (addProperty f (remainingPath, a) tn:delete tn forest)
where
pathNode = find (\(Node (_,(nodeName,_,alias,_,_, _)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
mutateRequest :: Schema -> TableName -> ApiRequest -> [FieldName] -> ReadRequest -> Either Error MutateRequest
mutateRequest schema tName apiRequest pkCols readReq = mapLeft ApiRequestError $
case action of
ActionCreate -> do
confCols <- case iOnConflict apiRequest of
Nothing -> pure pkCols
Just param -> pRequestOnConflict param
pure $ Insert qi (iColumns apiRequest) body ((,) <$> iPreferResolution apiRequest <*> Just confCols) [] returnings
ActionUpdate -> Update qi (iColumns apiRequest) body <$> combinedLogic <*> pure returnings
ActionSingleUpsert ->
(\flts ->
if null (iLogic apiRequest) &&
S.fromList (fst <$> iFilters apiRequest) == S.fromList pkCols &&
not (null (S.fromList pkCols)) &&
all (\case
Filter _ (OpExpr False (Op "eq" _)) -> True
_ -> False) flts
then Insert qi (iColumns apiRequest) body (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
else
Left InvalidFilters) =<< filters
ActionDelete -> Delete qi <$> combinedLogic <*> pure returnings
_ -> Left UnsupportedVerb
where
qi = QualifiedIdentifier schema tName
action = iAction apiRequest
returnings =
if iPreferRepresentation apiRequest == None
then []
else returningCols readReq pkCols
filters = map snd <$> pRequestFilter `traverse` mutateFilters
logic = map snd <$> pRequestLogicTree `traverse` logicFilters
combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters
-- update/delete filters can be only on the root table
(mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
body = payRaw <$> iPayload apiRequest -- the body is assumed to be json at this stage(ApiRequest validates)
callRequest :: ProcDescription -> ApiRequest -> ReadRequest -> CallRequest
callRequest proc apiReq readReq = FunctionCall {
funCQi = QualifiedIdentifier (pdSchema proc) (pdName proc)
, funCParams = callParams
, funCArgs = payRaw <$> iPayload apiReq
, funCScalar = procReturnsScalar proc
, funCMultipleCall = iPreferParameters apiReq == Just MultipleObjects
, funCReturning = returningCols readReq []
}
where
paramsAsSingleObject = iPreferParameters apiReq == Just SingleObject
callParams = case pdParams proc of
[prm] | paramsAsSingleObject -> OnePosParam prm
| ppName prm == mempty -> OnePosParam prm
| otherwise -> KeyParams $ specifiedParams [prm]
prms -> KeyParams $ specifiedParams prms
specifiedParams params = filter (\x -> ppName x `S.member` iColumns apiReq) params
returningCols :: ReadRequest -> [FieldName] -> [FieldName]
returningCols rr@(Node _ forest) pkCols
-- if * is part of the select, we must not add pk or fk columns manually -
-- otherwise those would be selected and output twice
| "*" `elem` fldNames = ["*"]
| otherwise = returnings
where
fldNames = fstFieldNames rr
-- Without fkCols, when a mutateRequest to
-- /projects?select=name,clients(name) occurs, the RETURNING SQL part would
-- be `RETURNING name`(see QueryBuilder). This would make the embedding
-- fail because the following JOIN would need the "client_id" column from
-- projects. So this adds the foreign key columns to ensure the embedding
-- succeeds, result would be `RETURNING name, client_id`.
fkCols = concat $ mapMaybe (\case
Node (_, (_, Just Relationship{relColumns=cols}, _, _, _, _)) _ -> Just cols
_ -> Nothing
) forest
-- However if the "client_id" is present, e.g. mutateRequest to
-- /projects?select=client_id,name,clients(name) we would get `RETURNING
-- client_id, name, client_id` and then we would produce the "column
-- reference \"client_id\" is ambiguous" error from PostgreSQL. So we
-- deduplicate with Set: We are adding the primary key columns as well to
-- make sure, that a proper location header can always be built for
-- INSERT/POST
returnings = S.toList . S.fromList $ fldNames ++ (colName <$> fkCols) ++ pkCols
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
-- they are later concatenated with AND in the QueryBuilder
addFilterToLogicForest :: Filter -> [LogicTree] -> [LogicTree]
addFilterToLogicForest flt lf = Stmnt flt : lf
-294
View File
@@ -1,294 +0,0 @@
{-|
Module : PostgREST.Request.Parsers
Description : PostgREST parser combinators
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`.
-}
module PostgREST.Request.Parsers
( pColumns
, pLogicPath
, pLogicSingleVal
, pLogicTree
, pOrder
, pOrderTerm
, pRequestColumns
, pRequestFilter
, pRequestLogicTree
, pRequestOnConflict
, pRequestOrder
, pRequestRange
, pRequestSelect
, pSingleVal
, pTreePath
) where
import qualified Data.HashMap.Strict as M
import qualified Data.Set as S
import Data.Either.Combinators (mapLeft)
import Data.Foldable (foldl1)
import Data.List (init, last)
import Data.Text (intercalate, replace, strip)
import Data.Tree (Tree (..))
import Text.Parsec.Error (errorMessages,
showErrorMessages)
import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser,
anyChar, between, char, digit,
eof, errorPos, letter,
lookAhead, many1, noneOf,
notFollowedBy, oneOf, option,
optionMaybe, parse, sepBy1,
string, try, (<?>))
import PostgREST.DbStructure.Identifiers (FieldName)
import PostgREST.Error (ApiRequestError (ParseRequestError))
import PostgREST.Query.SqlFragment (ftsOperators, operators)
import PostgREST.RangeQuery (NonnegRange)
import PostgREST.Request.Types
import Protolude hiding (intercalate, option, replace, try)
pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
pRequestSelect selStr =
mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
pRequestOnConflict :: Text -> Either ApiRequestError [FieldName]
pRequestOnConflict oncStr =
mapError $ parse pColumns ("failed to parse on_conflict parameter (" <> toS oncStr <> ")") (toS oncStr)
pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
where
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
oper = parse (pOpExpr pSingleVal) ("failed to parse filter (" ++ toS v ++ ")") $ toS v
path = fst <$> treePath
fld = snd <$> treePath
pRequestOrder :: (Text, Text) -> Either ApiRequestError (EmbedPath, [OrderTerm])
pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord'
where
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
path = fst <$> treePath
ord' = parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v
pRequestRange :: (Text, NonnegRange) -> Either ApiRequestError (EmbedPath, NonnegRange)
pRequestRange (k, v) = mapError $ (,) <$> path <*> pure v
where
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
path = fst <$> treePath
pRequestLogicTree :: (Text, Text) -> Either ApiRequestError (EmbedPath, LogicTree)
pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree
where
path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k
embedPath = fst <$> path
logicTree = do
op <- snd <$> path
-- Concat op and v to make pLogicTree argument regular,
-- in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)"
parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") $ toS (op <> v)
pRequestColumns :: Maybe Text -> Either ApiRequestError (Maybe (S.Set FieldName))
pRequestColumns colStr =
case colStr of
Just str ->
mapError $ Just . S.fromList <$> parse pColumns ("failed to parse columns parameter (" <> toS str <> ")") (toS str)
_ -> Right Nothing
ws :: Parser Text
ws = toS <$> many (oneOf " \t")
lexeme :: Parser a -> Parser a
lexeme p = ws *> p <* ws
pTreePath :: Parser (EmbedPath, Field)
pTreePath = do
p <- pFieldName `sepBy1` pDelimiter
jp <- option [] pJsonPath
return (init p, (last p, jp))
pFieldForest :: Parser [Tree SelectItem]
pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
where
pFieldTree :: Parser (Tree SelectItem)
pFieldTree = try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) <|>
Node <$> pFieldSelect <*> pure []
pStar :: Parser Text
pStar = string "*" $> "*"
pFieldName :: Parser Text
pFieldName =
pQuotedValue <|>
intercalate "-" . map toS <$> (many1 (letter <|> digit <|> oneOf "_ ") `sepBy1` dash) <?>
"field name (* or [a..z0..9_])"
where
isDash :: GenParser Char st ()
isDash = try ( char '-' >> notFollowedBy (char '>') )
dash :: Parser Char
dash = isDash $> '-'
pJsonPath :: Parser JsonPath
pJsonPath = many pJsonOperation
where
pJsonOperation :: Parser JsonOperation
pJsonOperation = pJsonArrow <*> pJsonOperand
pJsonArrow =
try (string "->>" $> J2Arrow) <|>
try (string "->" $> JArrow)
pJsonOperand =
let pJKey = JKey . toS <$> pFieldName
pJIdx = JIdx . toS <$> ((:) <$> option '+' (char '-') <*> many1 digit) <* pEnd
pEnd = try (void $ lookAhead (string "->")) <|>
try (void $ lookAhead (string "::")) <|>
try eof in
try pJIdx <|> try pJKey
pField :: Parser Field
pField = lexeme $ (,) <$> pFieldName <*> option [] pJsonPath
aliasSeparator :: Parser ()
aliasSeparator = char ':' >> notFollowedBy (char ':')
pRelationSelect :: Parser SelectItem
pRelationSelect = lexeme $ try ( do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField
prm1 <- optionMaybe pEmbedParam
prm2 <- optionMaybe pEmbedParam
return (fld, Nothing, alias, embedParamHint prm1 <|> embedParamHint prm2, embedParamJoin prm1 <|> embedParamJoin prm2)
)
where
pEmbedParam :: Parser EmbedParam
pEmbedParam =
char '!' *> (
try (string "left" $> EPJoinType JTLeft) <|>
try (string "inner" $> EPJoinType JTInner) <|>
try (EPHint <$> pFieldName))
embedParamHint prm = case prm of
Just (EPHint hint) -> Just hint
_ -> Nothing
embedParamJoin prm = case prm of
Just (EPJoinType jt) -> Just jt
_ -> Nothing
pFieldSelect :: Parser SelectItem
pFieldSelect = lexeme $
try (
do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField
cast' <- optionMaybe (string "::" *> many letter)
return (fld, toS <$> cast', alias, Nothing, Nothing)
)
<|> do
s <- pStar
return ((s, []), Nothing, Nothing, Nothing, Nothing)
pOpExpr :: Parser SingleVal -> Parser OpExpr
pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
where
pOperation :: Parser Operation
pOperation =
Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal
<|> In <$> (try (string "in" *> pDelimiter) *> pListVal)
<|> Is <$> (try (string "is" *> pDelimiter) *> pTriVal)
<|> pFts
<?> "operator (eq, gt, ...)"
pTriVal = try (string "null" $> TriNull)
<|> try (string "unknown" $> TriUnknown)
<|> try (string "true" $> TriTrue)
<|> try (string "false" $> TriFalse)
<?> "null or trilean value (unknown, true, false)"
pFts = do
op <- foldl1 (<|>) (try . string . toS <$> ftsOps)
lang <- optionMaybe $ try (between (char '(') (char ')') (many (letter <|> digit <|> oneOf "_")))
pDelimiter >> Fts (toS op) (toS <$> lang) <$> pSVal
ops = M.filterWithKey (const . flip notElem ("in":"is":ftsOps)) operators
ftsOps = M.keys ftsOperators
pSingleVal :: Parser SingleVal
pSingleVal = toS <$> many anyChar
pListVal :: Parser ListVal
pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
pListElement :: Parser Text
pListElement = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> (toS <$> many (noneOf ",)"))
pQuotedValue :: Parser Text
pQuotedValue = toS <$> (char '"' *> many pCharsOrSlashed <* char '"')
where
pCharsOrSlashed = noneOf "\\\"" <|> (char '\\' *> anyChar)
pDelimiter :: Parser Char
pDelimiter = char '.' <?> "delimiter (.)"
pOrder :: Parser [OrderTerm]
pOrder = lexeme pOrderTerm `sepBy1` char ','
pOrderTerm :: Parser OrderTerm
pOrderTerm = do
fld <- pField
dir <- optionMaybe $
try (pDelimiter *> string "asc" $> OrderAsc) <|>
try (pDelimiter *> string "desc" $> OrderDesc)
nls <- optionMaybe pNulls <* pEnd <|>
pEnd $> Nothing
return $ OrderTerm fld dir nls
where
pNulls = try (pDelimiter *> string "nullsfirst" $> OrderNullsFirst) <|>
try (pDelimiter *> string "nullslast" $> OrderNullsLast)
pEnd = try (void $ lookAhead (char ',')) <|>
try eof
pLogicTree :: Parser LogicTree
pLogicTree = Stmnt <$> try pLogicFilter
<|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')'))
where
pLogicFilter :: Parser Filter
pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal
pNot :: Parser Bool
pNot = try (string "not" *> pDelimiter $> True)
<|> pure False
<?> "negation operator (not)"
pLogicOp :: Parser LogicOperator
pLogicOp = try (string "and" $> And)
<|> string "or" $> Or
<?> "logic operator (and, or)"
pLogicSingleVal :: Parser SingleVal
pLogicSingleVal = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> try pPgArray <|> (toS <$> many (noneOf ",)"))
where
pPgArray :: Parser Text
pPgArray = do
a <- string "{"
b <- many (noneOf "{}")
c <- string "}"
pure (toS $ a ++ b ++ c)
pLogicPath :: Parser (EmbedPath, Text)
pLogicPath = do
path <- pFieldName `sepBy1` pDelimiter
let op = last path
notOp = "not." <> op
return (filter (/= "not") (init path), if "not" `elem` path then notOp else op)
pColumns :: Parser [FieldName]
pColumns = pFieldName `sepBy1` lexeme (char ',')
mapError :: Either ParseError a -> Either ApiRequestError a
mapError = mapLeft translateError
where
translateError e =
ParseRequestError message details
where
message = show $ errorPos e
details = strip $ replace "\n" " " $ toS
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
-232
View File
@@ -1,232 +0,0 @@
{-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.Request.Types
( Alias
, Depth
, EmbedParam(..)
, EmbedPath
, Field
, Filter(..)
, Hint
, CallQuery(..)
, CallParams(..)
, CallRequest
, JoinCondition(..)
, JoinType(..)
, JsonOperand(..)
, JsonOperation(..)
, JsonPath
, ListVal
, LogicOperator(..)
, LogicTree(..)
, MutateQuery(..)
, MutateRequest
, NodeName
, OpExpr(..)
, Operation (..)
, OrderDirection(..)
, OrderNulls(..)
, OrderTerm(..)
, ReadNode
, ReadQuery(..)
, ReadRequest
, SelectItem
, SingleVal
, TrileanVal(..)
, fstFieldNames
) where
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Set as S
import Data.Tree (Tree (..))
import PostgREST.DbStructure.Identifiers (FieldName,
QualifiedIdentifier)
import PostgREST.DbStructure.Proc (ProcParam (..))
import PostgREST.DbStructure.Relationship (Relationship)
import PostgREST.RangeQuery (NonnegRange)
import PostgREST.Request.Preferences (PreferResolution)
import Protolude
type ReadRequest = Tree ReadNode
type MutateRequest = MutateQuery
type CallRequest = CallQuery
type ReadNode =
(ReadQuery, (NodeName, Maybe Relationship, Maybe Alias, Maybe Hint, Maybe JoinType, Depth))
type NodeName = Text
type Depth = Integer
data ReadQuery = Select
{ select :: [SelectItem]
, from :: QualifiedIdentifier
-- ^ A table alias is used in case of self joins
, fromAlias :: Maybe Alias
-- ^ Only used for Many to Many joins. Parent and Child joins use explicit joins.
, implicitJoins :: [QualifiedIdentifier]
, where_ :: [LogicTree]
, joinConditions :: [JoinCondition]
, order :: [OrderTerm]
, range_ :: NonnegRange
}
deriving (Eq)
data JoinCondition =
JoinCondition
(QualifiedIdentifier, FieldName)
(QualifiedIdentifier, FieldName)
deriving (Eq)
data OrderTerm = OrderTerm
{ otTerm :: Field
, otDirection :: Maybe OrderDirection
, otNullOrder :: Maybe OrderNulls
}
deriving (Eq)
data OrderDirection
= OrderAsc
| OrderDesc
deriving (Eq)
data OrderNulls
= OrderNullsFirst
| OrderNullsLast
deriving (Eq)
data MutateQuery
= Insert
{ in_ :: QualifiedIdentifier
, insCols :: S.Set FieldName
, insBody :: Maybe LBS.ByteString
, onConflict :: Maybe (PreferResolution, [FieldName])
, where_ :: [LogicTree]
, returning :: [FieldName]
}
| Update
{ in_ :: QualifiedIdentifier
, updCols :: S.Set FieldName
, updBody :: Maybe LBS.ByteString
, where_ :: [LogicTree]
, returning :: [FieldName]
}
| Delete
{ in_ :: QualifiedIdentifier
, where_ :: [LogicTree]
, returning :: [FieldName]
}
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)
-- | The select value in `/tbl?select=alias:field::cast`
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe Hint, Maybe JoinType)
type Field = (FieldName, JsonPath)
type Cast = Text
type Alias = Text
type Hint = Text
data EmbedParam
-- | Disambiguates an embedding operation when there's multiple relationships
-- between two tables. Can be the name of a foreign key constraint, column
-- name or the junction in an m2m relationship.
= EPHint Hint
| EPJoinType JoinType
data JoinType
= JTInner
| JTLeft
deriving Eq
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path
-- ["clients", "projects"]
type EmbedPath = [Text]
-- | Json path operations as specified in
-- https://www.postgresql.org/docs/current/static/functions-json.html
type JsonPath = [JsonOperation]
-- | Represents the single arrow `->` or double arrow `->>` operators
data JsonOperation
= JArrow { jOp :: JsonOperand }
| J2Arrow { jOp :: JsonOperand }
deriving (Eq)
-- | Represents the key(`->'key'`) or index(`->'1`::int`), the index is Text
-- because we reuse our escaping functons and let pg do the casting with
-- '1'::int
data JsonOperand
= JKey { jVal :: Text }
| JIdx { jVal :: Text }
deriving (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
-- | Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
--
-- And
-- / \
-- name.eq.N Or
-- / \
-- id.eq.1 id.eq.2
data LogicTree
= Expr Bool LogicOperator [LogicTree]
| Stmnt Filter
deriving (Eq)
data LogicOperator
= And
| Or
deriving Eq
data Filter = Filter
{ field :: Field
, opExpr :: OpExpr
}
deriving (Eq)
data OpExpr =
OpExpr Bool Operation
deriving (Eq)
data Operation
= Op Operator SingleVal
| In ListVal
| Is TrileanVal
| Fts Operator (Maybe Language) SingleVal
deriving (Eq)
type Operator = Text
type Language = Text
-- | Represents a single value in a filter, e.g. id=eq.singleval
type SingleVal = Text
-- | Represents a list value in a filter, e.g. id=in.(val1,val2,val3)
type ListVal = [Text]
-- | Three-valued logic values
data TrileanVal
= TriTrue
| TriFalse
| TriNull
| TriUnknown
deriving Eq
+343
View File
@@ -0,0 +1,343 @@
{- |
Module : PostgREST.Response
Description : Generate HTTP Response
-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Response
( createResponse
, deleteResponse
, infoIdentResponse
, infoProcResponse
, infoRootResponse
, invokeResponse
, openApiResponse
, readResponse
, singleUpsertResponse
, updateResponse
, addRetryHint
, isServiceUnavailable
, optionalRollback
, concatPrefAppsHeaders
, addPrefToHeaders
, traceHeaderMiddleware
) 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 qualified Data.List as L
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 (..))
import PostgREST.ApiRequest.Preferences (PreferRepresentation (..),
PreferTransaction (..),
Preferences (..),
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.Routine (FuncVolatility (..),
Routine (..), RoutineMap)
import PostgREST.SchemaCache.Table (Table (..), TablesMap)
import qualified PostgREST.ApiRequest.Types as ApiRequestTypes
import qualified PostgREST.SchemaCache.Routine as Routine
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{iPreferences=Preferences{..}, ..} 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 preferCount then Just rsQueryTotal else Nothing
, if null pkCols && isNothing (qsOnConflict iQueryParams) then
Nothing
else
toAppliedHeader <$> preferResolution
, toAppliedHeader <$> preferMissing
]
case preferRepresentation of
Just Full -> response HTTP.status201 (addPrefToHeaders headers Full ++ contentTypeHeaders ctxApiRequest) (LBS.fromStrict rsBody)
Just None -> response HTTP.status201 (addPrefToHeaders headers None) mempty
Just HeadersOnly -> response HTTP.status201 (addPrefToHeaders headers HeadersOnly) mempty
Nothing -> response HTTP.status201 headers mempty
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
updateResponse :: ApiRequest -> ResultSet -> Wai.Response
updateResponse ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
RSStandard{..} -> do
let
response = gucResponse rsGucStatus rsGucHeaders
contentRangeHeader =
Just . RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
if shouldCount preferCount then Just rsQueryTotal else Nothing
headers = catMaybes [contentRangeHeader, toAppliedHeader <$> preferMissing]
case preferRepresentation of
Just Full -> response HTTP.status200 (addPrefToHeaders headers Full ++ contentTypeHeaders ctxApiRequest)
(LBS.fromStrict rsBody)
Just None -> response HTTP.status204 (addPrefToHeaders headers None) mempty
_ -> response HTTP.status204 headers mempty
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
singleUpsertResponse :: ApiRequest -> ResultSet -> Wai.Response
singleUpsertResponse ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
RSStandard {..} -> do
let
response = gucResponse rsGucStatus rsGucHeaders
case preferRepresentation of
Just Full -> response HTTP.status200 (contentTypeHeaders ctxApiRequest ++ [toAppliedHeader Full]) (LBS.fromStrict rsBody)
Just None -> response HTTP.status204 [toAppliedHeader None] mempty
_ -> response HTTP.status204 [] mempty
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
deleteResponse :: ApiRequest -> ResultSet -> Wai.Response
deleteResponse ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
RSStandard {..} -> do
let
response = gucResponse rsGucStatus rsGucHeaders
contentRangeHeader =
RangeQuery.contentRangeH 1 0 $
if shouldCount preferCount then Just rsQueryTotal else Nothing
headers = [contentRangeHeader]
case preferRepresentation of
Just Full -> response HTTP.status200 (addPrefToHeaders headers Full ++ contentTypeHeaders ctxApiRequest)
(LBS.fromStrict rsBody)
Just None -> response HTTP.status204 (addPrefToHeaders headers None) mempty
_ -> response HTTP.status204 headers mempty
RSPlan plan ->
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
infoIdentResponse :: QualifiedIdentifier -> SchemaCache -> Wai.Response
infoIdentResponse identifier sCache =
case HM.lookup identifier (dbTables sCache) of
Just tbl -> respondInfo $ allowH tbl
Nothing -> Error.errorResponseFor $ Error.ApiRequestError ApiRequestTypes.NotFound
where
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]
infoProcResponse :: Routine -> Wai.Response
infoProcResponse proc | pdVolatility proc == Volatile = respondInfo "OPTIONS,POST"
| otherwise = respondInfo "OPTIONS,GET,HEAD,POST"
infoRootResponse :: Wai.Response
infoRootResponse = respondInfo "OPTIONS,GET,HEAD"
respondInfo :: ByteString -> Wai.Response
respondInfo allowHeader =
let allOrigins = ("Access-Control-Allow-Origin", "*") in
Wai.responseLBS HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty
invokeResponse :: InvokeMethod -> Routine -> 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 Routine.funcReturnsVoid 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 :: (Text, Text) -> Bool -> Maybe (TablesMap, RoutineMap, Maybe Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Wai.Response
openApiResponse versions 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 versions 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{iPreferences=Preferences{..}} resp = do
newRes <- catchError resp $ return . Error.errorResponseFor
return $ Wai.mapResponseHeaders preferenceApplied newRes
where
shouldCommit =
configDbTxAllowOverride && preferTransaction == Just Commit
shouldRollback =
configDbTxAllowOverride && preferTransaction == Just Rollback
preferenceApplied
| shouldCommit =
addHeadersIfNotIncluded
[toAppliedHeader Commit]
| shouldRollback =
addHeadersIfNotIncluded
[toAppliedHeader Rollback]
| otherwise =
identity
-- | Add headers not already included to allow the user to override them instead of duplicating them
addHeadersIfNotIncluded :: [HTTP.Header] -> [HTTP.Header] -> [HTTP.Header]
addHeadersIfNotIncluded newHeaders initialHeaders =
filter (\(nk, nv) -> isNothing $ find (\(ik, iv) -> ik == nk && nv == iv) initialHeaders) newHeaders ++
initialHeaders
-- | Filters out multiple Preference-Applied Headers from the list and concatenate them into a single Preference-Applied header:
--
-- >>> :{
-- concatPrefAppsHeaders
-- [("Content-Type","application/json")
-- , ("Preference-Applied","tx=commit")
-- , ("Preference-Applied","return=minimal")]
-- :}
-- [("Content-Type","application/json"),("Preference-Applied","tx=commit, return=minimal")]
concatPrefAppsHeaders :: [HTTP.Header] -> [HTTP.Header]
concatPrefAppsHeaders headers = otherHeaders ++ [(HTTP.hPreferenceApplied, combinedPrefApps)]
where
(prefApps, otherHeaders) = L.partition (\(k, _) -> k == HTTP.hPreferenceApplied) headers
prefAppsValues = [ v | (_,v) <- prefApps]
combinedPrefApps = BS.intercalate ", " prefAppsValues
-- | Given response headers and a preferRepresentation value, add
-- preferRepresentation to Preference-Applied
--
-- >>> :{
-- addPrefToHeaders
-- [("Content-Type", "application/json")
-- , ("Preference-Applied", "tx=commit")]
-- None
-- :}
-- [("Content-Type","application/json"),("Preference-Applied","tx=commit, return=minimal")]
addPrefToHeaders :: [HTTP.Header] -> PreferRepresentation -> [HTTP.Header]
addPrefToHeaders headers pref = concatPrefAppsHeaders (headers ++ [toAppliedHeader pref])
traceHeaderMiddleware :: AppConfig -> Wai.Middleware
traceHeaderMiddleware AppConfig{configServerTraceHeader} app req respond =
case configServerTraceHeader of
Nothing -> app req respond
Just hdr ->
let hdrVal = L.lookup hdr $ Wai.requestHeaders req in
app req (respond . Wai.mapResponseHeaders ([(hdr, fromMaybe mempty hdrVal)] ++))
@@ -1,12 +1,12 @@
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
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as M
import Network.HTTP.Types.Header (Header) import Network.HTTP.Types.Header (Header)
@@ -21,16 +21,10 @@ newtype GucHeader = GucHeader (CI.CI ByteString, ByteString)
instance JSON.FromJSON GucHeader where instance JSON.FromJSON GucHeader where
parseJSON (JSON.Object o) = parseJSON (JSON.Object o) =
case M.toList o of case KM.toList o of
[(k, JSON.String s)] -> pure $ GucHeader (CI.mk $ toUtf8 k, toUtf8 s) [(k, JSON.String s)] -> pure $ GucHeader (CI.mk $ toUtf8 $ K.toText k, toUtf8 s)
_ -> mzero _ -> mzero
parseJSON _ = mzero parseJSON _ = mzero
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
@@ -3,17 +3,15 @@ Module : PostgREST.OpenAPI
Description : Generates the OpenAPI output Description : Generates the OpenAPI output
-} -}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# 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
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as HM
import qualified Data.HashSet.InsOrd as Set import qualified Data.HashSet.InsOrd as Set
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Control.Arrow ((&&&)) import Control.Arrow ((&&&))
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList) import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList)
@@ -27,86 +25,105 @@ 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 (..))
tableCols, tablePKCols) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.DbStructure.Proc (ProcDescription (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
ProcParam (..)) Relationship (..),
import PostgREST.DbStructure.Relationship (Cardinality (..), RelationshipsMap)
PrimaryKey (..), import PostgREST.SchemaCache.Routine (Routine (..),
Relationship (..)) RoutineParam (..))
import PostgREST.DbStructure.Table (Column (..), Table (..)) import PostgREST.SchemaCache.Table (Column (..), Table (..),
import PostgREST.Version (docsVersion, prettyVersion) TablesMap,
tableColumnsList)
import PostgREST.ContentType import PostgREST.MediaType
import Protolude hiding (Proxy, get) import Protolude hiding (Proxy, get)
encode :: AppConfig -> DbStructure -> [Table] -> M.HashMap k [ProcDescription] -> Maybe Text -> LBS.ByteString encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString
encode conf dbStructure tables procs schemaDescription = encode versions conf sCache tables procs schemaDescription =
JSON.encode $ JSON.encode $
postgrestSpec postgrestSpec
(dbRelationships dbStructure) versions
(concat $ M.elems procs) (dbRelationships sCache)
(openApiTableInfo dbStructure <$> tables) (concat $ HM.elems procs)
(snd <$> HM.toList tables)
(proxyUri conf) (proxyUri conf)
schemaDescription schemaDescription
(dbPrimaryKeys dbStructure) (configOpenApiSecurityActive conf)
makeMimeList :: [ContentType] -> MimeList makeMimeList :: [MediaType] -> MimeList
makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs
toSwaggerType :: Text -> SwaggerType t toSwaggerType :: Text -> Maybe (SwaggerType t)
toSwaggerType "character varying" = SwaggerString toSwaggerType "character varying" = Just SwaggerString
toSwaggerType "character" = SwaggerString toSwaggerType "character" = Just SwaggerString
toSwaggerType "text" = SwaggerString toSwaggerType "text" = Just SwaggerString
toSwaggerType "boolean" = SwaggerBoolean toSwaggerType "boolean" = Just SwaggerBoolean
toSwaggerType "smallint" = SwaggerInteger toSwaggerType "smallint" = Just SwaggerInteger
toSwaggerType "integer" = SwaggerInteger toSwaggerType "integer" = Just SwaggerInteger
toSwaggerType "bigint" = SwaggerInteger toSwaggerType "bigint" = Just SwaggerInteger
toSwaggerType "numeric" = SwaggerNumber toSwaggerType "numeric" = Just SwaggerNumber
toSwaggerType "real" = SwaggerNumber toSwaggerType "real" = Just SwaggerNumber
toSwaggerType "double precision" = SwaggerNumber toSwaggerType "double precision" = Just SwaggerNumber
toSwaggerType "ARRAY" = SwaggerArray toSwaggerType "json" = Nothing
toSwaggerType _ = SwaggerString toSwaggerType "jsonb" = Nothing
toSwaggerType colType = case T.takeEnd 2 colType of
"[]" -> Just SwaggerArray
_ -> Just SwaggerString
typeFromArray :: Text -> Text
typeFromArray = T.dropEnd 2
toSwaggerTypeFromArray :: Text -> Maybe (SwaggerType t)
toSwaggerTypeFromArray arrType = toSwaggerType $ typeFromArray arrType
makePropertyItems :: Text -> Maybe (Referenced Schema)
makePropertyItems arrType = case toSwaggerType arrType of
Just SwaggerArray -> Just $ Inline (mempty & type_ .~ toSwaggerTypeFromArray arrType)
_ -> Nothing
parseDefault :: Text -> Text -> Text parseDefault :: Text -> Text -> Text
parseDefault colType colDefault = parseDefault colType colDefault =
case toSwaggerType colType of case toSwaggerType colType of
SwaggerString -> wrapInQuotations $ case T.stripSuffix ("::" <> colType) colDefault of Just SwaggerString -> wrapInQuotations $ case T.stripSuffix ("::" <> colType) colDefault of
Just def -> T.dropAround (=='\'') def Just def -> T.dropAround (=='\'') def
Nothing -> colDefault Nothing -> colDefault
_ -> colDefault _ -> colDefault
where where
wrapInQuotations text = "\"" <> text <> "\"" wrapInQuotations text = "\"" <> text <> "\""
makeTableDef :: [Relationship] -> [PrimaryKey] -> (Table, [Column], [Text]) -> (Text, Schema) makeTableDef :: RelationshipsMap -> Table -> (Text, Schema)
makeTableDef rels pks (t, cs, _) = makeTableDef rels t =
let tn = tableName t in let tn = tableName t in
(tn, (mempty :: Schema) (tn, (mempty :: Schema)
& description .~ tableDescription t & description .~ tableDescription t
& type_ ?~ SwaggerObject & type_ ?~ SwaggerObject
& properties .~ fromList (fmap (makeProperty rels pks) cs) & properties .~ fromList (makeProperty t rels <$> tableColumnsList t)
& required .~ fmap colName (filter (not . colNullable) cs)) & required .~ fmap colName (filter (not . colNullable) $ tableColumnsList t))
makeProperty :: [Relationship] -> [PrimaryKey] -> Column -> (Text, Referenced Schema) makeProperty :: Table -> RelationshipsMap -> Column -> (Text, Referenced Schema)
makeProperty rels pks c = (colName c, Inline s) makeProperty tbl rels col = (colName col, Inline s)
where where
e = if null $ colEnum c then Nothing else JSON.decode $ JSON.encode $ colEnum c e = if null $ colEnum col then Nothing else JSON.decode $ JSON.encode $ colEnum col
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{relColumns, relCardinality=M2O _} -> [c] == relColumns Relationship{relCardinality=(M2O _ relColumns)} -> [colName col] == (fst <$> relColumns)
_ -> False _ -> False
) rels ) relsSortedByIsView
fCol = colName <$> (headMay . relForeignColumns =<< rel) fCol = (headMay . (\r -> snd <$> relColumns (relCardinality r)) =<< rel)
fTbl = tableName . relForeignTable <$> rel fTbl = qiName . relForeignTable <$> rel
fTblCol = (,) <$> fTbl <*> fCol fTblCol = (,) <$> fTbl <*> fCol
in in
(\(a, b) -> T.intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]) <$> fTblCol (\(a, b) -> T.intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]) <$> fTblCol
pk :: Bool pk :: Bool
pk = any (\p -> pkTable p == colTable c && pkName p == colName c) pks pk = colName col `elem` tablePKCols tbl
n = catMaybes n = catMaybes
[ Just "Note:" [ Just "Note:"
, if pk then Just "This is a Primary Key.<pk/>" else Nothing , if pk then Just "This is a Primary Key.<pk/>" else Nothing
@@ -114,19 +131,20 @@ makeProperty rels pks c = (colName c, Inline s)
] ]
d = d =
if length n > 1 then if length n > 1 then
Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription c) (T.intercalate "\n" n) Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n)
else else
colDescription c colDescription col
s = s =
(mempty :: Schema) (mempty :: Schema)
& default_ .~ (JSON.decode . toUtf8Lazy . parseDefault (colType c) =<< colDefault c) & default_ .~ (JSON.decode . toUtf8Lazy . parseDefault (colType col) =<< colDefault col)
& description .~ d & description .~ d
& enum_ .~ e & enum_ .~ e
& format ?~ colType c & format ?~ colType col
& maxLength .~ (fromIntegral <$> colMaxLen c) & maxLength .~ (fromIntegral <$> colMaxLen col)
& type_ ?~ toSwaggerType (colType c) & type_ .~ toSwaggerType (colType col)
& items .~ (SwaggerItemsObject <$> makePropertyItems (colType col))
makeProcSchema :: ProcDescription -> Schema makeProcSchema :: Routine -> Schema
makeProcSchema pd = makeProcSchema pd =
(mempty :: Schema) (mempty :: Schema)
& description .~ pdDescription pd & description .~ pdDescription pd
@@ -134,11 +152,12 @@ makeProcSchema pd =
& properties .~ fromList (fmap makeProcProperty (pdParams pd)) & properties .~ fromList (fmap makeProcProperty (pdParams pd))
& required .~ fmap ppName (filter ppReq (pdParams pd)) & required .~ fmap ppName (filter ppReq (pdParams pd))
makeProcProperty :: ProcParam -> (Text, Referenced Schema) makeProcProperty :: RoutineParam -> (Text, Referenced Schema)
makeProcProperty (ProcParam n t _ _) = (n, Inline s) makeProcProperty (RoutineParam n t _ _ _) = (n, Inline s)
where where
s = (mempty :: Schema) s = (mempty :: Schema)
& type_ ?~ toSwaggerType t & type_ .~ toSwaggerType t
& items .~ (SwaggerItemsObject <$> makePropertyItems t)
& format ?~ t & format ?~ t
makePreferParam :: [Text] -> Param makePreferParam :: [Text] -> Param
@@ -150,10 +169,47 @@ 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] makeProcGetParam :: RoutineParam -> Referenced Param
makeProcParam pd = makeProcGetParam (RoutineParam n t _ r v) =
Inline $ (mempty :: Param)
& name .~ n
& required ?~ r
& schema .~ ParamOther fullSchema
where
fullSchema = if v then schemaMulti else schemaNotMulti
baseSchema = (mempty :: ParamOtherSchema)
& in_ .~ ParamQuery
schemaNotMulti = baseSchema
& format ?~ t
& type_ ?~ toParamType (toSwaggerType t)
schemaMulti = baseSchema
& type_ ?~ fromMaybe SwaggerString (toSwaggerType t)
& items ?~ SwaggerItemsPrimitive (Just CollectionMulti)
((mempty :: ParamSchema x)
& type_ .~ toSwaggerTypeFromArray t
& format ?~ typeFromArray t)
toParamType paramType = case paramType of
-- Array uses {} in query params
Just SwaggerArray -> SwaggerString
-- Type must be specified in query params
Nothing -> SwaggerString
_ -> fromJust paramType
makeProcGetParams :: [RoutineParam] -> [Referenced Param]
makeProcGetParams = fmap makeProcGetParam
makeProcPostParams :: Routine -> [Referenced Param]
makeProcPostParams pd =
[ Inline $ (mempty :: Param) [ Inline $ (mempty :: Param)
& name .~ "args" & name .~ "args"
& required ?~ True & required ?~ True
@@ -161,11 +217,13 @@ makeProcParam pd =
, Ref $ Reference "preferParams" , Ref $ Reference "preferParams"
] ]
makeParamDefs :: [(Table, [Column], [Text])] -> [(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"
@@ -217,8 +275,8 @@ makeParamDefs ti =
& in_ .~ ParamQuery & in_ .~ ParamQuery
& type_ ?~ SwaggerString)) & type_ ?~ SwaggerString))
] ]
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) cs <> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) (tableColumnsList t)
| (t, cs, _) <- ti | t <- ti
] ]
makeObjectBody :: Text -> (Text, Param) makeObjectBody :: Text -> (Text, Param)
@@ -243,8 +301,8 @@ makeRowFilter tn c =
makeRowFilters :: Text -> [Column] -> [(Text, Param)] makeRowFilters :: Text -> [Column] -> [(Text, Param)]
makeRowFilters tn = fmap (makeRowFilter tn) makeRowFilters tn = fmap (makeRowFilter tn)
makePathItem :: (Table, [Column], [Text]) -> (FilePath, PathItem) makePathItem :: Table -> (FilePath, PathItem)
makePathItem (t, cs, _) = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t || tableDeletable t) makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t || tableDeletable t)
where where
-- Use first line of table description as summary; rest as description (if present) -- Use first line of table description as summary; rest as description (if present)
-- We strip leading newlines from description so that users can include a blank line between summary and description -- We strip leading newlines from description so that users can include a blank line between summary and description
@@ -265,7 +323,7 @@ makePathItem (t, cs, _) = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpd
) )
) )
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"])
@@ -278,24 +336,29 @@ makePathItem (t, cs, _) = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpd
p False = pr p False = pr
p True = pw p True = pw
tn = tableName t tn = tableName t
rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- cs ] rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- tableColumnsList t ]
ref = Ref . Reference ref = Ref . Reference
makeProcPathItem :: ProcDescription -> (FilePath, PathItem) makeProcPathItem :: Routine -> (FilePath, PathItem)
makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe) makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe)
where where
-- Use first line of proc description as summary; rest as description (if present) -- Use first line of proc description as summary; rest as description (if present)
-- We strip leading newlines from description so that users can include a blank line between summary and description -- We strip leading newlines from description so that users can include a blank line between summary and description
(pSum, pDesc) = fmap fst &&& fmap (T.dropWhile (=='\n') . snd) $ (pSum, pDesc) = fmap fst &&& fmap (T.dropWhile (=='\n') . snd) $
T.breakOn "\n" <$> pdDescription pd T.breakOn "\n" <$> pdDescription pd
postOp = (mempty :: Operation) procOp = (mempty :: Operation)
& summary .~ pSum & summary .~ pSum
& description .~ mfilter (/="") pDesc & description .~ mfilter (/="") pDesc
& parameters .~ makeProcParam pd
& tags .~ Set.fromList ["(rpc) " <> pdName pd] & tags .~ Set.fromList ["(rpc) " <> pdName pd]
& produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON] & produces ?~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False]
& at 200 ?~ "OK" & at 200 ?~ "OK"
pe = (mempty :: PathItem) & post ?~ postOp getOp = procOp
& parameters .~ makeProcGetParams (pdParams pd)
postOp = procOp
& parameters .~ makeProcPostParams pd
pe = (mempty :: PathItem)
& get ?~ getOp
& post ?~ postOp
makeRootPathItem :: (FilePath, PathItem) makeRootPathItem :: (FilePath, PathItem)
makeRootPathItem = ("/", p) makeRootPathItem = ("/", p)
@@ -303,15 +366,23 @@ makeRootPathItem = ("/", p)
getOp = (mempty :: Operation) getOp = (mempty :: Operation)
& tags .~ Set.fromList ["Introspection"] & tags .~ Set.fromList ["Introspection"]
& summary ?~ "OpenAPI description (this document)" & summary ?~ "OpenAPI description (this document)"
& produces ?~ makeMimeList [CTOpenAPI, CTApplicationJSON] & produces ?~ makeMimeList [MTOpenAPI, MTApplicationJSON]
& at 200 ?~ "OK" & at 200 ?~ "OK"
pr = (mempty :: PathItem) & get ?~ getOp pr = (mempty :: PathItem) & get ?~ getOp
p = pr p = pr
makePathItems :: [ProcDescription] -> [(Table, [Column], [Text])] -> InsOrdHashMap FilePath PathItem makePathItems :: [Routine] -> [Table] -> InsOrdHashMap FilePath PathItem
makePathItems pds ti = fromList $ makeRootPathItem : makePathItems pds ti = fromList $ makeRootPathItem :
fmap makePathItem ti ++ fmap makeProcPathItem pds fmap makePathItem ti ++ fmap makeProcPathItem pds
makeSecurityDefinitions :: Text -> Bool -> SecurityDefinitions
makeSecurityDefinitions secName allow
| allow = SecurityDefinitions (fromList [(secName, SecurityScheme secSchType secSchDescription)])
| otherwise = mempty
where
secSchType = SecuritySchemeApiKey (ApiKeyParams "Authorization" ApiKeyHeader)
secSchDescription = Just "Add the token prepending \"Bearer \" (without quotes) to it"
escapeHostName :: Text -> Text escapeHostName :: Text -> Text
escapeHostName "*" = "0.0.0.0" escapeHostName "*" = "0.0.0.0"
escapeHostName "*4" = "0.0.0.0" escapeHostName "*4" = "0.0.0.0"
@@ -320,27 +391,31 @@ escapeHostName "*6" = "0.0.0.0"
escapeHostName "!6" = "0.0.0.0" escapeHostName "!6" = "0.0.0.0"
escapeHostName h = h escapeHostName h = h
postgrestSpec :: [Relationship] -> [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Maybe Text -> [PrimaryKey] -> Swagger postgrestSpec :: (Text, Text) -> RelationshipsMap -> [Routine] -> [Table] -> (Text, Text, Integer, Text) -> Maybe Text -> Bool -> Swagger
postgrestSpec rels pds ti (s, h, p, b) sd pks = (mempty :: Swagger) postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecurityDef = (mempty :: Swagger)
& basePath ?~ T.unpack b & basePath ?~ T.unpack b
& schemes ?~ [s'] & schemes ?~ [s']
& info .~ ((mempty :: Info) & info .~ ((mempty :: Info)
& version .~ T.decodeUtf8 prettyVersion & version .~ prettyVersion
& title .~ "PostgREST API" & title .~ fromMaybe "PostgREST API" dTitle
& description ?~ d) & description ?~ fromMaybe "This is a dynamic API generated by PostgREST" dDesc)
& externalDocs ?~ ((mempty :: ExternalDocs) & externalDocs ?~ ((mempty :: ExternalDocs)
& description ?~ "PostgREST Documentation" & description ?~ "PostgREST Documentation"
& url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/api.html")) & url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
& host .~ h' & host .~ h'
& definitions .~ fromList (makeTableDef rels pks <$> ti) & definitions .~ fromList (makeTableDef rels <$> ti)
& parameters .~ fromList (makeParamDefs ti) & parameters .~ fromList (makeParamDefs ti)
& paths .~ makePathItems pds ti & paths .~ makePathItems pds ti
& produces .~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV] & produces .~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False, MTTextCSV]
& consumes .~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV] & consumes .~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False, MTTextCSV]
& securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef
& security .~ [SecurityRequirement (fromList [(securityDefName, [])]) | allowSecurityDef]
where where
s' = if s == "http" then Http else Https s' = if s == "http" then Http else Https
h' = Just $ Host (T.unpack $ escapeHostName h) (Just (fromInteger p)) h' = Just $ Host (T.unpack $ escapeHostName h) (Just (fromInteger p))
d = fromMaybe "This is a dynamic API generated by PostgREST" sd securityDefName = "JWT"
(dTitle, dDesc) = fmap fst &&& fmap (T.dropWhile (=='\n') . snd) $
T.breakOn "\n" <$> sd
pickProxy :: Maybe Text -> Maybe Proxy pickProxy :: Maybe Text -> Maybe Proxy
pickProxy proxy pickProxy proxy
@@ -378,10 +453,3 @@ proxyUri AppConfig{..} =
(proxyScheme, proxyHost, proxyPort, proxyPath) (proxyScheme, proxyHost, proxyPort, proxyPath)
Nothing -> Nothing ->
("http", configServerHost, toInteger configServerPort, "/") ("http", configServerHost, toInteger configServerPort, "/")
openApiTableInfo :: DbStructure -> Table -> (Table, [Column], [Text])
openApiTableInfo dbStructure table =
( table
, tableCols dbStructure (tableSchema table) (tableName table)
, tablePKCols dbStructure (tableSchema table) (tableName table)
)
File diff suppressed because it is too large Load Diff
@@ -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
@@ -22,7 +24,7 @@ data QualifiedIdentifier = QualifiedIdentifier
{ qiSchema :: Schema { qiSchema :: Schema
, qiName :: TableName , qiName :: TableName
} }
deriving (Eq, Ord, Generic, JSON.ToJSON, JSON.ToJSONKey) deriving (Eq, Show, Ord, Generic, JSON.ToJSON, JSON.ToJSONKey)
instance Hashable QualifiedIdentifier instance Hashable QualifiedIdentifier
@@ -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
+72
View File
@@ -0,0 +1,72 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
module PostgREST.SchemaCache.Relationship
( Cardinality(..)
, Relationship(..)
, Junction(..)
, RelationshipsMap
, relIsToOne
) where
import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as HM
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier, Schema)
import Protolude
-- | Relationship between two tables.
data Relationship = Relationship
{ relTable :: QualifiedIdentifier
, relForeignTable :: QualifiedIdentifier
, relIsSelf :: Bool -- ^ Whether is a self relationship
, relCardinality :: Cardinality
, relTableIsView :: Bool
, relFTableIsView :: Bool
}
| ComputedRelationship
{ relFunction :: QualifiedIdentifier
, relTable :: QualifiedIdentifier
, relForeignTable :: QualifiedIdentifier
, relToOne :: Bool
, relIsSelf :: Bool
}
deriving (Eq, Show, Ord, Generic, JSON.ToJSON)
-- | The relationship cardinality
-- | https://en.wikipedia.org/wiki/Cardinality_(data_modeling)
data Cardinality
= O2M {relCons :: FKConstraint, relColumns :: [(FieldName, FieldName)]}
-- ^ one-to-many
| M2O {relCons :: FKConstraint, relColumns :: [(FieldName, FieldName)]}
-- ^ many-to-one
| O2O {relCons :: FKConstraint, relColumns :: [(FieldName, FieldName)]}
-- ^ one-to-one, this is a refinement over M2O so operating on it is pretty much the same as M2O
| M2M Junction
-- ^ many-to-many
deriving (Eq, Show, Ord, Generic, JSON.ToJSON)
type FKConstraint = Text
-- | Junction table on an M2M relationship
data Junction = Junction
{ junTable :: QualifiedIdentifier
, junConstraint1 :: FKConstraint
, junConstraint2 :: FKConstraint
, junColsSource :: [(FieldName, FieldName)]
, junColsTarget :: [(FieldName, FieldName)]
}
deriving (Eq, Show, Ord, Generic, JSON.ToJSON)
-- | Key based on the source table and the foreign table schema
type RelationshipsMap = HM.HashMap (QualifiedIdentifier, Schema) [Relationship]
relIsToOne :: Relationship -> Bool
relIsToOne rel = case rel of
Relationship{relCardinality=M2O _ _} -> True
Relationship{relCardinality=O2O _ _} -> True
ComputedRelationship{relToOne=True} -> True
_ -> False
@@ -0,0 +1,29 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
module PostgREST.SchemaCache.Representations
( DataRepresentation(..)
, RepresentationsMap
) where
import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as HM
import Protolude
-- | Data representations allow user customisation of how to present and receive data through APIs, per field.
-- This structure is used for the library of available transforms. It answers questions like:
-- - What function, if any, should be used to present a certain field that's been selected for API output?
-- - How do we parse incoming data for a certain field type when inserting or updating?
-- - And similarly, how do we parse textual data in a query string to be used as a filter?
--
-- Support for outputting special formats like CSV and binary data would fit into the same system.
data DataRepresentation = DataRepresentation
{ drSourceType :: Text
, drTargetType :: Text
, drFunction :: Text
} deriving (Eq, Show, Generic, JSON.ToJSON, JSON.FromJSON)
-- The representation map maps from (source type, target type) to a DR.
type RepresentationsMap = HM.HashMap (Text, Text) DataRepresentation
+132
View File
@@ -0,0 +1,132 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
module PostgREST.SchemaCache.Routine
( PgType(..)
, Routine(..)
, RoutineParam(..)
, FuncVolatility(..)
, RoutineMap
, RetType(..)
, funcReturnsScalar
, funcReturnsSetOfScalar
, funcReturnsSingleComposite
, funcReturnsVoid
, funcTableName
, funcReturnsCompositeAlias
, ResultAggregate(..)
) where
import Data.Aeson ((.=))
import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as HM
import qualified Hasql.Transaction.Sessions as SQL
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema, TableName)
import Protolude
data PgType
= Scalar QualifiedIdentifier
| Composite QualifiedIdentifier Bool -- True if the composite is a domain alias(used to work around a bug in pg 11 and 12, see QueryBuilder.hs)
deriving (Eq, Show, Ord, Generic, JSON.ToJSON)
data RetType
= Single PgType
| SetOf PgType
deriving (Eq, Show, Ord, Generic, JSON.ToJSON)
data FuncVolatility
= Volatile
| Stable
| Immutable
deriving (Eq, Show, Ord, Generic, JSON.ToJSON)
data Routine = Function
{ pdSchema :: Schema
, pdName :: Text
, pdDescription :: Maybe Text
, pdParams :: [RoutineParam]
, pdReturnType :: RetType
, pdVolatility :: FuncVolatility
, pdHasVariadic :: Bool
, pdIsoLvl :: Maybe SQL.IsolationLevel
}
deriving (Eq, Show, Generic)
-- need to define JSON manually bc SQL.IsolationLevel doesn't have a JSON instance(and we can't define one for that type without getting a compiler error)
instance JSON.ToJSON Routine where
toJSON (Function sch nam desc params ret vol hasVar _) = JSON.object
[
"pdSchema" .= sch
, "pdName" .= nam
, "pdDescription" .= desc
, "pdParams" .= JSON.toJSON params
, "pdReturnType" .= JSON.toJSON ret
, "pdVolatility" .= JSON.toJSON vol
, "pdHasVariadic" .= JSON.toJSON hasVar
]
data RoutineParam = RoutineParam
{ ppName :: Text
, ppType :: Text
, ppTypeMaxLength :: Text
, ppReq :: Bool
, ppVar :: Bool
}
deriving (Eq, Show, Ord, Generic, JSON.ToJSON)
-- Order by least number of params in the case of overloaded functions
instance Ord Routine where
Function schema1 name1 des1 prms1 rt1 vol1 hasVar1 iso1 `compare` Function schema2 name2 des2 prms2 rt2 vol2 hasVar2 iso2
| schema1 == schema2 && name1 == name2 && length prms1 < length prms2 = LT
| schema2 == schema2 && name1 == name2 && length prms1 > length prms2 = GT
| otherwise = (schema1, name1, des1, prms1, rt1, vol1, hasVar1, iso1) `compare` (schema2, name2, des2, prms2, rt2, vol2, hasVar2, iso2)
-- | A map of all procs, all of which can be overloaded(one entry will have more than one Routine).
-- | It uses a HashMap for a faster lookup.
type RoutineMap = HM.HashMap QualifiedIdentifier [Routine]
data ResultAggregate
= BuiltinAggJson
| BuiltinAggSingleJson Bool
| BuiltinAggArrayJsonStrip
| BuiltinAggGeoJson
| BuiltinAggCsv
| BuiltinAggXml (Maybe FieldName)
| BuiltinAggBinary (Maybe FieldName)
| NoAgg
deriving (Eq, Show)
funcReturnsScalar :: Routine -> Bool
funcReturnsScalar proc = case proc of
Function{pdReturnType = Single (Scalar{})} -> True
_ -> False
funcReturnsSetOfScalar :: Routine -> Bool
funcReturnsSetOfScalar proc = case proc of
Function{pdReturnType = SetOf (Scalar{})} -> True
_ -> False
funcReturnsCompositeAlias :: Routine -> Bool
funcReturnsCompositeAlias proc = case proc of
Function{pdReturnType = Single (Composite _ True)} -> True
Function{pdReturnType = SetOf (Composite _ True)} -> True
_ -> False
funcReturnsSingleComposite :: Routine -> Bool
funcReturnsSingleComposite proc = case proc of
Function{pdReturnType = Single (Composite _ _)} -> True
_ -> False
funcReturnsVoid :: Routine -> Bool
funcReturnsVoid proc = case proc of
Function{pdReturnType = Single (Scalar (QualifiedIdentifier "pg_catalog" "void"))} -> True
_ -> False
funcTableName :: Routine -> Maybe TableName
funcTableName proc = case pdReturnType proc of
SetOf (Composite qi _) -> Just $ qiName qi
Single (Composite qi _) -> Just $ qiName qi
_ -> Nothing
+58
View File
@@ -0,0 +1,58 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
module PostgREST.SchemaCache.Table
( Column(..)
, Table(..)
, tableColumnsList
, TablesMap
, ColumnMap
) where
import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as HM
import qualified Data.HashMap.Strict.InsOrd as HMI
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema, TableName)
import Protolude
data Table = Table
{ tableSchema :: Schema
, tableName :: TableName
, tableDescription :: Maybe Text
-- TODO Find a better way to separate tables and views
, tableIsView :: Bool
-- The following fields identify what can be done on the table/view, they're not related to the privileges granted to it
, tableInsertable :: Bool
, tableUpdatable :: Bool
, tableDeletable :: Bool
, tablePKCols :: [FieldName]
, tableColumns :: ColumnMap
}
deriving (Show, Generic, JSON.ToJSON)
tableColumnsList :: Table -> [Column]
tableColumnsList = HMI.elems . tableColumns
instance Eq Table where
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
data Column = Column
{ colName :: FieldName
, colDescription :: Maybe Text
, colNullable :: Bool
, colType :: Text
, colNominalType :: Text
, colMaxLen :: Maybe Int32
, colDefault :: Maybe Text
, colEnum :: [Text]
}
deriving (Eq, Show, Ord, Generic, JSON.ToJSON)
type TablesMap = HM.HashMap QualifiedIdentifier Table
type ColumnMap = HMI.InsOrdHashMap FieldName Column
+6 -8
View File
@@ -14,7 +14,6 @@ import System.Posix.Files (setFileMode)
import System.Posix.Types (FileMode) import System.Posix.Types (FileMode)
import qualified PostgREST.AppState as AppState import qualified PostgREST.AppState as AppState
import qualified PostgREST.Workers as Workers
import Protolude import Protolude
@@ -43,17 +42,16 @@ runAppWithSocket settings app socketFileMode socketFilePath =
-- | Set signal handlers, only for systems with signals -- | Set signal handlers, only for systems with signals
installSignalHandlers :: AppState.AppState -> IO () installSignalHandlers :: AppState.AppState -> IO ()
installSignalHandlers appState = do installSignalHandlers appState = do
-- Releases the connection pool whenever the program is terminated, let interrupt = throwTo (AppState.getMainThreadId appState) UserInterrupt
-- see https://github.com/PostgREST/postgrest/issues/268 install Signals.sigINT interrupt
install Signals.sigINT $ AppState.releasePool appState install Signals.sigTERM interrupt
install Signals.sigTERM $ AppState.releasePool appState
-- 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 $ AppState.connectionWorker appState
-- Re-read the config on SIGUSR2 -- Re-read the config on SIGUSR2
install Signals.sigUSR2 $ Workers.reReadConfig False appState install Signals.sigUSR2 $ AppState.reReadConfig False appState
where where
install signal handler = install signal handler =
void $ Signals.installHandler signal (Signals.Catch handler) Nothing void $ Signals.installHandler signal (Signals.Catch handler) Nothing

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