Compare commits
+1
-1
@@ -3,7 +3,7 @@ freebsd_instance:
|
|||||||
|
|
||||||
build_task:
|
build_task:
|
||||||
name: Build FreeBSD (Stack)
|
name: Build FreeBSD (Stack)
|
||||||
install_script: pkg install -y postgresql13-client hs-stack
|
install_script: pkg install -y postgresql13-client hs-stack git
|
||||||
|
|
||||||
stack_cache:
|
stack_cache:
|
||||||
folders: /.stack
|
folders: /.stack
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ inputs:
|
|||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- uses: cachix/install-nix-action@v16
|
- uses: cachix/install-nix-action@v18
|
||||||
- uses: cachix/cachix-action@v10
|
- uses: cachix/cachix-action@v12
|
||||||
with:
|
with:
|
||||||
name: postgrest
|
name: postgrest
|
||||||
authToken: ${{ inputs.authToken }}
|
authToken: ${{ inputs.authToken }}
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tools: tests
|
tools: tests
|
||||||
|
|
||||||
- name: Run coverage (IO tests and Spec tests against PostgreSQL 14)
|
- name: Run coverage (IO tests and Spec tests against PostgreSQL 15)
|
||||||
run: postgrest-coverage
|
run: postgrest-coverage
|
||||||
- name: Upload coverage to codecov
|
- name: Upload coverage to codecov
|
||||||
uses: codecov/codecov-action@v3.1.0
|
uses: codecov/codecov-action@v3.1.1
|
||||||
with:
|
with:
|
||||||
files: ./coverage/codecov.json
|
files: ./coverage/codecov.json
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
pgVersion: [9.6, 10, 11, 12, 13, 14]
|
pgVersion: [9.6, 10, 11, 12, 13, 14, 15]
|
||||||
name: Test PG ${{ matrix.pgVersion }} (Nix)
|
name: Test PG ${{ matrix.pgVersion }} (Nix)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
@@ -148,14 +148,14 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: Linux & test
|
- name: Linux
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
cache: |
|
cache: |
|
||||||
~/.stack
|
~/.stack
|
||||||
.stack-work
|
.stack-work
|
||||||
artifact: postgrest-ubuntu-x64
|
artifact: postgrest-ubuntu-x64
|
||||||
|
|
||||||
- name: MacOS & test
|
- name: MacOS
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
cache: |
|
cache: |
|
||||||
~/.stack
|
~/.stack
|
||||||
@@ -211,6 +211,36 @@ jobs:
|
|||||||
path: postgrest
|
path: postgrest
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
Build-Cabal:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
ghc: ['8.10.7', '9.2.4']
|
||||||
|
fail-fast: false
|
||||||
|
name: Build Linux (Cabal, GHC ${{ matrix.ghc }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: ghcup
|
||||||
|
run: |
|
||||||
|
ghcup install ghc ${{ matrix.ghc }}
|
||||||
|
ghcup set ghc ${{ matrix.ghc }}
|
||||||
|
- name: Copy cabal.project
|
||||||
|
run: |
|
||||||
|
cp cabal.project.non-nix cabal.project
|
||||||
|
- name: Cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.cabal
|
||||||
|
key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('**/*.cabal') }}-${{ hashFiles('**/cabal.project') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ matrix.ghc }}-
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cabal update
|
||||||
|
cabal build --only-dependencies --enable-tests --enable-benchmarks
|
||||||
|
- name: Build
|
||||||
|
run: cabal build --enable-tests --enable-benchmarks all
|
||||||
|
|
||||||
Build-Cabal-Arm:
|
Build-Cabal-Arm:
|
||||||
name: Build aarch64 (Cabal)
|
name: Build aarch64 (Cabal)
|
||||||
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/rel-') }}
|
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/rel-') }}
|
||||||
@@ -220,7 +250,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_COMMIT: ${{ github.sha }}
|
GITHUB_COMMIT: ${{ github.sha }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2.4.0
|
- uses: actions/checkout@v3
|
||||||
- id: Remote-Dir
|
- id: Remote-Dir
|
||||||
name: Unique directory name for the remote build
|
name: Unique directory name for the remote build
|
||||||
run: echo "::set-output name=remotepath::postgrest-build-$(uuidgen)"
|
run: echo "::set-output name=remotepath::postgrest-build-$(uuidgen)"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
name: loadtest.md
|
name: loadtest.md
|
||||||
path: artifacts
|
path: artifacts
|
||||||
- name: Upload to GitHub Checks
|
- name: Upload to GitHub Checks
|
||||||
uses: LouisBrunner/checks-action@v1.2.0
|
uses: LouisBrunner/checks-action@v1.5.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
sha: ${{ github.event.workflow_run.head_sha }}
|
sha: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This document describes the high-level architecture of PostgREST.
|
||||||
|
|
||||||
|
## Bird's Eye View
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
postgrest :: Request -> Either Error SQLStatement -> Response
|
||||||
|
```
|
||||||
|
|
||||||
|
On the highest level, PostgREST processes an HTTP request, if it's accepted it builds a SQL statement for it, executes it, and produces a response.
|
||||||
|
|
||||||
|
## Code Map
|
||||||
|
|
||||||
|
This section talks briefly about various important modules.
|
||||||
|
|
||||||
|
The starting point of the program is `main/Main.hs`, which calls `src/PostgREST/CLI.hs` which then calls `src/PostgREST/App.hs`.
|
||||||
|
|
||||||
|
`App.hs` is then in charge of composing the different modules.
|
||||||
|
|
||||||
|
### ApiRequest.hs
|
||||||
|
|
||||||
|
PostgREST operates over two types of resources: database relations(tables or views) and database functions; providing different representations(depending on the media type)
|
||||||
|
for them.
|
||||||
|
|
||||||
|
This module is in charge of representing the operation over an `ApiRequest` type. It parses the URL querystring following PostgREST syntax, the request headers, and the request body
|
||||||
|
(if possible it avoids parsing the body and sends it directly to the db).
|
||||||
|
|
||||||
|
A request might be rejected at this level if it's invalid, e.g. providing an unknown media type to PostgREST or using an unknown HTTP method.
|
||||||
|
|
||||||
|
### Plan.hs
|
||||||
|
|
||||||
|
Using the Schema Cache, this module enables more complex functionality(like resource embedding) by enriching the ApiRequest. It generates Plan types(`ReadPlan`, `MutatePlan`)
|
||||||
|
that then will be used to generate a SQL statement.
|
||||||
|
|
||||||
|
A request might be rejected at this level if it's invalid, e.g. by doing resource embedding on a nonexistent resource.
|
||||||
|
|
||||||
|
An OPTIONS request doesn't require a plan to be generated.
|
||||||
|
|
||||||
|
### Query.hs
|
||||||
|
|
||||||
|
This module constructs single SQL statements that can be parametrized and prepared. Only at this stage a PostgreSQL connection from the pool is used.
|
||||||
|
|
||||||
|
A query might fail(and be rollbacked) at this level if it doesn't comply to certain conditions, e.g. by not returning a single row when a ``Accept: application/vnd.pgrst.object`` header is specified.
|
||||||
|
|
||||||
|
An OPTIONS request doesn't require a query to be executed.
|
||||||
|
|
||||||
|
### Response.hs
|
||||||
|
|
||||||
|
This module constructs the HTTP response body with the right headers.
|
||||||
|
|
||||||
|
It builds the OpenAPI response using the schema cache.
|
||||||
|
|
||||||
|
### Auth.hs
|
||||||
|
|
||||||
|
This module provides functions to deal with JWT authorization.
|
||||||
|
|
||||||
|
### Workers.hs
|
||||||
|
|
||||||
|
This spawns threads which are used to execute concurrent jobs.
|
||||||
|
|
||||||
|
Jobs include connection recovery, a listener for the PostgreSQL LISTEN command, and an admin server.
|
||||||
|
|
||||||
|
### SchemaCache.hs
|
||||||
|
|
||||||
|
This queries the PostgreSQL system catalogs and caches the metadata into a SchemaCache type,
|
||||||
|
|
||||||
|
### AppState.hs
|
||||||
|
|
||||||
|
The state of the App which is kept across requests.
|
||||||
+56
-1
@@ -3,7 +3,61 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
## Unreleased
|
## [10.1.2] - 2023-02-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #2565, Fix bad M2M embedding on RPC - @steve-chavez
|
||||||
|
- #2575, Replace misleading error message when no function is found with a hint containing functions/parameters names suggestions - @laurenceisla
|
||||||
|
- #2582, Move explanation about "single parameters" from the `message` to the `details` in the error output - @laurenceisla
|
||||||
|
- #2569, Replace misleading error message when no relationship is found with a hint containing parent/child names suggestions - @laurenceisla
|
||||||
|
- #1405, Add the required OpenAPI items object when the parameter is an array - @laurenceisla
|
||||||
|
- #2592, Add upsert headers for POST requests to the OpenAPI output - @laurenceisla
|
||||||
|
- #2623, Fix FK pointing to VIEW instead of TABLE in OpenAPI output - @laurenceisla
|
||||||
|
- #2622, Consider any PostgreSQL authentication failure as fatal and exit immediately - @michivi
|
||||||
|
- #2620, Fix `NOTIFY pgrst` not reloading the db connections catalog cache - @steve-chavez
|
||||||
|
|
||||||
|
## [10.1.1] - 2022-11-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #2548, Fix regression when embedding views with partial references to multi column FKs - @wolfgangwalther
|
||||||
|
- #2558, Fix regression when requesting limit=0 and `db-max-row` is set - @laurenceisla
|
||||||
|
|
||||||
|
## [10.1.0] - 2022-10-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- #2348, Add `db-pool-acquisition-timeout` configuration option, time in seconds to wait to acquire a connection. - @robx
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #2261, #2349, #2467, Reduce allocations communication with PostgreSQL, particularly for request bodies. - @robx
|
||||||
|
- #2401, #2444, Fix SIGUSR1 to fully flush connections pool. - @robx
|
||||||
|
- #2428, Fix opening an empty transaction on failed resource embedding - @steve-chavez
|
||||||
|
- #2455, Fix embedding the same table multiple times - @steve-chavez
|
||||||
|
- #2518, Fix a regression when embedding views where base tables have a different column order for FK columns - @wolfgangwalther
|
||||||
|
- #2458, Fix a regression with the location header when inserting into views with PKs from multiple tables - @wolfgangwalther
|
||||||
|
- #2356, Fix a regression in openapi output with mode follow-privileges - @wolfgangwalther
|
||||||
|
- #2283, Fix infinite recursion when loading schema cache with self-referencing view - @wolfgangwalther
|
||||||
|
- #2343, Return status code 200 for PATCH requests which don't affect any rows - @wolfgangwalther
|
||||||
|
- #2481, Treat computed relationships not marked SETOF as M2O/O2O relationship - @wolfgangwalther
|
||||||
|
- #2534, Fix embedding a computed relationship with a normal relationship - @steve-chavez
|
||||||
|
- #2362, Fix error message when [] is used inside select - @wolfgangwalther
|
||||||
|
- #2475, Disallow !inner on computed columns - @wolfgangwalther
|
||||||
|
- #2285, Ignore leading and trailing spaces in column names when parsing the query string - @wolfgangwalther
|
||||||
|
- #2545, Fix UPSERT with PostgreSQL 15 - @wolfgangwalther
|
||||||
|
- #2459, Fix embedding views with multiple references to the same base column - @wolfgangwalther
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- #2444, Removed `db-pool-timeout` option, because this was removed upstream in hasql-pool. - @robx
|
||||||
|
- #2343, PATCH requests that don't affect any rows no longer return 404 - @wolfgangwalther
|
||||||
|
- #2537, Stricter parsing of query string. Instead of silently ignoring, the parser now throws on invalid syntax like json paths for embeddings, hints for regular columns, empty casts or fts languages, etc. - @wolfgangwalther
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- #1385, Deprecate bulk-calls when including the `Prefer: params=multiple-objects` in the request. A function with a JSON array or object parameter should be used instead for a better performance.
|
||||||
|
|
||||||
## [10.0.0] - 2022-08-18
|
## [10.0.0] - 2022-08-18
|
||||||
|
|
||||||
@@ -70,6 +124,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- #2410, Fix loop crash error on startup in Postgres 15 beta 3. Log: "UNION types \"char\" and text cannot be matched". - @yevon
|
- #2410, Fix loop crash error on startup in Postgres 15 beta 3. Log: "UNION types \"char\" and text cannot be matched". - @yevon
|
||||||
- #2397, Fix race conditions managing database connection helper - @robx
|
- #2397, Fix race conditions managing database connection helper - @robx
|
||||||
- #2269, Allow `limit=0` in the request query to return an empty array - @gautam1168, @laurenceisla
|
- #2269, Allow `limit=0` in the request query to return an empty array - @gautam1168, @laurenceisla
|
||||||
|
- #2401, Ensure database connections can't outlive SIGUSR1 - @robx
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Settings to allow building with plain cabal. If this was
|
||||||
|
-- named just cabal.project, it would interfere with the default
|
||||||
|
-- nix build.
|
||||||
|
|
||||||
|
packages: .
|
||||||
|
|
||||||
|
-- Example of depending on a forked repository (the same dependency
|
||||||
|
-- would be mentioned in nix/overlays/haskell-packages.nix and
|
||||||
|
-- stack.yaml, and should refer to a main branch commit of the
|
||||||
|
-- repository.
|
||||||
|
--
|
||||||
|
-- source-repository-package
|
||||||
|
-- type: git
|
||||||
|
-- location: https://github.com/PostgREST/hasql-pool.git
|
||||||
|
-- tag: 4d462c4d47d762effefc7de6c85eaed55f144f1d
|
||||||
|
|
||||||
|
source-repository-package
|
||||||
|
type: git
|
||||||
|
location: https://github.com/PostgREST/postgresql-libpq.git
|
||||||
|
tag: 33ff97db570b5b432255f5f24a68db51453f6eb8
|
||||||
+3
-1
@@ -36,7 +36,8 @@ let
|
|||||||
allOverlays.build-toolbox
|
allOverlays.build-toolbox
|
||||||
allOverlays.checked-shell-script
|
allOverlays.checked-shell-script
|
||||||
allOverlays.gitignore
|
allOverlays.gitignore
|
||||||
allOverlays.postgresql-default
|
allOverlays.postgis
|
||||||
|
(allOverlays.postgresql-default { inherit patches; })
|
||||||
allOverlays.postgresql-legacy
|
allOverlays.postgresql-legacy
|
||||||
allOverlays.postgresql-future
|
allOverlays.postgresql-future
|
||||||
(allOverlays.haskell-packages { inherit compiler; })
|
(allOverlays.haskell-packages { inherit compiler; })
|
||||||
@@ -48,6 +49,7 @@ let
|
|||||||
|
|
||||||
postgresqlVersions =
|
postgresqlVersions =
|
||||||
[
|
[
|
||||||
|
{ name = "postgresql-15"; postgresql = pkgs.postgresql_15.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||||
{ name = "postgresql-14"; postgresql = pkgs.postgresql_14.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
{ name = "postgresql-14"; postgresql = pkgs.postgresql_14.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||||
{ name = "postgresql-13"; postgresql = pkgs.postgresql_13.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
{ name = "postgresql-13"; postgresql = pkgs.postgresql_13.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||||
{ name = "postgresql-12"; postgresql = pkgs.postgresql_12.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
{ name = "postgresql-12"; postgresql = pkgs.postgresql_12.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Pinned version of Nixpkgs, generated with postgrest-nixpkgs-upgrade.
|
# Pinned version of Nixpkgs, generated with postgrest-nixpkgs-upgrade.
|
||||||
{
|
{
|
||||||
date = "2022-08-09";
|
date = "2022-10-28";
|
||||||
rev = "9f15d6c3a74d2778c6e1af67947c95f100dc6fd2";
|
rev = "f44ba1be526c8da9e79a5759feca2365204003f6";
|
||||||
tarballHash = "14axdmi3kb6rlib39ik42yq907bm66x6vzswm5w1rsnw9vzgm31a";
|
tarballHash = "0npbwsdjw88py5w2pjflwh94wgi4jmnmls0k1n7q8m6h94w1y1ps";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# directly, or use the .bin attribute to get the script in a bin/ directory,
|
# directly, or use the .bin attribute to get the script in a bin/ directory,
|
||||||
# to be used in a path for example.
|
# to be used in a path for example.
|
||||||
{ argbash
|
{ argbash
|
||||||
, bash_5
|
, bash
|
||||||
, coreutils
|
, coreutils
|
||||||
, git
|
, git
|
||||||
, lib
|
, lib
|
||||||
@@ -77,7 +77,7 @@ let
|
|||||||
|
|
||||||
text =
|
text =
|
||||||
''
|
''
|
||||||
#!${bash_5}/bin/bash
|
#!${bash}/bin/bash
|
||||||
source ${argsParser}
|
source ${argsParser}
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
''
|
''
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
checked-shell-script = import ./checked-shell-script;
|
checked-shell-script = import ./checked-shell-script;
|
||||||
gitignore = import ./gitignore.nix;
|
gitignore = import ./gitignore.nix;
|
||||||
haskell-packages = import ./haskell-packages.nix;
|
haskell-packages = import ./haskell-packages.nix;
|
||||||
|
postgis = import ./postgis.nix;
|
||||||
postgresql-default = import ./postgresql-default.nix;
|
postgresql-default = import ./postgresql-default.nix;
|
||||||
postgresql-legacy = import ./postgresql-legacy.nix;
|
postgresql-legacy = import ./postgresql-legacy.nix;
|
||||||
postgresql-future = import ./postgresql-future.nix;
|
postgresql-future = import ./postgresql-future.nix;
|
||||||
|
|||||||
@@ -13,13 +13,10 @@ let
|
|||||||
# {
|
# {
|
||||||
# pkg = "protolude";
|
# pkg = "protolude";
|
||||||
# ver = "0.3.0";
|
# ver = "0.3.0";
|
||||||
# sha256 = "0iwh4wsjhb7pms88lw1afhdal9f86nrrkkvv65f9wxbd1b159n72";
|
# sha256 = "<sha256>";
|
||||||
# }
|
# }
|
||||||
# { };
|
# { };
|
||||||
#
|
#
|
||||||
# To get the sha256:
|
|
||||||
# nix-prefetch-url --unpack https://hackage.haskell.org/package/protolude-0.3.0/protolude-0.3.0.tar.gz
|
|
||||||
|
|
||||||
# To temporarily pin unreleased versions from GitHub:
|
# To temporarily pin unreleased versions from GitHub:
|
||||||
# <name> =
|
# <name> =
|
||||||
# prev.callCabal2nixWithOptions "<name>" (super.fetchFromGitHub {
|
# prev.callCabal2nixWithOptions "<name>" (super.fetchFromGitHub {
|
||||||
@@ -29,8 +26,36 @@ let
|
|||||||
# sha256 = "<sha256>";
|
# sha256 = "<sha256>";
|
||||||
# }) "--subpath=<subpath>" {};
|
# }) "--subpath=<subpath>" {};
|
||||||
#
|
#
|
||||||
# To get the sha256:
|
# To fill in the sha256:
|
||||||
# nix-prefetch-url --unpack https://github.com/<owner>/<repo>/archive/<commit>.tar.gz
|
# update-nix-fetchgit nix/overlays/haskell-packages.nix
|
||||||
|
|
||||||
|
hashtables = lib.dontCheck prev.hashtables_1_3_1;
|
||||||
|
hasql = lib.dontCheck prev.hasql_1_6_1_4;
|
||||||
|
hasql-dynamic-statements = lib.dontCheck prev.hasql-dynamic-statements_0_3_1_2;
|
||||||
|
hasql-pool = lib.dontCheck
|
||||||
|
(prev.callHackageDirect
|
||||||
|
{
|
||||||
|
pkg = "hasql-pool";
|
||||||
|
ver = "0.8.0.6";
|
||||||
|
sha256 = "sha256-2u/cwPk8XfXffaDRzGeyzhL+9k2+2T4b8bGOZwz8AX0=";
|
||||||
|
}
|
||||||
|
{ });
|
||||||
|
hasql-transaction = lib.dontCheck prev.hasql-transaction_1_0_1_2;
|
||||||
|
isomorphism-class = lib.unmarkBroken prev.isomorphism-class;
|
||||||
|
lens = lib.dontCheck prev.lens_5_2;
|
||||||
|
postgresql-binary = lib.dontCheck prev.postgresql-binary_0_13_1;
|
||||||
|
text-builder = lib.dontCheck prev.text-builder_0_6_7;
|
||||||
|
text-builder-dev = lib.dontCheck prev.text-builder-dev_0_3_3;
|
||||||
|
|
||||||
|
postgresql-libpq = lib.dontCheck
|
||||||
|
(prev.callCabal2nix "postgresql-libpq"
|
||||||
|
(super.fetchFromGitHub {
|
||||||
|
owner = "PostgREST";
|
||||||
|
repo = "postgresql-libpq";
|
||||||
|
rev = "cef92cb4c07b56568dffdbf4b719258b82183119"; # master
|
||||||
|
sha256 = "0r59klrz47qcnd22s47h612mlz3jbg40wwalfj3f6djwg0cdyr85";
|
||||||
|
})
|
||||||
|
{ });
|
||||||
} // extraOverrides final prev;
|
} // extraOverrides final prev;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 ];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,19 @@ self: super:
|
|||||||
};
|
};
|
||||||
in
|
in
|
||||||
(import pinnedPkgs { }).pkgs.postgresql_9_6;
|
(import pinnedPkgs { }).pkgs.postgresql_9_6;
|
||||||
|
|
||||||
|
# PostgreSQL 10 was removed from Nixpkgs with
|
||||||
|
# https://github.com/NixOS/nixpkgs/commit/aa1483114bb329fee7e1266100b8d8921ed4723f
|
||||||
|
# We pin its parent commit to get the last version that was available.
|
||||||
|
postgresql_10 =
|
||||||
|
let
|
||||||
|
rev = "79661ba7e2fb96ebefbb537458a5bbae9dc5bd1a";
|
||||||
|
tarballHash = "0rn796pfn4sg90ai9fdnwmr10a2s835p1arazzgz46h6s5cxvq97";
|
||||||
|
pinnedPkgs =
|
||||||
|
builtins.fetchTarball {
|
||||||
|
url = "https://github.com/nixos/nixpkgs/archive/${rev}.tar.gz";
|
||||||
|
sha256 = tarballHash;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
(import pinnedPkgs { }).pkgs.postgresql_10;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,8 @@
|
|||||||
./static-haskell-nix-ncurses.patch;
|
./static-haskell-nix-ncurses.patch;
|
||||||
static-haskell-nix-ghc-bignum =
|
static-haskell-nix-ghc-bignum =
|
||||||
./static-haskell-nix-ghc-bignum.patch;
|
./static-haskell-nix-ghc-bignum.patch;
|
||||||
|
static-haskell-nix-openssl =
|
||||||
|
./static-haskell-nix-openssl.patch;
|
||||||
|
postgresql-atexit =
|
||||||
|
./postgresql-atexit.patch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
--- a/src/interfaces/libpq/Makefile
|
||||||
|
+++ b/src/interfaces/libpq/Makefile
|
||||||
|
@@ -118,7 +118,7 @@ backend_src = $(top_srcdir)/src/backend
|
||||||
|
libpq-refs-stamp: $(shlib)
|
||||||
|
ifneq ($(enable_coverage), yes)
|
||||||
|
ifeq (,$(filter aix solaris,$(PORTNAME)))
|
||||||
|
- @if nm -A -u $< 2>/dev/null | grep -v __cxa_atexit | grep exit; then \
|
||||||
|
+ @if nm -A -u $< 2>/dev/null | grep " exit"; then \
|
||||||
|
echo 'libpq must not be calling any function which invokes exit'; exit 1; \
|
||||||
|
fi
|
||||||
|
endif
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
diff --git a/survey/default.nix b/survey/default.nix
|
||||||
|
index cf1bd31..9d34753 100644
|
||||||
|
--- a/survey/default.nix
|
||||||
|
+++ b/survey/default.nix
|
||||||
|
@@ -736,6 +736,7 @@ let
|
||||||
|
openblas = previous.openblas.override { enableStatic = true; };
|
||||||
|
|
||||||
|
openssl = previous.openssl.override { static = true; };
|
||||||
|
+ openssl_1_1 = previous.openssl_1_1.override { static = true; };
|
||||||
|
|
||||||
|
libsass = previous.libsass.overrideAttrs (old: { dontDisableStatic = true; });
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ let
|
|||||||
[
|
[
|
||||||
patches.static-haskell-nix-ncurses
|
patches.static-haskell-nix-ncurses
|
||||||
patches.static-haskell-nix-ghc-bignum
|
patches.static-haskell-nix-ghc-bignum
|
||||||
|
patches.static-haskell-nix-openssl
|
||||||
];
|
];
|
||||||
|
|
||||||
extraOverrides =
|
extraOverrides =
|
||||||
@@ -34,7 +35,7 @@ let
|
|||||||
overlays =
|
overlays =
|
||||||
[
|
[
|
||||||
allOverlays.postgresql-future
|
allOverlays.postgresql-future
|
||||||
allOverlays.postgresql-default
|
(allOverlays.postgresql-default { inherit patches; })
|
||||||
(allOverlays.haskell-packages { inherit compiler extraOverrides; })
|
(allOverlays.haskell-packages { inherit compiler extraOverrides; })
|
||||||
# Disable failing tests for postgresql on musl that should have no impact
|
# Disable failing tests for postgresql on musl that should have no impact
|
||||||
# on the libpq that we need (collate.icu.utf8 and foreign regression
|
# on the libpq that we need (collate.icu.utf8 and foreign regression
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ let
|
|||||||
)"
|
)"
|
||||||
|
|
||||||
repo_url="https://hub.docker.com/v2/repositories/$DOCKER_REPO/postgrest/"
|
repo_url="https://hub.docker.com/v2/repositories/$DOCKER_REPO/postgrest/"
|
||||||
echo "Patching both descriptions at $repo_url ..."
|
echo "Patching both descriptions at $repo_url ..."
|
||||||
${curl}/bin/curl --fail -X PATCH "$repo_url" \
|
${curl}/bin/curl --fail -X PATCH "$repo_url" \
|
||||||
-H "Authorization: JWT $token" \
|
-H "Authorization: JWT $token" \
|
||||||
--data-urlencode description@${description} \
|
--data-urlencode description@${description} \
|
||||||
@@ -51,7 +51,7 @@ let
|
|||||||
checkedShellScript
|
checkedShellScript
|
||||||
{
|
{
|
||||||
name = "postgrest-release";
|
name = "postgrest-release";
|
||||||
docs = "Patch postgrest.cabal, tag and push all in one go.";
|
docs = "Patch postgrest.cabal, CHANGELOG.md, tag and push all in one go.";
|
||||||
args = [ "ARG_POSITIONAL_SINGLE([version], [Version to release], [pre])" ];
|
args = [ "ARG_POSITIONAL_SINGLE([version], [Version to release], [pre])" ];
|
||||||
inRootDir = true;
|
inRootDir = true;
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,9 @@ let
|
|||||||
IFS=. read -r major minor patch pre <<< "$current_version"
|
IFS=. read -r major minor patch pre <<< "$current_version"
|
||||||
echo "Current version is $current_version"
|
echo "Current version is $current_version"
|
||||||
|
|
||||||
bump_pre="$major.$minor.$patch.$(date '+%Y%m%d')"
|
today_date="$(date '+%Y%m%d')"
|
||||||
|
today_date_for_changelog="$(date '+%Y-%m-%d')"
|
||||||
|
bump_pre="$major.$minor.$patch.$today_date"
|
||||||
bump_patch="$major.$minor.$((patch+1))"
|
bump_patch="$major.$minor.$((patch+1))"
|
||||||
bump_minor="$major.$((minor+1)).0"
|
bump_minor="$major.$((minor+1)).0"
|
||||||
bump_major="$((major+1)).0.0"
|
bump_major="$((major+1)).0.0"
|
||||||
@@ -92,6 +94,13 @@ let
|
|||||||
|
|
||||||
echo "Committing ..."
|
echo "Committing ..."
|
||||||
git add postgrest.cabal > /dev/null
|
git add postgrest.cabal > /dev/null
|
||||||
|
|
||||||
|
if [[ "$new_version" != "$bump_pre" ]]; then
|
||||||
|
echo "Updating CHANGELOG.md ..."
|
||||||
|
sed -i -E "s/Unreleased/&\n\n## [$new_version] - $today_date_for_changelog/" CHANGELOG.md > /dev/null
|
||||||
|
git add CHANGELOG.md > /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
git commit -m "bump version to $new_version" > /dev/null
|
git commit -m "bump version to $new_version" > /dev/null
|
||||||
|
|
||||||
echo "Tagging ..."
|
echo "Tagging ..."
|
||||||
@@ -107,9 +116,9 @@ let
|
|||||||
echo
|
echo
|
||||||
echo "$push"
|
echo "$push"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
read -r -p 'Proceed? (y/N) ' REPLY
|
read -r -p 'Proceed? (y/N) ' REPLY
|
||||||
case "$REPLY" in
|
case "$REPLY" in
|
||||||
y|Y)
|
y|Y)
|
||||||
$push
|
$push
|
||||||
;;
|
;;
|
||||||
|
|||||||
+2
-2
@@ -80,7 +80,7 @@ let
|
|||||||
python3.withPackages (ps: [
|
python3.withPackages (ps: [
|
||||||
ps.pyjwt
|
ps.pyjwt
|
||||||
ps.pytest
|
ps.pytest
|
||||||
ps.pytest_xdist
|
ps.pytest-xdist
|
||||||
ps.pyyaml
|
ps.pyyaml
|
||||||
ps.requests
|
ps.requests
|
||||||
ps.requests-unixsocket
|
ps.requests-unixsocket
|
||||||
@@ -105,7 +105,7 @@ let
|
|||||||
checkedShellScript
|
checkedShellScript
|
||||||
{
|
{
|
||||||
name = "postgrest-dump-schema";
|
name = "postgrest-dump-schema";
|
||||||
docs = "Dump the loaded schema's DbStructure as a yaml file.";
|
docs = "Dump the loaded schema's SchemaCache as a yaml file.";
|
||||||
inRootDir = true;
|
inRootDir = true;
|
||||||
withEnv = postgrest.env;
|
withEnv = postgrest.env;
|
||||||
withPath = [ jq ];
|
withPath = [ jq ];
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ let
|
|||||||
export PGDATABASE
|
export PGDATABASE
|
||||||
export PGRST_DB_SCHEMAS
|
export PGRST_DB_SCHEMAS
|
||||||
|
|
||||||
|
HBA_FILE="$tmpdir/pg_hba.conf"
|
||||||
|
echo "local $PGDATABASE some_protected_user password" > "$HBA_FILE"
|
||||||
|
echo "local $PGDATABASE all trust" >> "$HBA_FILE"
|
||||||
|
|
||||||
log "Initializing database cluster..."
|
log "Initializing database cluster..."
|
||||||
# We try to make the database cluster as independent as possible from the host
|
# We try to make the database cluster as independent as possible from the host
|
||||||
# by specifying the timezone, locale and encoding.
|
# by specifying the timezone, locale and encoding.
|
||||||
@@ -62,7 +66,7 @@ let
|
|||||||
|
|
||||||
log "Starting the database cluster..."
|
log "Starting the database cluster..."
|
||||||
# Instead of listening on a local port, we will listen on a unix domain socket.
|
# Instead of listening on a local port, we will listen on a unix domain socket.
|
||||||
pg_ctl -l "$tmpdir/db.log" -w start -o "-F -c listen_addresses=\"\" -k $PGHOST -c log_statement=\"all\"" \
|
pg_ctl -l "$tmpdir/db.log" -w start -o "-F -c listen_addresses=\"\" -c hba_file=$HBA_FILE -k $PGHOST -c log_statement=\"all\"" \
|
||||||
>> "$setuplog"
|
>> "$setuplog"
|
||||||
|
|
||||||
stop () {
|
stop () {
|
||||||
|
|||||||
+32
-30
@@ -1,5 +1,5 @@
|
|||||||
name: postgrest
|
name: postgrest
|
||||||
version: 10.0.0
|
version: 10.1.2
|
||||||
synopsis: REST API for any Postgres database
|
synopsis: REST API for any Postgres database
|
||||||
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||||
for tables, views, and functions, supporting all HTTP methods that security
|
for tables, views, and functions, supporting all HTTP methods that security
|
||||||
@@ -35,7 +35,6 @@ library
|
|||||||
NoImplicitPrelude
|
NoImplicitPrelude
|
||||||
hs-source-dirs: src
|
hs-source-dirs: src
|
||||||
exposed-modules: PostgREST.App
|
exposed-modules: PostgREST.App
|
||||||
PostgREST.Admin
|
|
||||||
PostgREST.AppState
|
PostgREST.AppState
|
||||||
PostgREST.Auth
|
PostgREST.Auth
|
||||||
PostgREST.CLI
|
PostgREST.CLI
|
||||||
@@ -45,35 +44,37 @@ library
|
|||||||
PostgREST.Config.PgVersion
|
PostgREST.Config.PgVersion
|
||||||
PostgREST.Config.Proxy
|
PostgREST.Config.Proxy
|
||||||
PostgREST.Cors
|
PostgREST.Cors
|
||||||
PostgREST.DbStructure
|
PostgREST.SchemaCache
|
||||||
PostgREST.DbStructure.Identifiers
|
PostgREST.SchemaCache.Identifiers
|
||||||
PostgREST.DbStructure.Proc
|
PostgREST.SchemaCache.Proc
|
||||||
PostgREST.DbStructure.Relationship
|
PostgREST.SchemaCache.Relationship
|
||||||
PostgREST.DbStructure.Table
|
PostgREST.SchemaCache.Table
|
||||||
PostgREST.Error
|
PostgREST.Error
|
||||||
PostgREST.GucHeader
|
|
||||||
PostgREST.Logger
|
PostgREST.Logger
|
||||||
PostgREST.Middleware
|
|
||||||
PostgREST.MediaType
|
PostgREST.MediaType
|
||||||
PostgREST.OpenAPI
|
PostgREST.Query
|
||||||
PostgREST.Query.QueryBuilder
|
PostgREST.Query.QueryBuilder
|
||||||
PostgREST.Query.SqlFragment
|
PostgREST.Query.SqlFragment
|
||||||
PostgREST.Query.Statements
|
PostgREST.Query.Statements
|
||||||
|
PostgREST.Plan
|
||||||
|
PostgREST.Plan.CallPlan
|
||||||
|
PostgREST.Plan.MutatePlan
|
||||||
|
PostgREST.Plan.ReadPlan
|
||||||
PostgREST.RangeQuery
|
PostgREST.RangeQuery
|
||||||
PostgREST.Request.ApiRequest
|
PostgREST.ApiRequest
|
||||||
PostgREST.Request.DbRequestBuilder
|
PostgREST.ApiRequest.Preferences
|
||||||
PostgREST.Request.MutateQuery
|
PostgREST.ApiRequest.QueryParams
|
||||||
PostgREST.Request.Preferences
|
PostgREST.ApiRequest.Types
|
||||||
PostgREST.Request.QueryParams
|
PostgREST.Response
|
||||||
PostgREST.Request.ReadQuery
|
PostgREST.Response.OpenAPI
|
||||||
PostgREST.Request.Types
|
PostgREST.Response.GucHeader
|
||||||
PostgREST.Version
|
PostgREST.Version
|
||||||
PostgREST.Workers
|
PostgREST.Workers
|
||||||
other-modules: Paths_postgrest
|
other-modules: Paths_postgrest
|
||||||
build-depends: base >= 4.9 && < 4.17
|
build-depends: base >= 4.9 && < 4.17
|
||||||
, HTTP >= 4000.3.7 && < 4000.4
|
, HTTP >= 4000.3.7 && < 4000.4
|
||||||
, Ranged-sets >= 0.3 && < 0.5
|
, Ranged-sets >= 0.3 && < 0.5
|
||||||
, aeson >= 2.0.3 && < 2.1
|
, aeson >= 2.0.3 && < 2.2
|
||||||
, auto-update >= 0.1.4 && < 0.2
|
, auto-update >= 0.1.4 && < 0.2
|
||||||
, base64-bytestring >= 1 && < 1.3
|
, base64-bytestring >= 1 && < 1.3
|
||||||
, bytestring >= 0.10.8 && < 0.12
|
, bytestring >= 0.10.8 && < 0.12
|
||||||
@@ -84,18 +85,19 @@ library
|
|||||||
, contravariant-extras >= 0.3.3 && < 0.4
|
, contravariant-extras >= 0.3.3 && < 0.4
|
||||||
, cookie >= 0.4.2 && < 0.5
|
, cookie >= 0.4.2 && < 0.5
|
||||||
, either >= 4.4.1 && < 5.1
|
, either >= 4.4.1 && < 5.1
|
||||||
|
, fuzzyset >= 0.2.3
|
||||||
, gitrev >= 1.2 && < 1.4
|
, gitrev >= 1.2 && < 1.4
|
||||||
, hasql >= 1.4 && < 1.6
|
, hasql >= 1.6.1.1 && < 1.7
|
||||||
, hasql-dynamic-statements >= 0.3.1 && < 0.4
|
, hasql-dynamic-statements >= 0.3.1 && < 0.4
|
||||||
, hasql-notifications >= 0.1 && < 0.3
|
, hasql-notifications >= 0.1 && < 0.3
|
||||||
, hasql-pool >= 0.5 && < 0.6
|
, hasql-pool >= 0.8.0.6 && < 0.9
|
||||||
, hasql-transaction >= 1.0.1 && < 1.1
|
, hasql-transaction >= 1.0.1 && < 1.1
|
||||||
, heredoc >= 0.2 && < 0.3
|
, heredoc >= 0.2 && < 0.3
|
||||||
, http-types >= 0.12.2 && < 0.13
|
, http-types >= 0.12.2 && < 0.13
|
||||||
, insert-ordered-containers >= 0.2.2 && < 0.3
|
, insert-ordered-containers >= 0.2.2 && < 0.3
|
||||||
, interpolatedstring-perl6 >= 1 && < 1.1
|
, interpolatedstring-perl6 >= 1 && < 1.1
|
||||||
, jose >= 0.8.5.1 && < 0.10
|
, jose >= 0.8.5.1 && < 0.11
|
||||||
, lens >= 4.14 && < 5.2
|
, lens >= 4.14 && < 5.3
|
||||||
, lens-aeson >= 1.0.1 && < 1.2
|
, lens-aeson >= 1.0.1 && < 1.2
|
||||||
, mtl >= 2.2.2 && < 2.3
|
, mtl >= 2.2.2 && < 2.3
|
||||||
, network >= 2.6 && < 3.2
|
, network >= 2.6 && < 3.2
|
||||||
@@ -111,7 +113,7 @@ library
|
|||||||
, time >= 1.6 && < 1.12
|
, time >= 1.6 && < 1.12
|
||||||
, unordered-containers >= 0.2.8 && < 0.3
|
, unordered-containers >= 0.2.8 && < 0.3
|
||||||
, vault >= 0.3.1.5 && < 0.4
|
, vault >= 0.3.1.5 && < 0.4
|
||||||
, vector >= 0.11 && < 0.13
|
, vector >= 0.11 && < 0.14
|
||||||
, wai >= 3.2.1 && < 3.3
|
, wai >= 3.2.1 && < 3.3
|
||||||
, wai-cors >= 0.2.5 && < 0.3
|
, wai-cors >= 0.2.5 && < 0.3
|
||||||
, wai-extra >= 3.1.8 && < 3.2
|
, wai-extra >= 3.1.8 && < 3.2
|
||||||
@@ -218,7 +220,7 @@ test-suite spec
|
|||||||
SpecHelper
|
SpecHelper
|
||||||
TestTypes
|
TestTypes
|
||||||
build-depends: base >= 4.9 && < 4.17
|
build-depends: base >= 4.9 && < 4.17
|
||||||
, aeson >= 2.0.3 && < 2.1
|
, aeson >= 2.0.3 && < 2.2
|
||||||
, aeson-qq >= 0.8.1 && < 0.9
|
, aeson-qq >= 0.8.1 && < 0.9
|
||||||
, async >= 2.1.1 && < 2.3
|
, async >= 2.1.1 && < 2.3
|
||||||
, auto-update >= 0.1.4 && < 0.2
|
, auto-update >= 0.1.4 && < 0.2
|
||||||
@@ -226,14 +228,14 @@ test-suite spec
|
|||||||
, bytestring >= 0.10.8 && < 0.12
|
, bytestring >= 0.10.8 && < 0.12
|
||||||
, case-insensitive >= 1.2 && < 1.3
|
, case-insensitive >= 1.2 && < 1.3
|
||||||
, containers >= 0.5.7 && < 0.7
|
, containers >= 0.5.7 && < 0.7
|
||||||
, hasql-pool >= 0.5 && < 0.6
|
, hasql-pool >= 0.8.0.2 && < 0.9
|
||||||
, hasql-transaction >= 1.0.1 && < 1.1
|
, hasql-transaction >= 1.0.1 && < 1.1
|
||||||
, heredoc >= 0.2 && < 0.3
|
, heredoc >= 0.2 && < 0.3
|
||||||
, hspec >= 2.3 && < 2.9
|
, hspec >= 2.3 && < 2.9
|
||||||
, hspec-wai >= 0.10 && < 0.12
|
, hspec-wai >= 0.10 && < 0.12
|
||||||
, hspec-wai-json >= 0.10 && < 0.12
|
, hspec-wai-json >= 0.10 && < 0.12
|
||||||
, http-types >= 0.12.3 && < 0.13
|
, http-types >= 0.12.3 && < 0.13
|
||||||
, lens >= 4.14 && < 5.2
|
, lens >= 4.14 && < 5.3
|
||||||
, lens-aeson >= 1.0.1 && < 1.2
|
, lens-aeson >= 1.0.1 && < 1.2
|
||||||
, monad-control >= 1.0.1 && < 1.1
|
, monad-control >= 1.0.1 && < 1.1
|
||||||
, postgrest
|
, postgrest
|
||||||
@@ -261,22 +263,22 @@ test-suite querycost
|
|||||||
main-is: QueryCost.hs
|
main-is: QueryCost.hs
|
||||||
other-modules: SpecHelper
|
other-modules: SpecHelper
|
||||||
build-depends: base >= 4.9 && < 4.17
|
build-depends: base >= 4.9 && < 4.17
|
||||||
, aeson >= 2.0.3 && < 2.1
|
, aeson >= 2.0.3 && < 2.2
|
||||||
, base64-bytestring >= 1 && < 1.3
|
, base64-bytestring >= 1 && < 1.3
|
||||||
, bytestring >= 0.10.8 && < 0.12
|
, bytestring >= 0.10.8 && < 0.12
|
||||||
, case-insensitive >= 1.2 && < 1.3
|
, case-insensitive >= 1.2 && < 1.3
|
||||||
, containers >= 0.5.7 && < 0.7
|
, containers >= 0.5.7 && < 0.7
|
||||||
, contravariant >= 1.4 && < 1.6
|
, contravariant >= 1.4 && < 1.6
|
||||||
, hasql >= 1.4 && < 1.6
|
, hasql >= 1.6 && < 1.7
|
||||||
, hasql-dynamic-statements >= 0.3.1 && < 0.4
|
, hasql-dynamic-statements >= 0.3.1 && < 0.4
|
||||||
, hasql-pool >= 0.5 && < 0.6
|
, hasql-pool >= 0.8.0.2 && < 0.9
|
||||||
, hasql-transaction >= 1.0.1 && < 1.1
|
, hasql-transaction >= 1.0.1 && < 1.1
|
||||||
, heredoc >= 0.2 && < 0.3
|
, heredoc >= 0.2 && < 0.3
|
||||||
, hspec >= 2.3 && < 2.9
|
, hspec >= 2.3 && < 2.9
|
||||||
, hspec-wai >= 0.10 && < 0.12
|
, hspec-wai >= 0.10 && < 0.12
|
||||||
, hspec-wai-json >= 0.10 && < 0.12
|
, hspec-wai-json >= 0.10 && < 0.12
|
||||||
, http-types >= 0.12.3 && < 0.13
|
, http-types >= 0.12.3 && < 0.13
|
||||||
, lens >= 4.14 && < 5.2
|
, lens >= 4.14 && < 5.3
|
||||||
, lens-aeson >= 1.0.1 && < 1.2
|
, lens-aeson >= 1.0.1 && < 1.2
|
||||||
, postgrest
|
, postgrest
|
||||||
, process >= 1.4.2 && < 1.7
|
, process >= 1.4.2 && < 1.7
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ lib.overrideDerivation postgrest.env (
|
|||||||
pkgs.cabal2nix
|
pkgs.cabal2nix
|
||||||
pkgs.git
|
pkgs.git
|
||||||
pkgs.postgresql
|
pkgs.postgresql
|
||||||
|
pkgs.update-nix-fetchgit
|
||||||
postgrest.hsie.bin
|
postgrest.hsie.bin
|
||||||
]
|
]
|
||||||
++ toolboxes;
|
++ toolboxes;
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
{-# LANGUAGE RecordWildCards #-}
|
|
||||||
module PostgREST.Admin
|
|
||||||
( postgrestAdmin
|
|
||||||
) where
|
|
||||||
|
|
||||||
import qualified Data.Text as T
|
|
||||||
|
|
||||||
import Network.Socket
|
|
||||||
import Network.Socket.ByteString
|
|
||||||
|
|
||||||
import qualified Network.HTTP.Types.Status as HTTP
|
|
||||||
import qualified Network.Wai as Wai
|
|
||||||
|
|
||||||
import qualified Hasql.Session as SQL
|
|
||||||
|
|
||||||
import qualified PostgREST.AppState as AppState
|
|
||||||
import PostgREST.Config (AppConfig (..))
|
|
||||||
|
|
||||||
import Protolude
|
|
||||||
|
|
||||||
-- | PostgREST admin application
|
|
||||||
postgrestAdmin :: AppState.AppState -> AppConfig -> Wai.Application
|
|
||||||
postgrestAdmin appState appConfig req respond = do
|
|
||||||
isMainAppReachable <- any isRight <$> reachMainApp appConfig
|
|
||||||
isSchemaCacheLoaded <- isJust <$> AppState.getDbStructure appState
|
|
||||||
isConnectionUp <-
|
|
||||||
if configDbChannelEnabled appConfig
|
|
||||||
then AppState.getIsListenerOn appState
|
|
||||||
else isRight <$> AppState.usePool appState (SQL.sql "SELECT 1")
|
|
||||||
|
|
||||||
case Wai.pathInfo req of
|
|
||||||
["ready"] ->
|
|
||||||
respond $ Wai.responseLBS (if isMainAppReachable && isConnectionUp && isSchemaCacheLoaded then HTTP.status200 else HTTP.status503) [] mempty
|
|
||||||
["live"] ->
|
|
||||||
respond $ Wai.responseLBS (if isMainAppReachable then HTTP.status200 else HTTP.status503) [] mempty
|
|
||||||
_ ->
|
|
||||||
respond $ Wai.responseLBS HTTP.status404 [] mempty
|
|
||||||
|
|
||||||
-- Try to connect to the main app socket
|
|
||||||
-- Note that it doesn't even send a valid HTTP request, we just want to check that the main app is accepting connections
|
|
||||||
-- The code for resolving the "*4", "!4", "*6", "!6", "*" special values is taken from
|
|
||||||
-- https://hackage.haskell.org/package/streaming-commons-0.2.2.4/docs/src/Data.Streaming.Network.html#bindPortGenEx
|
|
||||||
reachMainApp :: AppConfig -> IO [Either IOException ()]
|
|
||||||
reachMainApp AppConfig{..} =
|
|
||||||
case configServerUnixSocket of
|
|
||||||
Just path -> do
|
|
||||||
sock <- socket AF_UNIX Stream 0
|
|
||||||
(:[]) <$> try (do
|
|
||||||
connect sock $ SockAddrUnix path
|
|
||||||
withSocketsDo $ bracket (pure sock) close sendEmpty)
|
|
||||||
Nothing -> do
|
|
||||||
let
|
|
||||||
host | configServerHost `elem` ["*4", "!4", "*6", "!6", "*"] = Nothing
|
|
||||||
| otherwise = Just configServerHost
|
|
||||||
filterAddrs xs =
|
|
||||||
case configServerHost of
|
|
||||||
"*4" -> ipv4Addrs xs ++ ipv6Addrs xs
|
|
||||||
"!4" -> ipv4Addrs xs
|
|
||||||
"*6" -> ipv6Addrs xs ++ ipv4Addrs xs
|
|
||||||
"!6" -> ipv6Addrs xs
|
|
||||||
_ -> xs
|
|
||||||
ipv4Addrs = filter ((/=) AF_INET6 . addrFamily)
|
|
||||||
ipv6Addrs = filter ((==) AF_INET6 . addrFamily)
|
|
||||||
|
|
||||||
addrs <- getAddrInfo (Just $ defaultHints { addrSocketType = Stream }) (T.unpack <$> host) (Just . show $ configServerPort)
|
|
||||||
tryAddr `traverse` filterAddrs addrs
|
|
||||||
where
|
|
||||||
sendEmpty sock = void $ send sock mempty
|
|
||||||
tryAddr :: AddrInfo -> IO (Either IOException ())
|
|
||||||
tryAddr addr = do
|
|
||||||
sock <- socket (addrFamily addr) (addrSocketType addr) (addrProtocol addr)
|
|
||||||
try $ do
|
|
||||||
connect sock $ addrAddress addr
|
|
||||||
withSocketsDo $ bracket (pure sock) close sendEmpty
|
|
||||||
@@ -6,7 +6,7 @@ Description : PostgREST functions to translate HTTP request to a domain type cal
|
|||||||
{-# LANGUAGE NamedFieldPuns #-}
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
|
||||||
module PostgREST.Request.ApiRequest
|
module PostgREST.ApiRequest
|
||||||
( ApiRequest(..)
|
( ApiRequest(..)
|
||||||
, InvokeMethod(..)
|
, InvokeMethod(..)
|
||||||
, Mutation(..)
|
, Mutation(..)
|
||||||
@@ -35,40 +35,44 @@ import qualified Data.Vector as V
|
|||||||
import Control.Arrow ((***))
|
import Control.Arrow ((***))
|
||||||
import Data.Aeson.Types (emptyArray, emptyObject)
|
import Data.Aeson.Types (emptyArray, emptyObject)
|
||||||
import Data.List (lookup, union)
|
import Data.List (lookup, union)
|
||||||
import Data.Maybe (fromJust)
|
import Data.Ranged.Ranges (emptyRange, rangeIntersection,
|
||||||
import Data.Ranged.Ranges (emptyRange, rangeIntersection)
|
rangeIsEmpty)
|
||||||
import Network.HTTP.Types.Header (hCookie)
|
import Data.Tree (Tree (..))
|
||||||
|
import Network.HTTP.Types.Header (RequestHeaders, hCookie)
|
||||||
import Network.HTTP.Types.URI (parseSimpleQuery)
|
import Network.HTTP.Types.URI (parseSimpleQuery)
|
||||||
import Network.Wai (Request (..))
|
import Network.Wai (Request (..))
|
||||||
import Network.Wai.Parse (parseHttpAccept)
|
import Network.Wai.Parse (parseHttpAccept)
|
||||||
import Web.Cookie (parseCookies)
|
import Web.Cookie (parseCookies)
|
||||||
|
|
||||||
import PostgREST.Config (AppConfig (..),
|
import PostgREST.ApiRequest.Preferences (PreferCount (..),
|
||||||
OpenAPIMode (..))
|
|
||||||
import PostgREST.DbStructure (DbStructure (..))
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
|
||||||
QualifiedIdentifier (..),
|
|
||||||
Schema)
|
|
||||||
import PostgREST.DbStructure.Proc (ProcDescription (..),
|
|
||||||
ProcParam (..), ProcsMap)
|
|
||||||
import PostgREST.MediaType (MTPlanAttrs (..),
|
|
||||||
MTPlanFormat (..),
|
|
||||||
MediaType (..))
|
|
||||||
import PostgREST.RangeQuery (NonnegRange, allRange,
|
|
||||||
hasLimitZero,
|
|
||||||
limitZeroRange,
|
|
||||||
rangeRequested)
|
|
||||||
import PostgREST.Request.Preferences (PreferCount (..),
|
|
||||||
PreferParameters (..),
|
PreferParameters (..),
|
||||||
PreferRepresentation (..),
|
PreferRepresentation (..),
|
||||||
PreferResolution (..),
|
PreferResolution (..),
|
||||||
PreferTransaction (..))
|
PreferTransaction (..))
|
||||||
import PostgREST.Request.QueryParams (QueryParams (..))
|
import PostgREST.ApiRequest.QueryParams (QueryParams (..))
|
||||||
import PostgREST.Request.Types (ApiRequestError (..))
|
import PostgREST.ApiRequest.Types (ApiRequestError (..),
|
||||||
|
RangeError (..),
|
||||||
|
SelectItem (..))
|
||||||
|
import PostgREST.Config (AppConfig (..),
|
||||||
|
OpenAPIMode (..))
|
||||||
|
import PostgREST.MediaType (MTPlanAttrs (..),
|
||||||
|
MTPlanFormat (..),
|
||||||
|
MediaType (..))
|
||||||
|
import PostgREST.RangeQuery (NonnegRange, allRange,
|
||||||
|
convertToLimitZeroRange,
|
||||||
|
hasLimitZero,
|
||||||
|
rangeRequested)
|
||||||
|
import PostgREST.SchemaCache (SchemaCache (..))
|
||||||
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
|
QualifiedIdentifier (..),
|
||||||
|
Schema)
|
||||||
|
import PostgREST.SchemaCache.Proc (ProcDescription (..),
|
||||||
|
ProcParam (..), ProcsMap,
|
||||||
|
procReturnsScalar)
|
||||||
|
|
||||||
import qualified PostgREST.MediaType as MediaType
|
import qualified PostgREST.ApiRequest.Preferences as Preferences
|
||||||
import qualified PostgREST.Request.Preferences as Preferences
|
import qualified PostgREST.ApiRequest.QueryParams as QueryParams
|
||||||
import qualified PostgREST.Request.QueryParams as QueryParams
|
import qualified PostgREST.MediaType as MediaType
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
@@ -169,18 +173,21 @@ data ApiRequest = ApiRequest {
|
|||||||
, iCookies :: [(ByteString, ByteString)] -- ^ Request Cookies
|
, iCookies :: [(ByteString, ByteString)] -- ^ Request Cookies
|
||||||
, iPath :: ByteString -- ^ Raw request path
|
, iPath :: ByteString -- ^ Raw request path
|
||||||
, iMethod :: ByteString -- ^ Raw request method
|
, iMethod :: ByteString -- ^ Raw request method
|
||||||
, iProfile :: Maybe Schema -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/.
|
, iSchema :: Schema -- ^ The request schema. Can vary depending on profile headers.
|
||||||
, iSchema :: Schema -- ^ The request schema. Can vary depending on iProfile.
|
, iNegotiatedByProfile :: Bool -- ^ If schema was was chosen according to the profile spec https://www.w3.org/TR/dx-prof-conneg/
|
||||||
, iAcceptMediaType :: MediaType
|
, iAcceptMediaType :: MediaType -- ^ The media type in the Accept header
|
||||||
|
, iBinaryField :: Maybe FieldName -- ^ field used for raw output
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Examines HTTP request and translates it into user intent.
|
-- | Examines HTTP request and translates it into user intent.
|
||||||
userApiRequest :: AppConfig -> DbStructure -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
userApiRequest :: AppConfig -> SchemaCache -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||||
userApiRequest conf dbStructure req reqBody = do
|
userApiRequest conf sCache req reqBody = do
|
||||||
qPrms <- first QueryParamError $ QueryParams.parse $ rawQueryString req
|
qPrms <- first QueryParamError $ QueryParams.parse $ rawQueryString req
|
||||||
pInfo <- getPathInfo conf $ pathInfo req
|
pInfo <- getPathInfo conf $ pathInfo req
|
||||||
act <- getAction pInfo $ requestMethod req
|
act <- getAction pInfo $ requestMethod req
|
||||||
apiRequest conf dbStructure req reqBody qPrms pInfo act
|
mediaTypes <- getMediaTypes conf (requestHeaders req) act pInfo
|
||||||
|
negotiatedSchema <- getSchema conf (requestHeaders req) (requestMethod req)
|
||||||
|
apiRequest conf sCache req reqBody qPrms pInfo act mediaTypes negotiatedSchema
|
||||||
|
|
||||||
getPathInfo :: AppConfig -> [Text] -> Either ApiRequestError PathInfo
|
getPathInfo :: AppConfig -> [Text] -> Either ApiRequestError PathInfo
|
||||||
getPathInfo AppConfig{configOpenApiMode, configDbRootSpec} path =
|
getPathInfo AppConfig{configOpenApiMode, configDbRootSpec} path =
|
||||||
@@ -214,17 +221,44 @@ getAction PathInfo{pathIsProc, pathIsDefSpec} method =
|
|||||||
"OPTIONS" -> Right ActionInfo
|
"OPTIONS" -> Right ActionInfo
|
||||||
_ -> Left $ UnsupportedMethod method
|
_ -> Left $ UnsupportedMethod method
|
||||||
|
|
||||||
apiRequest :: AppConfig -> DbStructure -> Request -> RequestBody -> QueryParams.QueryParams -> PathInfo -> Action -> Either ApiRequestError ApiRequest
|
getMediaTypes :: AppConfig -> RequestHeaders -> Action -> PathInfo -> Either ApiRequestError (MediaType, MediaType)
|
||||||
apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..} path@PathInfo{pathName, pathIsProc, pathIsRootSpec, pathIsDefSpec} action
|
getMediaTypes conf hdrs action path = do
|
||||||
| isJust profile && fromJust profile `notElem` configDbSchemas = Left $ UnacceptableSchema $ toList configDbSchemas
|
acceptMediaType <- findAcceptMediaType conf action path accepts
|
||||||
| isInvalidRange = Left InvalidRange
|
pure (acceptMediaType, contentMediaType)
|
||||||
|
where
|
||||||
|
accepts = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"
|
||||||
|
contentMediaType = maybe MTApplicationJSON MediaType.decodeMediaType $ lookupHeader "content-type"
|
||||||
|
lookupHeader = flip lookup hdrs
|
||||||
|
|
||||||
|
getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool)
|
||||||
|
getSchema AppConfig{configDbSchemas} hdrs method = do
|
||||||
|
case profile of
|
||||||
|
Just p | p `notElem` configDbSchemas -> Left $ UnacceptableSchema $ toList configDbSchemas
|
||||||
|
| otherwise -> Right (p, True)
|
||||||
|
Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated
|
||||||
|
where
|
||||||
|
defaultSchema = NonEmptyList.head configDbSchemas
|
||||||
|
profile = case method of
|
||||||
|
-- POST/PATCH/PUT/DELETE don't use the same header as per the spec
|
||||||
|
"DELETE" -> contentProfile
|
||||||
|
"PATCH" -> contentProfile
|
||||||
|
"POST" -> contentProfile
|
||||||
|
"PUT" -> contentProfile
|
||||||
|
_ -> acceptProfile
|
||||||
|
contentProfile = T.decodeUtf8 <$> lookupHeader "Content-Profile"
|
||||||
|
acceptProfile = T.decodeUtf8 <$> lookupHeader "Accept-Profile"
|
||||||
|
lookupHeader = flip lookup hdrs
|
||||||
|
|
||||||
|
apiRequest :: AppConfig -> SchemaCache -> Request -> RequestBody -> QueryParams.QueryParams -> PathInfo -> Action -> (MediaType, MediaType) -> (Schema, Bool) -> Either ApiRequestError ApiRequest
|
||||||
|
apiRequest conf sCache req reqBody queryparams@QueryParams{..} PathInfo{pathName, pathIsProc, pathIsRootSpec, pathIsDefSpec} action (acceptMediaType, contentMediaType) (schema, negotiatedByProfile)
|
||||||
|
| isInvalidRange = Left $ InvalidRange (if rangeIsEmpty headerRange then LowerGTUpper else NegativeLimit)
|
||||||
| shouldParsePayload && isLeft payload = either (Left . InvalidBody) witness payload
|
| shouldParsePayload && isLeft payload = either (Left . InvalidBody) witness payload
|
||||||
| not expectParams && not (L.null qsParams) = Left $ ParseRequestError "Unexpected param or filter missing operator" ("Failed to parse " <> show qsParams)
|
| not expectParams && not (L.null qsParams) = Left $ ParseRequestError "Unexpected param or filter missing operator" ("Failed to parse " <> show qsParams)
|
||||||
| method `elem` ["PATCH", "DELETE"] && not (null qsRanges) && null qsOrder = Left LimitNoOrderError
|
| method `elem` ["PATCH", "DELETE"] && not (null qsRanges) && null qsOrder = Left LimitNoOrderError
|
||||||
| method == "PUT" && topLevelRange /= allRange = Left PutRangeNotAllowedError
|
| method == "PUT" && topLevelRange /= allRange = Left PutRangeNotAllowedError
|
||||||
| otherwise = do
|
| otherwise = do
|
||||||
acceptMediaType <- findAcceptMediaType conf action path accepts
|
|
||||||
checkedTarget <- target
|
checkedTarget <- target
|
||||||
|
bField <- binaryField conf acceptMediaType checkedTarget queryparams
|
||||||
return ApiRequest {
|
return ApiRequest {
|
||||||
iAction = action
|
iAction = action
|
||||||
, iTarget = checkedTarget
|
, iTarget = checkedTarget
|
||||||
@@ -242,17 +276,14 @@ apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..
|
|||||||
, iCookies = maybe [] parseCookies $ lookupHeader "Cookie"
|
, iCookies = maybe [] parseCookies $ lookupHeader "Cookie"
|
||||||
, iPath = rawPathInfo req
|
, iPath = rawPathInfo req
|
||||||
, iMethod = method
|
, iMethod = method
|
||||||
, iProfile = profile
|
|
||||||
, iSchema = schema
|
, iSchema = schema
|
||||||
|
, iNegotiatedByProfile = negotiatedByProfile
|
||||||
, iAcceptMediaType = acceptMediaType
|
, iAcceptMediaType = acceptMediaType
|
||||||
|
, iBinaryField = bField
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
accepts = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"
|
|
||||||
|
|
||||||
expectParams = pathIsProc && method /= "POST"
|
expectParams = pathIsProc && method /= "POST"
|
||||||
|
|
||||||
contentMediaType = maybe MTApplicationJSON MediaType.decodeMediaType $ lookupHeader "content-type"
|
|
||||||
|
|
||||||
columns = case action of
|
columns = case action of
|
||||||
ActionMutate MutationCreate -> qsColumns
|
ActionMutate MutationCreate -> qsColumns
|
||||||
ActionMutate MutationUpdate -> qsColumns
|
ActionMutate MutationUpdate -> qsColumns
|
||||||
@@ -289,30 +320,13 @@ apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..
|
|||||||
(ct, _) -> Left $ "Content-Type not acceptable: " <> MediaType.toMime ct
|
(ct, _) -> Left $ "Content-Type not acceptable: " <> MediaType.toMime ct
|
||||||
topLevelRange = fromMaybe allRange $ HM.lookup "limit" ranges -- if no limit is specified, get all the request rows
|
topLevelRange = fromMaybe allRange $ HM.lookup "limit" ranges -- if no limit is specified, get all the request rows
|
||||||
|
|
||||||
defaultSchema = NonEmptyList.head configDbSchemas
|
|
||||||
profile
|
|
||||||
| length configDbSchemas <= 1 -- only enable content negotiation by profile when there are multiple schemas specified in the config
|
|
||||||
= Nothing
|
|
||||||
| otherwise = case method of
|
|
||||||
-- POST/PATCH/PUT/DELETE don't use the same header as per the spec
|
|
||||||
"DELETE" -> contentProfile
|
|
||||||
"PATCH" -> contentProfile
|
|
||||||
"POST" -> contentProfile
|
|
||||||
"PUT" -> contentProfile
|
|
||||||
_ -> acceptProfile
|
|
||||||
where
|
|
||||||
contentProfile = Just $ maybe defaultSchema T.decodeUtf8 $ lookupHeader "Content-Profile"
|
|
||||||
acceptProfile = Just $ maybe defaultSchema T.decodeUtf8 $ lookupHeader "Accept-Profile"
|
|
||||||
|
|
||||||
schema = fromMaybe defaultSchema profile
|
|
||||||
|
|
||||||
target
|
target
|
||||||
| pathIsProc = (`TargetProc` pathIsRootSpec) <$> callFindProc schema pathName
|
| pathIsProc = (`TargetProc` pathIsRootSpec) <$> callFindProc schema pathName
|
||||||
| pathIsDefSpec = Right $ TargetDefaultSpec schema
|
| pathIsDefSpec = Right $ TargetDefaultSpec schema
|
||||||
| otherwise = Right $ TargetIdent $ QualifiedIdentifier schema pathName
|
| otherwise = Right $ TargetIdent $ QualifiedIdentifier schema pathName
|
||||||
where
|
where
|
||||||
callFindProc procSch procNam = findProc
|
callFindProc procSch procNam = findProc
|
||||||
(QualifiedIdentifier procSch procNam) payloadColumns (preferParameters == Just SingleObject) (dbProcs dbStructure)
|
(QualifiedIdentifier procSch procNam) payloadColumns (preferParameters == Just SingleObject) (dbProcs sCache)
|
||||||
contentMediaType (action == ActionInvoke InvPost)
|
contentMediaType (action == ActionInvoke InvPost)
|
||||||
|
|
||||||
shouldParsePayload = case (action, contentMediaType) of
|
shouldParsePayload = case (action, contentMediaType) of
|
||||||
@@ -340,7 +354,7 @@ apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..
|
|||||||
|
|
||||||
-- Bypass all the ranges and send only the limit zero range (0 <= x <= -1) if
|
-- Bypass all the ranges and send only the limit zero range (0 <= x <= -1) if
|
||||||
-- limit=0 is present in the query params (not allowed for the Range header)
|
-- limit=0 is present in the query params (not allowed for the Range header)
|
||||||
ranges = HM.insert "limit" (if hasLimitZero limitRange then limitZeroRange else headerAndLimitRange) qsRanges
|
ranges = HM.insert "limit" (convertToLimitZeroRange limitRange headerAndLimitRange) qsRanges
|
||||||
-- The only emptyRange allowed is the limit zero range
|
-- The only emptyRange allowed is the limit zero range
|
||||||
isInvalidRange = topLevelRange == emptyRange && not (hasLimitZero limitRange)
|
isInvalidRange = topLevelRange == emptyRange && not (hasLimitZero limitRange)
|
||||||
|
|
||||||
@@ -439,7 +453,7 @@ requestMediaTypes conf action path =
|
|||||||
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> MediaType -> Bool -> Either ApiRequestError ProcDescription
|
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> MediaType -> Bool -> Either ApiRequestError ProcDescription
|
||||||
findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPost =
|
findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPost =
|
||||||
case matchProc of
|
case matchProc of
|
||||||
([], []) -> Left $ NoRpc (qiSchema qi) (qiName qi) (S.toList argumentsKeys) paramsAsSingleObject contentMediaType isInvPost
|
([], []) -> Left $ NoRpc (qiSchema qi) (qiName qi) (S.toList argumentsKeys) paramsAsSingleObject contentMediaType isInvPost (HM.keys allProcs) lookupProcName
|
||||||
-- If there are no functions with named arguments, fallback to the single unnamed argument function
|
-- If there are no functions with named arguments, fallback to the single unnamed argument function
|
||||||
([], [proc]) -> Right proc
|
([], [proc]) -> Right proc
|
||||||
([], procs) -> Left $ AmbiguousRpc (toList procs)
|
([], procs) -> Left $ AmbiguousRpc (toList procs)
|
||||||
@@ -447,7 +461,9 @@ findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPo
|
|||||||
([proc], _) -> Right proc
|
([proc], _) -> Right proc
|
||||||
(procs, _) -> Left $ AmbiguousRpc (toList procs)
|
(procs, _) -> Left $ AmbiguousRpc (toList procs)
|
||||||
where
|
where
|
||||||
matchProc = overloadedProcPartition $ HM.lookupDefault mempty qi allProcs -- first find the proc by name
|
matchProc = overloadedProcPartition lookupProcName
|
||||||
|
-- First find the proc by name
|
||||||
|
lookupProcName = HM.lookupDefault mempty qi allProcs
|
||||||
-- The partition obtained has the form (overloadedProcs,fallbackProcs)
|
-- The partition obtained has the form (overloadedProcs,fallbackProcs)
|
||||||
-- where fallbackProcs are functions with a single unnamed parameter
|
-- where fallbackProcs are functions with a single unnamed parameter
|
||||||
overloadedProcPartition = foldr select ([],[])
|
overloadedProcPartition = foldr select ([],[])
|
||||||
@@ -486,3 +502,35 @@ findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPo
|
|||||||
-- If the function has required and optional parameters, the arguments keys have to match the required parameters
|
-- If the function has required and optional parameters, the arguments keys have to match the required parameters
|
||||||
-- and can match any or none of the default parameters.
|
-- and can match any or none of the default parameters.
|
||||||
(reqParams, optParams) -> argumentsKeys `S.difference` S.fromList (ppName <$> optParams) == S.fromList (ppName <$> reqParams)
|
(reqParams, optParams) -> argumentsKeys `S.difference` S.fromList (ppName <$> optParams) == S.fromList (ppName <$> reqParams)
|
||||||
|
|
||||||
|
-- | If raw(binary) output is requested, check that MediaType is one of the
|
||||||
|
-- admitted rawMediaTypes and that`?select=...` contains only one field other
|
||||||
|
-- than `*`
|
||||||
|
binaryField :: AppConfig -> MediaType -> Target -> QueryParams -> Either ApiRequestError (Maybe FieldName)
|
||||||
|
binaryField AppConfig{configRawMediaTypes} acceptMediaType target QueryParams{qsSelect}
|
||||||
|
| returnsScalar target && isRawMediaType =
|
||||||
|
Right $ Just "pgrst_scalar"
|
||||||
|
| isRawMediaType =
|
||||||
|
let
|
||||||
|
fieldName = fstFieldName qsSelect
|
||||||
|
in
|
||||||
|
case fieldName of
|
||||||
|
Just fld -> Right $ Just fld
|
||||||
|
Nothing -> Left $ BinaryFieldError acceptMediaType
|
||||||
|
| otherwise =
|
||||||
|
Right Nothing
|
||||||
|
where
|
||||||
|
isRawMediaType = acceptMediaType `elem` configRawMediaTypes `union` [MTOctetStream, MTTextPlain, MTTextXML] || isRawPlan acceptMediaType
|
||||||
|
isRawPlan mt = case mt of
|
||||||
|
MTPlan (MTPlanAttrs (Just MTOctetStream) _ _) -> True
|
||||||
|
MTPlan (MTPlanAttrs (Just MTTextPlain) _ _) -> True
|
||||||
|
MTPlan (MTPlanAttrs (Just MTTextXML) _ _) -> True
|
||||||
|
_ -> False
|
||||||
|
returnsScalar :: Target -> Bool
|
||||||
|
returnsScalar (TargetProc proc _) = procReturnsScalar proc
|
||||||
|
returnsScalar _ = False
|
||||||
|
|
||||||
|
fstFieldName :: [Tree SelectItem] -> Maybe FieldName
|
||||||
|
fstFieldName [Node SelectField{selField=("*", _)} []] = Nothing
|
||||||
|
fstFieldName [Node SelectField{selField=(fld, _)} []] = Just fld
|
||||||
|
fstFieldName _ = Nothing
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
-- |
|
-- |
|
||||||
-- Module: PostgREST.Request.Preferences
|
-- Module: PostgREST.ApiRequest.Preferences
|
||||||
-- Description: Track client preferences to be employed when processing requests
|
-- Description: Track client preferences to be employed when processing requests
|
||||||
--
|
--
|
||||||
-- Track client prefences set in HTTP 'Prefer' headers according to RFC7240[1].
|
-- Track client prefences set in HTTP 'Prefer' headers according to RFC7240[1].
|
||||||
--
|
--
|
||||||
-- [1] https://datatracker.ietf.org/doc/html/rfc7240
|
-- [1] https://datatracker.ietf.org/doc/html/rfc7240
|
||||||
--
|
--
|
||||||
module PostgREST.Request.Preferences
|
module PostgREST.ApiRequest.Preferences
|
||||||
( Preferences(..)
|
( Preferences(..)
|
||||||
, PreferCount(..)
|
, PreferCount(..)
|
||||||
, PreferParameters(..)
|
, PreferParameters(..)
|
||||||
@@ -15,6 +15,7 @@ module PostgREST.Request.Preferences
|
|||||||
, PreferTransaction(..)
|
, PreferTransaction(..)
|
||||||
, fromHeaders
|
, fromHeaders
|
||||||
, ToAppliedHeader(..)
|
, ToAppliedHeader(..)
|
||||||
|
, shouldCount
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
@@ -171,6 +172,7 @@ data PreferParameters
|
|||||||
| MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure.
|
| MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure.
|
||||||
deriving Eq
|
deriving Eq
|
||||||
|
|
||||||
|
-- TODO: Deprecate params=multiple-objects in next major version
|
||||||
instance ToHeaderValue PreferParameters where
|
instance ToHeaderValue PreferParameters where
|
||||||
toHeaderValue SingleObject = "params=single-object"
|
toHeaderValue SingleObject = "params=single-object"
|
||||||
toHeaderValue MultipleObjects = "params=multiple-objects"
|
toHeaderValue MultipleObjects = "params=multiple-objects"
|
||||||
@@ -187,6 +189,10 @@ instance ToHeaderValue PreferCount where
|
|||||||
toHeaderValue PlannedCount = "count=planned"
|
toHeaderValue PlannedCount = "count=planned"
|
||||||
toHeaderValue EstimatedCount = "count=estimated"
|
toHeaderValue EstimatedCount = "count=estimated"
|
||||||
|
|
||||||
|
shouldCount :: Maybe PreferCount -> Bool
|
||||||
|
shouldCount prefCount =
|
||||||
|
prefCount == Just ExactCount || prefCount == Just EstimatedCount
|
||||||
|
|
||||||
-- | Whether to commit or roll back transactions.
|
-- | Whether to commit or roll back transactions.
|
||||||
data PreferTransaction
|
data PreferTransaction
|
||||||
= Commit -- ^ Commit transaction - the default.
|
= Commit -- ^ Commit transaction - the default.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
-- |
|
-- |
|
||||||
-- Module : PostgREST.Request.QueryParams
|
-- Module : PostgREST.ApiRequest.QueryParams
|
||||||
-- Description : Parser for PostgREST Query paramters
|
-- Description : Parser for PostgREST Query parameters
|
||||||
--
|
--
|
||||||
-- This module is in charge of parsing all the querystring values in an url, e.g.
|
-- This module is in charge of parsing all the querystring values in an url, e.g.
|
||||||
-- the select, id, order in `/projects?select=id,name&id=eq.1&order=id,name.desc`.
|
-- the select, id, order in `/projects?select=id,name&id=eq.1&order=id,name.desc`.
|
||||||
{-# LANGUAGE LambdaCase #-}
|
{-# LANGUAGE LambdaCase #-}
|
||||||
{-# LANGUAGE TupleSections #-}
|
{-# LANGUAGE TupleSections #-}
|
||||||
module PostgREST.Request.QueryParams
|
module PostgREST.ApiRequest.QueryParams
|
||||||
( parse
|
( parse
|
||||||
, QueryParams(..)
|
, QueryParams(..)
|
||||||
, pRequestRange
|
, pRequestRange
|
||||||
@@ -39,23 +39,23 @@ import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser,
|
|||||||
optionMaybe, sepBy1, string,
|
optionMaybe, sepBy1, string,
|
||||||
try, (<?>))
|
try, (<?>))
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName)
|
|
||||||
import PostgREST.RangeQuery (NonnegRange, allRange,
|
import PostgREST.RangeQuery (NonnegRange, allRange,
|
||||||
rangeGeq, rangeLimit,
|
rangeGeq, rangeLimit,
|
||||||
rangeOffset, restrictRange)
|
rangeOffset, restrictRange)
|
||||||
|
import PostgREST.SchemaCache.Identifiers (FieldName)
|
||||||
|
|
||||||
import PostgREST.Request.ReadQuery (SelectItem)
|
import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field,
|
||||||
import PostgREST.Request.Types (EmbedParam (..), EmbedPath, Field,
|
Filter (..), FtsOperator (..),
|
||||||
Filter (..), FtsOperator (..),
|
JoinType (..), JsonOperand (..),
|
||||||
JoinType (..), JsonOperand (..),
|
JsonOperation (..), JsonPath,
|
||||||
JsonOperation (..), JsonPath,
|
ListVal, LogicOperator (..),
|
||||||
ListVal, LogicOperator (..),
|
LogicTree (..), OpExpr (..),
|
||||||
LogicTree (..), OpExpr (..),
|
Operation (..),
|
||||||
Operation (..),
|
OrderDirection (..),
|
||||||
OrderDirection (..),
|
OrderNulls (..), OrderTerm (..),
|
||||||
OrderNulls (..), OrderTerm (..),
|
QPError (..), SelectItem (..),
|
||||||
QPError (..), SimpleOperator (..),
|
SimpleOperator (..), SingleVal,
|
||||||
SingleVal, TrileanVal (..))
|
TrileanVal (..))
|
||||||
|
|
||||||
import Protolude hiding (try)
|
import Protolude hiding (try)
|
||||||
|
|
||||||
@@ -73,6 +73,7 @@ import Protolude hiding (try)
|
|||||||
-- >>> deriving instance Show JsonOperation
|
-- >>> deriving instance Show JsonOperation
|
||||||
-- >>> deriving instance Show Filter
|
-- >>> deriving instance Show Filter
|
||||||
-- >>> deriving instance Show JoinType
|
-- >>> deriving instance Show JoinType
|
||||||
|
-- >>> deriving instance Show SelectItem
|
||||||
|
|
||||||
data QueryParams =
|
data QueryParams =
|
||||||
QueryParams
|
QueryParams
|
||||||
@@ -105,7 +106,7 @@ data QueryParams =
|
|||||||
-- |
|
-- |
|
||||||
-- Parse query parameters from a query string like "id=eq.1&select=name".
|
-- Parse query parameters from a query string like "id=eq.1&select=name".
|
||||||
--
|
--
|
||||||
-- The canonical representation of the query string has paramters sorted alphabetically:
|
-- The canonical representation of the query string has parameters sorted alphabetically:
|
||||||
--
|
--
|
||||||
-- >>> qsCanonical <$> parse "a=1&c=3&b=2&d"
|
-- >>> qsCanonical <$> parse "a=1&c=3&b=2&d"
|
||||||
-- Right "a=1&b=2&c=3&d="
|
-- Right "a=1&b=2&c=3&d="
|
||||||
@@ -113,7 +114,7 @@ data QueryParams =
|
|||||||
-- 'select' is a reserved parameter that selects the fields to be returned:
|
-- 'select' is a reserved parameter that selects the fields to be returned:
|
||||||
--
|
--
|
||||||
-- >>> qsSelect <$> parse "select=name,location"
|
-- >>> qsSelect <$> parse "select=name,location"
|
||||||
-- Right [Node {rootLabel = (("name",[]),Nothing,Nothing,Nothing,Nothing), subForest = []},Node {rootLabel = (("location",[]),Nothing,Nothing,Nothing,Nothing), subForest = []}]
|
-- Right [Node {rootLabel = SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectField {selField = ("location",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]
|
||||||
--
|
--
|
||||||
-- Filters are parameters whose value contains an operator, separated by a '.' from its value:
|
-- Filters are parameters whose value contains an operator, separated by a '.' from its value:
|
||||||
--
|
--
|
||||||
@@ -301,6 +302,22 @@ pTreePath = do
|
|||||||
jp <- P.option [] pJsonPath
|
jp <- P.option [] pJsonPath
|
||||||
return (init p, (last p, jp))
|
return (init p, (last p, jp))
|
||||||
|
|
||||||
|
-- |
|
||||||
|
-- Parse select= into a Forest of SelectItems
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldForest "" "id"
|
||||||
|
-- Right [Node {rootLabel = SelectField {selField = ("id",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldForest "" "client(id)"
|
||||||
|
-- Right [Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("id",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}]
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldForest "" "*,client(*,nested(*))"
|
||||||
|
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "nested", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}]}]
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldForest "" "id,clients(name[])"
|
||||||
|
-- Left (line 1, column 16):
|
||||||
|
-- unexpected '['
|
||||||
|
-- expecting letter, digit, "-", "!", "(", "->>", "->", "::", ")", "," or end of input
|
||||||
pFieldForest :: Parser [Tree SelectItem]
|
pFieldForest :: Parser [Tree SelectItem]
|
||||||
pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
|
pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
|
||||||
where
|
where
|
||||||
@@ -311,11 +328,45 @@ pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
|
|||||||
pStar :: Parser Text
|
pStar :: Parser Text
|
||||||
pStar = string "*" $> "*"
|
pStar = string "*" $> "*"
|
||||||
|
|
||||||
|
-- |
|
||||||
|
-- Parse field names
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" "identifier"
|
||||||
|
-- Right "identifier"
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" "identifier with spaces"
|
||||||
|
-- Right "identifier with spaces"
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" "identifier-with-dashes"
|
||||||
|
-- Right "identifier-with-dashes"
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" "123"
|
||||||
|
-- Right "123"
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" "_"
|
||||||
|
-- Right "_"
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" "$"
|
||||||
|
-- Right "$"
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" ":"
|
||||||
|
-- Left (line 1, column 1):
|
||||||
|
-- unexpected ":"
|
||||||
|
-- expecting field name (* or [a..z0..9_$])
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" "\":\""
|
||||||
|
-- Right ":"
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" " no leading or trailing spaces "
|
||||||
|
-- Right "no leading or trailing spaces"
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldName "" "\" leading and trailing spaces \""
|
||||||
|
-- Right " leading and trailing spaces "
|
||||||
pFieldName :: Parser Text
|
pFieldName :: Parser Text
|
||||||
pFieldName =
|
pFieldName =
|
||||||
pQuotedValue <|>
|
pQuotedValue <|>
|
||||||
T.intercalate "-" . map toS <$> (many1 pIdentifierChar `sepBy1` dash) <?>
|
T.intercalate "-" . map toS <$> (pIdentifier `sepBy1` dash) <?>
|
||||||
"field name (* or [a..z0..9_])"
|
"field name (* or [a..z0..9_$])"
|
||||||
where
|
where
|
||||||
isDash :: GenParser Char st ()
|
isDash :: GenParser Char st ()
|
||||||
isDash = try ( char '-' >> notFollowedBy (char '>') )
|
isDash = try ( char '-' >> notFollowedBy (char '>') )
|
||||||
@@ -368,13 +419,42 @@ pField = lexeme $ (,) <$> pFieldName <*> P.option [] pJsonPath
|
|||||||
aliasSeparator :: Parser ()
|
aliasSeparator :: Parser ()
|
||||||
aliasSeparator = char ':' >> notFollowedBy (char ':')
|
aliasSeparator = char ':' >> notFollowedBy (char ':')
|
||||||
|
|
||||||
|
-- |
|
||||||
|
-- Parse regular fields in select
|
||||||
|
--
|
||||||
|
-- >>> P.parse pRelationSelect "" "rel(*)"
|
||||||
|
-- Right (SelectRelation {selRelation = "rel", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pRelationSelect "" "alias:rel(*)"
|
||||||
|
-- Right (SelectRelation {selRelation = "rel", selAlias = Just "alias", selHint = Nothing, selJoinType = Nothing})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pRelationSelect "" "rel!hint(*)"
|
||||||
|
-- Right (SelectRelation {selRelation = "rel", selAlias = Nothing, selHint = Just "hint", selJoinType = Nothing})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pRelationSelect "" "rel!inner(*)"
|
||||||
|
-- Right (SelectRelation {selRelation = "rel", selAlias = Nothing, selHint = Nothing, selJoinType = Just JTInner})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pRelationSelect "" "rel!hint!inner(*)"
|
||||||
|
-- Right (SelectRelation {selRelation = "rel", selAlias = Nothing, selHint = Just "hint", selJoinType = Just JTInner})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pRelationSelect "" "alias:rel!inner!hint(*)"
|
||||||
|
-- Right (SelectRelation {selRelation = "rel", selAlias = Just "alias", selHint = Just "hint", selJoinType = Just JTInner})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pRelationSelect "" "rel->jsonpath(*)"
|
||||||
|
-- Left (line 1, column 6):
|
||||||
|
-- unexpected '>'
|
||||||
|
--
|
||||||
|
-- >>> P.parse pRelationSelect "" "rel->jsonpath!hint(*)"
|
||||||
|
-- Left (line 1, column 6):
|
||||||
|
-- unexpected '>'
|
||||||
pRelationSelect :: Parser SelectItem
|
pRelationSelect :: Parser SelectItem
|
||||||
pRelationSelect = lexeme $ try ( do
|
pRelationSelect = lexeme $ try ( do
|
||||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||||
fld <- pField
|
name <- pFieldName
|
||||||
prm1 <- optionMaybe pEmbedParam
|
prm1 <- optionMaybe pEmbedParam
|
||||||
prm2 <- optionMaybe pEmbedParam
|
prm2 <- optionMaybe pEmbedParam
|
||||||
return (fld, Nothing, alias, embedParamHint prm1 <|> embedParamHint prm2, embedParamJoin prm1 <|> embedParamJoin prm2)
|
try (void $ lookAhead (string "("))
|
||||||
|
return $ SelectRelation name alias (embedParamHint prm1 <|> embedParamHint prm2) (embedParamJoin prm1 <|> embedParamJoin prm2)
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
pEmbedParam :: Parser EmbedParam
|
pEmbedParam :: Parser EmbedParam
|
||||||
@@ -390,19 +470,66 @@ pRelationSelect = lexeme $ try ( do
|
|||||||
Just (EPJoinType jt) -> Just jt
|
Just (EPJoinType jt) -> Just jt
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
|
|
||||||
|
-- |
|
||||||
|
-- Parse regular fields in select
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldSelect "" "name"
|
||||||
|
-- Right (SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Nothing})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldSelect "" "name->jsonpath"
|
||||||
|
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selCast = Nothing, selAlias = Nothing})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldSelect "" "name::cast"
|
||||||
|
-- Right (SelectField {selField = ("name",[]), selCast = Just "cast", selAlias = Nothing})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldSelect "" "alias:name"
|
||||||
|
-- Right (SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Just "alias"})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldSelect "" "alias:name->jsonpath::cast"
|
||||||
|
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selCast = Just "cast", selAlias = Just "alias"})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldSelect "" "*"
|
||||||
|
-- Right (SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing})
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldSelect "" "name!hint"
|
||||||
|
-- Left (line 1, column 5):
|
||||||
|
-- unexpected '!'
|
||||||
|
-- expecting letter, digit, "-", "->>", "->", "::", ")", "," or end of input
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldSelect "" "*!hint"
|
||||||
|
-- Left (line 1, column 2):
|
||||||
|
-- unexpected '!'
|
||||||
|
-- expecting ")", "," or end of input
|
||||||
|
--
|
||||||
|
-- >>> P.parse pFieldSelect "" "name::"
|
||||||
|
-- Left (line 1, column 7):
|
||||||
|
-- unexpected end of input
|
||||||
|
-- expecting letter or digit
|
||||||
pFieldSelect :: Parser SelectItem
|
pFieldSelect :: Parser SelectItem
|
||||||
pFieldSelect = lexeme $
|
pFieldSelect = lexeme $
|
||||||
try (
|
try (
|
||||||
do
|
do
|
||||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||||
fld <- pField
|
fld <- pField
|
||||||
cast' <- optionMaybe (string "::" *> many pIdentifierChar)
|
cast' <- optionMaybe (string "::" *> pIdentifier)
|
||||||
return (fld, toS <$> cast', alias, Nothing, Nothing)
|
pEnd
|
||||||
|
return $ SelectField fld (toS <$> cast') alias
|
||||||
)
|
)
|
||||||
<|> do
|
<|> do
|
||||||
s <- pStar
|
s <- pStar
|
||||||
return ((s, []), Nothing, Nothing, Nothing, Nothing)
|
pEnd
|
||||||
|
return $ SelectField (s, []) Nothing Nothing
|
||||||
|
where
|
||||||
|
pEnd = try (void $ lookAhead (string ")")) <|>
|
||||||
|
try (void $ lookAhead (string ",")) <|>
|
||||||
|
try eof
|
||||||
|
|
||||||
|
-- |
|
||||||
|
-- Parse operator expression used in horizontal filtering
|
||||||
|
--
|
||||||
|
-- >>> P.parse (pOpExpr pSingleVal) "" "fts().value"
|
||||||
|
-- Left (line 1, column 7):
|
||||||
|
-- unknown single value operator fts()
|
||||||
pOpExpr :: Parser SingleVal -> Parser OpExpr
|
pOpExpr :: Parser SingleVal -> Parser OpExpr
|
||||||
pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
|
pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
|
||||||
where
|
where
|
||||||
@@ -426,7 +553,7 @@ pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)
|
|||||||
pFts = do
|
pFts = do
|
||||||
opStr <- try (P.many (noneOf ".("))
|
opStr <- try (P.many (noneOf ".("))
|
||||||
op <- parseMaybe ("unknown fts operator " <> opStr) . ftsOperator $ toS opStr
|
op <- parseMaybe ("unknown fts operator " <> opStr) . ftsOperator $ toS opStr
|
||||||
lang <- optionMaybe $ try (between (char '(') (char ')') $ many pIdentifierChar)
|
lang <- optionMaybe $ try (between (char '(') (char ')') pIdentifier)
|
||||||
pDelimiter >> Fts op (toS <$> lang) <$> pSVal
|
pDelimiter >> Fts op (toS <$> lang) <$> pSVal
|
||||||
|
|
||||||
parseMaybe :: [Char] -> Maybe a -> Parser a
|
parseMaybe :: [Char] -> Maybe a -> Parser a
|
||||||
@@ -509,6 +636,9 @@ pLogicPath = do
|
|||||||
pColumns :: Parser [FieldName]
|
pColumns :: Parser [FieldName]
|
||||||
pColumns = pFieldName `sepBy1` lexeme (char ',')
|
pColumns = pFieldName `sepBy1` lexeme (char ',')
|
||||||
|
|
||||||
|
pIdentifier :: Parser Text
|
||||||
|
pIdentifier = T.strip . toS <$> many1 pIdentifierChar
|
||||||
|
|
||||||
pIdentifierChar :: Parser Char
|
pIdentifierChar :: Parser Char
|
||||||
pIdentifierChar = letter <|> digit <|> oneOf "_ $"
|
pIdentifierChar = letter <|> digit <|> oneOf "_ $"
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
module PostgREST.Request.Types
|
module PostgREST.ApiRequest.Types
|
||||||
( Alias
|
( Alias
|
||||||
, Cast
|
, Cast
|
||||||
, Depth
|
, Depth
|
||||||
@@ -9,10 +9,6 @@ module PostgREST.Request.Types
|
|||||||
, Field
|
, Field
|
||||||
, Filter(..)
|
, Filter(..)
|
||||||
, Hint
|
, Hint
|
||||||
, CallQuery(..)
|
|
||||||
, CallParams(..)
|
|
||||||
, CallRequest
|
|
||||||
, JoinCondition(..)
|
|
||||||
, JoinType(..)
|
, JoinType(..)
|
||||||
, JsonOperand(..)
|
, JsonOperand(..)
|
||||||
, JsonOperation(..)
|
, JsonOperation(..)
|
||||||
@@ -27,37 +23,51 @@ module PostgREST.Request.Types
|
|||||||
, OrderNulls(..)
|
, OrderNulls(..)
|
||||||
, OrderTerm(..)
|
, OrderTerm(..)
|
||||||
, QPError(..)
|
, QPError(..)
|
||||||
|
, RangeError(..)
|
||||||
, SingleVal
|
, SingleVal
|
||||||
, TrileanVal(..)
|
, TrileanVal(..)
|
||||||
, SimpleOperator(..)
|
, SimpleOperator(..)
|
||||||
, FtsOperator(..)
|
, FtsOperator(..)
|
||||||
|
, SelectItem(..)
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
|
||||||
QualifiedIdentifier)
|
|
||||||
import PostgREST.DbStructure.Proc (ProcDescription (..),
|
|
||||||
ProcParam (..))
|
|
||||||
import PostgREST.DbStructure.Relationship (Relationship)
|
|
||||||
import PostgREST.MediaType (MediaType (..))
|
import PostgREST.MediaType (MediaType (..))
|
||||||
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
|
QualifiedIdentifier)
|
||||||
|
import PostgREST.SchemaCache.Proc (ProcDescription (..))
|
||||||
|
import PostgREST.SchemaCache.Relationship (Relationship,
|
||||||
|
RelationshipsMap)
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
|
-- | The select value in `/tbl?select=alias:field::cast`
|
||||||
|
data SelectItem
|
||||||
|
= SelectField
|
||||||
|
{ selField :: Field
|
||||||
|
, selCast :: Maybe Cast
|
||||||
|
, selAlias :: Maybe Alias
|
||||||
|
}
|
||||||
|
| SelectRelation
|
||||||
|
{ selRelation :: FieldName
|
||||||
|
, selAlias :: Maybe Alias
|
||||||
|
, selHint :: Maybe Hint
|
||||||
|
, selJoinType :: Maybe JoinType
|
||||||
|
}
|
||||||
|
deriving (Eq)
|
||||||
|
|
||||||
data ApiRequestError
|
data ApiRequestError
|
||||||
= AmbiguousRelBetween Text Text [Relationship]
|
= AmbiguousRelBetween Text Text [Relationship]
|
||||||
| AmbiguousRpc [ProcDescription]
|
| AmbiguousRpc [ProcDescription]
|
||||||
|
| BinaryFieldError MediaType
|
||||||
| MediaTypeError [ByteString]
|
| MediaTypeError [ByteString]
|
||||||
| InvalidBody ByteString
|
| InvalidBody ByteString
|
||||||
| InvalidFilters
|
| InvalidFilters
|
||||||
| InvalidRange
|
| InvalidRange RangeError
|
||||||
| InvalidRpcMethod ByteString
|
| InvalidRpcMethod ByteString
|
||||||
| LimitNoOrderError
|
| LimitNoOrderError
|
||||||
| NotFound
|
| NotFound
|
||||||
| NoRelBetween Text Text Text
|
| NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
|
||||||
| NoRpc Text Text [Text] Bool MediaType Bool
|
| NoRpc Text Text [Text] Bool MediaType Bool [QualifiedIdentifier] [ProcDescription]
|
||||||
| NotEmbedded Text
|
| NotEmbedded Text
|
||||||
| ParseRequestError Text Text
|
| ParseRequestError Text Text
|
||||||
| PutRangeNotAllowedError
|
| PutRangeNotAllowedError
|
||||||
@@ -66,18 +76,14 @@ data ApiRequestError
|
|||||||
| UnsupportedMethod ByteString
|
| UnsupportedMethod ByteString
|
||||||
|
|
||||||
data QPError = QPError Text Text
|
data QPError = QPError Text Text
|
||||||
|
data RangeError
|
||||||
type CallRequest = CallQuery
|
= NegativeLimit
|
||||||
|
| LowerGTUpper
|
||||||
|
| OutOfBounds Text Text
|
||||||
|
|
||||||
type NodeName = Text
|
type NodeName = Text
|
||||||
type Depth = Integer
|
type Depth = Integer
|
||||||
|
|
||||||
data JoinCondition =
|
|
||||||
JoinCondition
|
|
||||||
(QualifiedIdentifier, FieldName)
|
|
||||||
(QualifiedIdentifier, FieldName)
|
|
||||||
deriving (Eq)
|
|
||||||
|
|
||||||
data OrderTerm = OrderTerm
|
data OrderTerm = OrderTerm
|
||||||
{ otTerm :: Field
|
{ otTerm :: Field
|
||||||
, otDirection :: Maybe OrderDirection
|
, otDirection :: Maybe OrderDirection
|
||||||
@@ -95,19 +101,6 @@ data OrderNulls
|
|||||||
| OrderNullsLast
|
| OrderNullsLast
|
||||||
deriving (Eq)
|
deriving (Eq)
|
||||||
|
|
||||||
data CallQuery = FunctionCall
|
|
||||||
{ funCQi :: QualifiedIdentifier
|
|
||||||
, funCParams :: CallParams
|
|
||||||
, funCArgs :: Maybe LBS.ByteString
|
|
||||||
, funCScalar :: Bool
|
|
||||||
, funCMultipleCall :: Bool
|
|
||||||
, funCReturning :: [FieldName]
|
|
||||||
}
|
|
||||||
|
|
||||||
data CallParams
|
|
||||||
= KeyParams [ProcParam] -- ^ Call with key params: func(a := val1, b:= val2)
|
|
||||||
| OnePosParam ProcParam -- ^ Call with positional params(only one supported): func(val)
|
|
||||||
|
|
||||||
type Field = (FieldName, JsonPath)
|
type Field = (FieldName, JsonPath)
|
||||||
type Cast = Text
|
type Cast = Text
|
||||||
type Alias = Text
|
type Alias = Text
|
||||||
+89
-535
@@ -17,112 +17,61 @@ module PostgREST.App
|
|||||||
, run
|
, run
|
||||||
) where
|
) where
|
||||||
|
|
||||||
|
|
||||||
import Control.Monad.Except (liftEither)
|
import Control.Monad.Except (liftEither)
|
||||||
import Data.Either.Combinators (mapLeft)
|
import Data.Either.Combinators (mapLeft)
|
||||||
import Data.List (union)
|
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.String (IsString (..))
|
import Data.String (IsString (..))
|
||||||
import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort,
|
import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort,
|
||||||
setServerName)
|
setServerName)
|
||||||
import System.Posix.Types (FileMode)
|
import System.Posix.Types (FileMode)
|
||||||
|
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Hasql.Transaction.Sessions as SQL
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
import qualified Network.Wai as Wai
|
||||||
import qualified Data.HashMap.Strict as HM
|
import qualified Network.Wai.Handler.Warp as Warp
|
||||||
import qualified Data.Set as S
|
|
||||||
import qualified Hasql.DynamicStatements.Snippet as SQL (Snippet)
|
|
||||||
import qualified Hasql.Transaction as SQL
|
|
||||||
import qualified Hasql.Transaction.Sessions as SQL
|
|
||||||
import qualified Network.HTTP.Types.Header as HTTP
|
|
||||||
import qualified Network.HTTP.Types.Status as HTTP
|
|
||||||
import qualified Network.HTTP.Types.URI as HTTP
|
|
||||||
import qualified Network.Wai as Wai
|
|
||||||
import qualified Network.Wai.Handler.Warp as Warp
|
|
||||||
|
|
||||||
import qualified PostgREST.Admin as Admin
|
import qualified PostgREST.ApiRequest as ApiRequest
|
||||||
import qualified PostgREST.AppState as AppState
|
import qualified PostgREST.ApiRequest.Types as ApiRequestTypes
|
||||||
import qualified PostgREST.Auth as Auth
|
import qualified PostgREST.AppState as AppState
|
||||||
import qualified PostgREST.Cors as Cors
|
import qualified PostgREST.Auth as Auth
|
||||||
import qualified PostgREST.DbStructure as DbStructure
|
import qualified PostgREST.Cors as Cors
|
||||||
import qualified PostgREST.Error as Error
|
import qualified PostgREST.Error as Error
|
||||||
import qualified PostgREST.Logger as Logger
|
import qualified PostgREST.Logger as Logger
|
||||||
import qualified PostgREST.Middleware as Middleware
|
import qualified PostgREST.Plan as Plan
|
||||||
import qualified PostgREST.OpenAPI as OpenAPI
|
import qualified PostgREST.Query as Query
|
||||||
import qualified PostgREST.Query.QueryBuilder as QueryBuilder
|
import qualified PostgREST.Response as Response
|
||||||
import qualified PostgREST.Query.Statements as Statements
|
import qualified PostgREST.Workers as Workers
|
||||||
import qualified PostgREST.RangeQuery as RangeQuery
|
|
||||||
import qualified PostgREST.Request.ApiRequest as ApiRequest
|
|
||||||
import qualified PostgREST.Request.DbRequestBuilder as ReqBuilder
|
|
||||||
import qualified PostgREST.Request.Types as ApiRequestTypes
|
|
||||||
|
|
||||||
import PostgREST.AppState (AppState)
|
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
|
||||||
import PostgREST.Auth (AuthResult (..))
|
Mutation (..), Target (..))
|
||||||
import PostgREST.Config (AppConfig (..),
|
import PostgREST.AppState (AppState)
|
||||||
LogLevel (..),
|
import PostgREST.Auth (AuthResult (..))
|
||||||
OpenAPIMode (..))
|
import PostgREST.Config (AppConfig (..), LogLevel (..))
|
||||||
import PostgREST.Config.PgVersion (PgVersion (..))
|
import PostgREST.Config.PgVersion (PgVersion (..))
|
||||||
import PostgREST.DbStructure (DbStructure (..))
|
import PostgREST.Error (Error)
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
import PostgREST.Query (DbHandler)
|
||||||
QualifiedIdentifier (..),
|
import PostgREST.SchemaCache (SchemaCache (..))
|
||||||
Schema)
|
import PostgREST.Version (prettyVersion)
|
||||||
import PostgREST.DbStructure.Proc (ProcDescription (..),
|
|
||||||
ProcVolatility (..))
|
|
||||||
import PostgREST.DbStructure.Table (Table (..))
|
|
||||||
import PostgREST.Error (Error)
|
|
||||||
import PostgREST.GucHeader (GucHeader,
|
|
||||||
addHeadersIfNotIncluded,
|
|
||||||
unwrapGucHeader)
|
|
||||||
import PostgREST.MediaType (MTPlanAttrs (..),
|
|
||||||
MediaType (..))
|
|
||||||
import PostgREST.Query.Statements (ResultSet (..))
|
|
||||||
import PostgREST.Request.ApiRequest (Action (..),
|
|
||||||
ApiRequest (..),
|
|
||||||
InvokeMethod (..),
|
|
||||||
Mutation (..), Target (..))
|
|
||||||
import PostgREST.Request.Preferences (PreferCount (..),
|
|
||||||
PreferParameters (..),
|
|
||||||
PreferRepresentation (..),
|
|
||||||
toAppliedHeader)
|
|
||||||
import PostgREST.Request.QueryParams (QueryParams (..))
|
|
||||||
import PostgREST.Request.ReadQuery (ReadRequest, fstFieldNames)
|
|
||||||
import PostgREST.Version (prettyVersion)
|
|
||||||
import PostgREST.Workers (connectionWorker, listener)
|
|
||||||
|
|
||||||
import qualified PostgREST.DbStructure.Proc as Proc
|
|
||||||
import qualified PostgREST.MediaType as MediaType
|
|
||||||
|
|
||||||
import Protolude hiding (Handler)
|
import Protolude hiding (Handler)
|
||||||
|
|
||||||
data RequestContext = RequestContext
|
|
||||||
{ ctxConfig :: AppConfig
|
|
||||||
, ctxDbStructure :: DbStructure
|
|
||||||
, ctxApiRequest :: ApiRequest
|
|
||||||
, ctxPgVersion :: PgVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
type Handler = ExceptT Error
|
type Handler = ExceptT Error
|
||||||
|
|
||||||
type DbHandler = Handler SQL.Transaction
|
|
||||||
|
|
||||||
type SignalHandlerInstaller = AppState -> IO()
|
type SignalHandlerInstaller = AppState -> IO()
|
||||||
|
|
||||||
type SocketRunner = Warp.Settings -> Wai.Application -> FileMode -> FilePath -> IO()
|
type SocketRunner = Warp.Settings -> Wai.Application -> FileMode -> FilePath -> IO()
|
||||||
|
|
||||||
|
|
||||||
run :: SignalHandlerInstaller -> Maybe SocketRunner -> AppState -> IO ()
|
run :: SignalHandlerInstaller -> Maybe SocketRunner -> AppState -> IO ()
|
||||||
run installHandlers maybeRunWithSocket appState = do
|
run installHandlers maybeRunWithSocket appState = do
|
||||||
conf@AppConfig{..} <- AppState.getConfig appState
|
conf@AppConfig{..} <- AppState.getConfig appState
|
||||||
connectionWorker appState -- Loads the initial DbStructure
|
Workers.connectionWorker appState -- Loads the initial SchemaCache
|
||||||
installHandlers appState
|
installHandlers appState
|
||||||
-- reload schema cache + config on NOTIFY
|
-- reload schema cache + config on NOTIFY
|
||||||
when configDbChannelEnabled $ listener appState
|
Workers.runListener conf appState
|
||||||
|
|
||||||
let app = postgrest configLogLevel appState (connectionWorker appState)
|
Workers.runAdmin conf appState $ serverSettings conf
|
||||||
adminApp = Admin.postgrestAdmin appState conf
|
|
||||||
|
|
||||||
whenJust configAdminServerPort $ \adminPort -> do
|
let app = postgrest configLogLevel appState (Workers.connectionWorker appState)
|
||||||
AppState.logWithZTime appState $ "Admin server listening on port " <> show adminPort
|
|
||||||
void . forkIO $ Warp.runSettings (serverSettings conf & setPort adminPort) adminApp
|
|
||||||
|
|
||||||
case configServerUnixSocket of
|
case configServerUnixSocket of
|
||||||
Just socket ->
|
Just socket ->
|
||||||
@@ -137,9 +86,6 @@ run installHandlers maybeRunWithSocket appState = do
|
|||||||
do
|
do
|
||||||
AppState.logWithZTime appState $ "Listening on port " <> show configServerPort
|
AppState.logWithZTime appState $ "Listening on port " <> show configServerPort
|
||||||
Warp.runSettings (serverSettings conf) app
|
Warp.runSettings (serverSettings conf) app
|
||||||
where
|
|
||||||
whenJust :: Applicative m => Maybe a -> (a -> m ()) -> m ()
|
|
||||||
whenJust mg f = maybe (pure ()) f mg
|
|
||||||
|
|
||||||
serverSettings :: AppConfig -> Warp.Settings
|
serverSettings :: AppConfig -> Warp.Settings
|
||||||
serverSettings AppConfig{..} =
|
serverSettings AppConfig{..} =
|
||||||
@@ -160,61 +106,50 @@ postgrest logLevel appState connWorker =
|
|||||||
Left err -> respond $ Error.errorResponseFor err
|
Left err -> respond $ Error.errorResponseFor err
|
||||||
Right authResult -> do
|
Right authResult -> do
|
||||||
conf <- AppState.getConfig appState
|
conf <- AppState.getConfig appState
|
||||||
maybeDbStructure <- AppState.getDbStructure appState
|
maybeSchemaCache <- AppState.getSchemaCache appState
|
||||||
pgVer <- AppState.getPgVersion appState
|
pgVer <- AppState.getPgVersion appState
|
||||||
jsonDbS <- AppState.getJsonDbS appState
|
jsonDbS <- AppState.getJsonDbS appState
|
||||||
|
|
||||||
let
|
let
|
||||||
eitherResponse :: IO (Either Error Wai.Response)
|
eitherResponse :: IO (Either Error Wai.Response)
|
||||||
eitherResponse =
|
eitherResponse =
|
||||||
runExceptT $ postgrestResponse appState conf maybeDbStructure jsonDbS pgVer authResult req
|
runExceptT $ postgrestResponse appState conf maybeSchemaCache jsonDbS pgVer authResult req
|
||||||
|
|
||||||
response <- either Error.errorResponseFor identity <$> eitherResponse
|
response <- either Error.errorResponseFor identity <$> eitherResponse
|
||||||
-- Launch the connWorker when the connection is down. The postgrest
|
-- Launch the connWorker when the connection is down. The postgrest
|
||||||
-- function can respond successfully (with a stale schema cache) before
|
-- function can respond successfully (with a stale schema cache) before
|
||||||
-- the connWorker is done.
|
-- the connWorker is done.
|
||||||
let isPGAway = Wai.responseStatus response == HTTP.status503
|
when (Response.isServiceUnavailable response) connWorker
|
||||||
when isPGAway connWorker
|
resp <- do
|
||||||
resp <- addRetryHint isPGAway appState response
|
delay <- AppState.getRetryNextIn appState
|
||||||
|
return $ Response.addRetryHint delay response
|
||||||
respond resp
|
respond resp
|
||||||
|
|
||||||
addRetryHint :: Bool -> AppState -> Wai.Response -> IO Wai.Response
|
|
||||||
addRetryHint shouldAdd appState response = do
|
|
||||||
delay <- AppState.getRetryNextIn appState
|
|
||||||
let h = ("Retry-After", BS.pack $ show delay)
|
|
||||||
return $ Wai.mapResponseHeaders (\hs -> if shouldAdd then h:hs else hs) response
|
|
||||||
|
|
||||||
postgrestResponse
|
postgrestResponse
|
||||||
:: AppState.AppState
|
:: AppState.AppState
|
||||||
-> AppConfig
|
-> AppConfig
|
||||||
-> Maybe DbStructure
|
-> Maybe SchemaCache
|
||||||
-> ByteString
|
-> ByteString
|
||||||
-> PgVersion
|
-> PgVersion
|
||||||
-> AuthResult
|
-> AuthResult
|
||||||
-> Wai.Request
|
-> Wai.Request
|
||||||
-> Handler IO Wai.Response
|
-> Handler IO Wai.Response
|
||||||
postgrestResponse appState conf@AppConfig{..} maybeDbStructure jsonDbS pgVer AuthResult{..} req = do
|
postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jsonDbS pgVer authResult@AuthResult{..} req = do
|
||||||
body <- lift $ Wai.strictRequestBody req
|
sCache <-
|
||||||
|
case maybeSchemaCache of
|
||||||
dbStructure <-
|
Just sCache ->
|
||||||
case maybeDbStructure of
|
return sCache
|
||||||
Just dbStructure ->
|
|
||||||
return dbStructure
|
|
||||||
Nothing ->
|
Nothing ->
|
||||||
throwError Error.NoSchemaCacheError
|
throwError Error.NoSchemaCacheError
|
||||||
|
|
||||||
|
body <- lift $ Wai.strictRequestBody req
|
||||||
|
|
||||||
apiRequest <-
|
apiRequest <-
|
||||||
liftEither . mapLeft Error.ApiRequestError $
|
liftEither . mapLeft Error.ApiRequestError $
|
||||||
ApiRequest.userApiRequest conf dbStructure req body
|
ApiRequest.userApiRequest conf sCache req body
|
||||||
|
|
||||||
let ctx apiReq = RequestContext conf dbStructure apiReq pgVer
|
Response.optionalRollback conf apiRequest $
|
||||||
|
handleRequest authResult conf appState (Query.txMode apiRequest) (Just authRole /= configDbAnonRole) configDbPreparedStatements jsonDbS pgVer apiRequest sCache
|
||||||
if iAction apiRequest == ActionInfo then
|
|
||||||
handleInfo (iTarget apiRequest) (ctx apiRequest)
|
|
||||||
else
|
|
||||||
runDbHandler appState (txMode apiRequest) (Just authRole /= configDbAnonRole) configDbPreparedStatements .
|
|
||||||
Middleware.optionalRollback conf apiRequest $
|
|
||||||
Middleware.runPgLocals conf authClaims authRole (handleRequest . ctx) apiRequest jsonDbS pgVer
|
|
||||||
|
|
||||||
runDbHandler :: AppState.AppState -> SQL.Mode -> Bool -> Bool -> DbHandler b -> Handler IO b
|
runDbHandler :: AppState.AppState -> SQL.Mode -> Bool -> Bool -> DbHandler b -> Handler IO b
|
||||||
runDbHandler appState mode authenticated prepared handler = do
|
runDbHandler appState mode authenticated prepared handler = do
|
||||||
@@ -228,433 +163,52 @@ runDbHandler appState mode authenticated prepared handler = do
|
|||||||
|
|
||||||
liftEither resp
|
liftEither resp
|
||||||
|
|
||||||
handleRequest :: RequestContext -> DbHandler Wai.Response
|
handleRequest :: AuthResult -> AppConfig -> AppState.AppState -> SQL.Mode -> Bool -> Bool -> ByteString -> PgVersion -> ApiRequest -> SchemaCache -> Handler IO Wai.Response
|
||||||
handleRequest context@(RequestContext _ _ ApiRequest{..} _) =
|
handleRequest AuthResult{..} conf appState mode authenticated prepared jsonDbS pgVer apiReq@ApiRequest{..} sCache =
|
||||||
case (iAction, iTarget) of
|
case (iAction, iTarget) of
|
||||||
(ActionRead headersOnly, TargetIdent identifier) ->
|
(ActionRead headersOnly, TargetIdent identifier) -> do
|
||||||
handleRead headersOnly identifier context
|
rPlan <- liftEither $ Plan.readPlan identifier conf sCache apiReq
|
||||||
(ActionMutate MutationCreate, TargetIdent identifier) ->
|
resultSet <- runQuery $ Query.readQuery rPlan conf apiReq
|
||||||
handleCreate identifier context
|
return $ Response.readResponse headersOnly identifier apiReq resultSet
|
||||||
(ActionMutate MutationUpdate, TargetIdent identifier) ->
|
|
||||||
handleUpdate identifier context
|
(ActionMutate MutationCreate, TargetIdent identifier) -> do
|
||||||
(ActionMutate MutationSingleUpsert, TargetIdent identifier) ->
|
mrPlan <- liftEither $ Plan.mutateReadPlan MutationCreate apiReq identifier conf sCache
|
||||||
handleSingleUpsert identifier context
|
resultSet <- runQuery $ Query.createQuery mrPlan apiReq conf
|
||||||
(ActionMutate MutationDelete, TargetIdent identifier) ->
|
return $ Response.createResponse identifier mrPlan apiReq resultSet
|
||||||
handleDelete identifier context
|
|
||||||
(ActionInvoke invMethod, TargetProc proc _) ->
|
(ActionMutate MutationUpdate, TargetIdent identifier) -> do
|
||||||
handleInvoke invMethod proc context
|
mrPlan <- liftEither $ Plan.mutateReadPlan MutationUpdate apiReq identifier conf sCache
|
||||||
(ActionInspect headersOnly, TargetDefaultSpec tSchema) ->
|
resultSet <- runQuery $ Query.updateQuery mrPlan apiReq conf
|
||||||
handleOpenApi headersOnly tSchema context
|
return $ Response.updateResponse apiReq resultSet
|
||||||
|
|
||||||
|
(ActionMutate MutationSingleUpsert, TargetIdent identifier) -> do
|
||||||
|
mrPlan <- liftEither $ Plan.mutateReadPlan MutationSingleUpsert apiReq identifier conf sCache
|
||||||
|
resultSet <- runQuery $ Query.singleUpsertQuery mrPlan apiReq conf
|
||||||
|
return $ Response.singleUpsertResponse apiReq resultSet
|
||||||
|
|
||||||
|
(ActionMutate MutationDelete, TargetIdent identifier) -> do
|
||||||
|
mrPlan <- liftEither $ Plan.mutateReadPlan MutationDelete apiReq identifier conf sCache
|
||||||
|
resultSet <- runQuery $ Query.deleteQuery mrPlan apiReq conf
|
||||||
|
return $ Response.deleteResponse apiReq resultSet
|
||||||
|
|
||||||
|
(ActionInvoke invMethod, TargetProc proc _) -> do
|
||||||
|
cPlan <- liftEither $ Plan.callReadPlan proc conf sCache apiReq
|
||||||
|
resultSet <- runQuery $ Query.invokeQuery proc cPlan apiReq conf
|
||||||
|
return $ Response.invokeResponse invMethod proc apiReq resultSet
|
||||||
|
|
||||||
|
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do
|
||||||
|
oaiResult <- runQuery $ Query.openApiQuery sCache pgVer conf tSchema
|
||||||
|
return $ Response.openApiResponse headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile
|
||||||
|
|
||||||
|
(ActionInfo, _) ->
|
||||||
|
return $ Response.infoResponse iTarget sCache
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
-- This is unreachable as the ApiRequest.hs rejects it before
|
-- This is unreachable as the ApiRequest.hs rejects it before
|
||||||
-- TODO Refactor the Action/Target types to remove this line
|
-- TODO Refactor the Action/Target types to remove this line
|
||||||
throwError $ Error.ApiRequestError ApiRequestTypes.NotFound
|
throwError $ Error.ApiRequestError ApiRequestTypes.NotFound
|
||||||
|
|
||||||
handleRead :: Bool -> QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
|
|
||||||
handleRead headersOnly identifier context@RequestContext{..} = do
|
|
||||||
req <- readRequest identifier context
|
|
||||||
bField <- binaryField context req
|
|
||||||
|
|
||||||
let
|
|
||||||
ApiRequest{..} = ctxApiRequest
|
|
||||||
AppConfig{..} = ctxConfig
|
|
||||||
countQuery = QueryBuilder.readRequestToCountQuery req
|
|
||||||
|
|
||||||
resultSet <-
|
|
||||||
lift . SQL.statement mempty $
|
|
||||||
Statements.prepareRead
|
|
||||||
(QueryBuilder.readRequestToQuery req)
|
|
||||||
(if iPreferCount == Just EstimatedCount then
|
|
||||||
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
|
|
||||||
QueryBuilder.limitedQuery countQuery ((+ 1) <$> configDbMaxRows)
|
|
||||||
else
|
|
||||||
countQuery
|
|
||||||
)
|
|
||||||
(shouldCount iPreferCount)
|
|
||||||
iAcceptMediaType
|
|
||||||
bField
|
|
||||||
configDbPreparedStatements
|
|
||||||
|
|
||||||
case resultSet of
|
|
||||||
RSStandard{..} -> do
|
|
||||||
total <- readTotal ctxConfig ctxApiRequest rsTableTotal countQuery
|
|
||||||
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
|
|
||||||
|
|
||||||
let
|
|
||||||
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal total
|
|
||||||
headers =
|
|
||||||
[ contentRange
|
|
||||||
, ( "Content-Location"
|
|
||||||
, "/"
|
|
||||||
<> toUtf8 (qiName identifier)
|
|
||||||
<> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams
|
|
||||||
)
|
|
||||||
]
|
|
||||||
++ contentTypeHeaders context
|
|
||||||
|
|
||||||
failNotSingular iAcceptMediaType rsQueryTotal . response status headers $
|
|
||||||
if headersOnly then mempty else LBS.fromStrict rsBody
|
|
||||||
|
|
||||||
RSPlan plan ->
|
|
||||||
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
|
|
||||||
|
|
||||||
readTotal :: AppConfig -> ApiRequest -> Maybe Int64 -> SQL.Snippet -> DbHandler (Maybe Int64)
|
|
||||||
readTotal AppConfig{..} ApiRequest{..} tableTotal countQuery =
|
|
||||||
case iPreferCount of
|
|
||||||
Just PlannedCount ->
|
|
||||||
explain
|
|
||||||
Just EstimatedCount ->
|
|
||||||
if tableTotal > (fromIntegral <$> configDbMaxRows) then
|
|
||||||
max tableTotal <$> explain
|
|
||||||
else
|
|
||||||
return tableTotal
|
|
||||||
_ ->
|
|
||||||
return tableTotal
|
|
||||||
where
|
where
|
||||||
explain =
|
runQuery query =
|
||||||
lift . SQL.statement mempty . Statements.preparePlanRows countQuery $
|
runDbHandler appState mode authenticated prepared $ do
|
||||||
configDbPreparedStatements
|
Query.setPgLocals conf authClaims authRole apiReq jsonDbS pgVer
|
||||||
|
query
|
||||||
handleCreate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
|
|
||||||
handleCreate identifier@QualifiedIdentifier{..} context@RequestContext{..} = do
|
|
||||||
let
|
|
||||||
ApiRequest{..} = ctxApiRequest
|
|
||||||
pkCols = if iPreferRepresentation /= None || isJust iPreferResolution
|
|
||||||
then maybe mempty tablePKCols $ HM.lookup identifier $ dbTables ctxDbStructure
|
|
||||||
else mempty
|
|
||||||
|
|
||||||
resultSet <- writeQuery MutationCreate identifier True pkCols context
|
|
||||||
|
|
||||||
case resultSet of
|
|
||||||
RSStandard{..} -> do
|
|
||||||
|
|
||||||
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
|
|
||||||
|
|
||||||
let
|
|
||||||
headers =
|
|
||||||
catMaybes
|
|
||||||
[ if null rsLocation then
|
|
||||||
Nothing
|
|
||||||
else
|
|
||||||
Just
|
|
||||||
( HTTP.hLocation
|
|
||||||
, "/"
|
|
||||||
<> toUtf8 qiName
|
|
||||||
<> HTTP.renderSimpleQuery True rsLocation
|
|
||||||
)
|
|
||||||
, Just . RangeQuery.contentRangeH 1 0 $
|
|
||||||
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
|
|
||||||
, if null pkCols && isNothing (qsOnConflict iQueryParams) then
|
|
||||||
Nothing
|
|
||||||
else
|
|
||||||
toAppliedHeader <$> iPreferResolution
|
|
||||||
]
|
|
||||||
|
|
||||||
failNotSingular iAcceptMediaType rsQueryTotal $
|
|
||||||
if iPreferRepresentation == Full then
|
|
||||||
response HTTP.status201 (headers ++ contentTypeHeaders context) (LBS.fromStrict rsBody)
|
|
||||||
else
|
|
||||||
response HTTP.status201 headers mempty
|
|
||||||
|
|
||||||
RSPlan plan ->
|
|
||||||
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
|
|
||||||
|
|
||||||
handleUpdate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
|
|
||||||
handleUpdate identifier context@(RequestContext _ _ ApiRequest{..} _) = do
|
|
||||||
resultSet <- writeQuery MutationUpdate identifier False mempty context
|
|
||||||
|
|
||||||
case resultSet of
|
|
||||||
RSStandard{..} -> do
|
|
||||||
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
|
|
||||||
|
|
||||||
let
|
|
||||||
fullRepr = iPreferRepresentation == Full
|
|
||||||
updateIsNoOp = S.null iColumns
|
|
||||||
status
|
|
||||||
| rsQueryTotal == 0 && not updateIsNoOp = HTTP.status404
|
|
||||||
| fullRepr = HTTP.status200
|
|
||||||
| otherwise = HTTP.status204
|
|
||||||
contentRangeHeader =
|
|
||||||
RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
|
|
||||||
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
|
|
||||||
|
|
||||||
failChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) rsQueryTotal =<<
|
|
||||||
failNotSingular iAcceptMediaType rsQueryTotal (
|
|
||||||
if fullRepr then
|
|
||||||
response status (contentTypeHeaders context ++ [contentRangeHeader]) (LBS.fromStrict rsBody)
|
|
||||||
else
|
|
||||||
response status [contentRangeHeader] mempty)
|
|
||||||
|
|
||||||
RSPlan plan ->
|
|
||||||
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
|
|
||||||
|
|
||||||
handleSingleUpsert :: QualifiedIdentifier -> RequestContext-> DbHandler Wai.Response
|
|
||||||
handleSingleUpsert identifier context@(RequestContext _ ctxDbStructure ApiRequest{..} _) = do
|
|
||||||
let pkCols = maybe mempty tablePKCols $ HM.lookup identifier $ dbTables ctxDbStructure
|
|
||||||
|
|
||||||
resultSet <- writeQuery MutationSingleUpsert identifier False pkCols context
|
|
||||||
|
|
||||||
case resultSet of
|
|
||||||
RSStandard {..} -> do
|
|
||||||
|
|
||||||
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
|
|
||||||
|
|
||||||
-- Makes sure the querystring pk matches the payload pk
|
|
||||||
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted,
|
|
||||||
-- PUT /items?id=eq.14 { "id" : 2, .. } is rejected.
|
|
||||||
-- If this condition is not satisfied then nothing is inserted,
|
|
||||||
-- check the WHERE for INSERT in QueryBuilder.hs to see how it's done
|
|
||||||
when (rsQueryTotal /= 1) $ do
|
|
||||||
lift SQL.condemn
|
|
||||||
throwError Error.PutMatchingPkError
|
|
||||||
|
|
||||||
return $
|
|
||||||
if iPreferRepresentation == Full then
|
|
||||||
response HTTP.status200 (contentTypeHeaders context) (LBS.fromStrict rsBody)
|
|
||||||
else
|
|
||||||
response HTTP.status204 [] mempty
|
|
||||||
|
|
||||||
RSPlan plan ->
|
|
||||||
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
|
|
||||||
|
|
||||||
handleDelete :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
|
|
||||||
handleDelete identifier context@(RequestContext _ _ ApiRequest{..} _) = do
|
|
||||||
resultSet <- writeQuery MutationDelete identifier False mempty context
|
|
||||||
|
|
||||||
case resultSet of
|
|
||||||
RSStandard {..} -> do
|
|
||||||
|
|
||||||
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
|
|
||||||
|
|
||||||
let
|
|
||||||
contentRangeHeader =
|
|
||||||
RangeQuery.contentRangeH 1 0 $
|
|
||||||
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
|
|
||||||
|
|
||||||
failChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) rsQueryTotal =<<
|
|
||||||
failNotSingular iAcceptMediaType rsQueryTotal (
|
|
||||||
if iPreferRepresentation == Full then
|
|
||||||
response HTTP.status200
|
|
||||||
(contentTypeHeaders context ++ [contentRangeHeader])
|
|
||||||
(LBS.fromStrict rsBody)
|
|
||||||
else
|
|
||||||
response HTTP.status204 [contentRangeHeader] mempty)
|
|
||||||
|
|
||||||
RSPlan plan ->
|
|
||||||
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
|
|
||||||
|
|
||||||
handleInfo :: Monad m => Target -> RequestContext -> Handler m Wai.Response
|
|
||||||
handleInfo target RequestContext{..} =
|
|
||||||
case target of
|
|
||||||
TargetIdent identifier ->
|
|
||||||
case HM.lookup identifier (dbTables ctxDbStructure) of
|
|
||||||
Just tbl -> infoResponse $ allowH tbl
|
|
||||||
Nothing -> throwError $ Error.ApiRequestError ApiRequestTypes.NotFound
|
|
||||||
TargetProc pd _
|
|
||||||
| pdVolatility pd == Volatile -> infoResponse "OPTIONS,POST"
|
|
||||||
| otherwise -> infoResponse "OPTIONS,GET,HEAD,POST"
|
|
||||||
TargetDefaultSpec _ -> infoResponse "OPTIONS,GET,HEAD"
|
|
||||||
where
|
|
||||||
infoResponse allowHeader = return $ Wai.responseLBS HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty
|
|
||||||
allOrigins = ("Access-Control-Allow-Origin", "*")
|
|
||||||
allowH table =
|
|
||||||
let hasPK = not . null $ tablePKCols table in
|
|
||||||
BS.intercalate "," $
|
|
||||||
["OPTIONS,GET,HEAD"] ++
|
|
||||||
["POST" | tableInsertable table] ++
|
|
||||||
["PUT" | tableInsertable table && tableUpdatable table && hasPK] ++
|
|
||||||
["PATCH" | tableUpdatable table] ++
|
|
||||||
["DELETE" | tableDeletable table]
|
|
||||||
|
|
||||||
handleInvoke :: InvokeMethod -> ProcDescription -> RequestContext -> DbHandler Wai.Response
|
|
||||||
handleInvoke invMethod proc context@RequestContext{..} = do
|
|
||||||
let
|
|
||||||
ApiRequest{..} = ctxApiRequest
|
|
||||||
|
|
||||||
identifier =
|
|
||||||
QualifiedIdentifier
|
|
||||||
(pdSchema proc)
|
|
||||||
(fromMaybe (pdName proc) $ Proc.procTableName proc)
|
|
||||||
|
|
||||||
req <- readRequest identifier context
|
|
||||||
bField <- binaryField context req
|
|
||||||
|
|
||||||
let callReq = ReqBuilder.callRequest proc ctxApiRequest req
|
|
||||||
|
|
||||||
resultSet <-
|
|
||||||
lift . SQL.statement mempty $
|
|
||||||
Statements.prepareCall
|
|
||||||
(Proc.procReturnsScalar proc)
|
|
||||||
(Proc.procReturnsSingle proc)
|
|
||||||
(QueryBuilder.requestToCallProcQuery callReq)
|
|
||||||
(QueryBuilder.readRequestToQuery req)
|
|
||||||
(QueryBuilder.readRequestToCountQuery req)
|
|
||||||
(shouldCount iPreferCount)
|
|
||||||
iAcceptMediaType
|
|
||||||
(iPreferParameters == Just MultipleObjects)
|
|
||||||
bField
|
|
||||||
(configDbPreparedStatements ctxConfig)
|
|
||||||
|
|
||||||
case resultSet of
|
|
||||||
RSStandard {..} -> do
|
|
||||||
response <- liftEither $ gucResponse <$> rsGucStatus <*> rsGucHeaders
|
|
||||||
let
|
|
||||||
(status, contentRange) =
|
|
||||||
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
|
|
||||||
|
|
||||||
failNotSingular iAcceptMediaType rsQueryTotal $
|
|
||||||
if Proc.procReturnsVoid proc then
|
|
||||||
response HTTP.status204 [contentRange] mempty
|
|
||||||
else
|
|
||||||
response status
|
|
||||||
(contentTypeHeaders context ++ [contentRange])
|
|
||||||
(if invMethod == InvHead then mempty else LBS.fromStrict rsBody)
|
|
||||||
|
|
||||||
RSPlan plan ->
|
|
||||||
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
|
|
||||||
|
|
||||||
handleOpenApi :: Bool -> Schema -> RequestContext -> DbHandler Wai.Response
|
|
||||||
handleOpenApi headersOnly tSchema (RequestContext conf@AppConfig{..} dbStructure apiRequest ctxPgVersion) = do
|
|
||||||
body <-
|
|
||||||
lift $ case configOpenApiMode of
|
|
||||||
OAFollowPriv ->
|
|
||||||
OpenAPI.encode conf dbStructure
|
|
||||||
<$> SQL.statement [tSchema] (DbStructure.accessibleTables ctxPgVersion configDbPreparedStatements)
|
|
||||||
<*> SQL.statement tSchema (DbStructure.accessibleProcs ctxPgVersion configDbPreparedStatements)
|
|
||||||
<*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)
|
|
||||||
OAIgnorePriv ->
|
|
||||||
OpenAPI.encode conf dbStructure
|
|
||||||
(HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ DbStructure.dbTables dbStructure)
|
|
||||||
(HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ DbStructure.dbProcs dbStructure)
|
|
||||||
<$> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)
|
|
||||||
OADisabled ->
|
|
||||||
pure mempty
|
|
||||||
|
|
||||||
return $
|
|
||||||
Wai.responseLBS HTTP.status200
|
|
||||||
(MediaType.toContentType MTOpenAPI : maybeToList (profileHeader apiRequest))
|
|
||||||
(if headersOnly then mempty else body)
|
|
||||||
|
|
||||||
txMode :: ApiRequest -> SQL.Mode
|
|
||||||
txMode ApiRequest{..} =
|
|
||||||
case (iAction, iTarget) of
|
|
||||||
(ActionRead _, _) ->
|
|
||||||
SQL.Read
|
|
||||||
(ActionInfo, _) ->
|
|
||||||
SQL.Read
|
|
||||||
(ActionInspect _, _) ->
|
|
||||||
SQL.Read
|
|
||||||
(ActionInvoke InvGet, _) ->
|
|
||||||
SQL.Read
|
|
||||||
(ActionInvoke InvHead, _) ->
|
|
||||||
SQL.Read
|
|
||||||
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Stable} _) ->
|
|
||||||
SQL.Read
|
|
||||||
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Immutable} _) ->
|
|
||||||
SQL.Read
|
|
||||||
_ ->
|
|
||||||
SQL.Write
|
|
||||||
|
|
||||||
writeQuery :: Mutation -> QualifiedIdentifier -> Bool -> [Text] -> RequestContext -> DbHandler ResultSet
|
|
||||||
writeQuery mutation identifier@QualifiedIdentifier{..} isInsert pkCols context@RequestContext{..} = do
|
|
||||||
readReq <- readRequest identifier context
|
|
||||||
|
|
||||||
mutateReq <-
|
|
||||||
liftEither $
|
|
||||||
ReqBuilder.mutateRequest mutation qiSchema qiName ctxApiRequest
|
|
||||||
pkCols
|
|
||||||
readReq
|
|
||||||
|
|
||||||
lift . SQL.statement mempty $
|
|
||||||
Statements.prepareWrite
|
|
||||||
(QueryBuilder.readRequestToQuery readReq)
|
|
||||||
(QueryBuilder.mutateRequestToQuery mutateReq)
|
|
||||||
isInsert
|
|
||||||
(iAcceptMediaType ctxApiRequest)
|
|
||||||
(iPreferRepresentation ctxApiRequest)
|
|
||||||
pkCols
|
|
||||||
(configDbPreparedStatements ctxConfig)
|
|
||||||
|
|
||||||
-- | Response with headers and status overridden from GUCs.
|
|
||||||
gucResponse
|
|
||||||
:: Maybe HTTP.Status
|
|
||||||
-> [GucHeader]
|
|
||||||
-> HTTP.Status
|
|
||||||
-> [HTTP.Header]
|
|
||||||
-> LBS.ByteString
|
|
||||||
-> Wai.Response
|
|
||||||
gucResponse gucStatus gucHeaders status headers =
|
|
||||||
Wai.responseLBS (fromMaybe status gucStatus) $
|
|
||||||
addHeadersIfNotIncluded headers (map unwrapGucHeader gucHeaders)
|
|
||||||
|
|
||||||
-- |
|
|
||||||
-- Fail a response if a single JSON object was requested and not exactly one
|
|
||||||
-- was found.
|
|
||||||
failNotSingular :: MediaType -> Int64 -> Wai.Response -> DbHandler Wai.Response
|
|
||||||
failNotSingular mediaType queryTotal response =
|
|
||||||
if mediaType == MTSingularJSON && queryTotal /= 1 then
|
|
||||||
do
|
|
||||||
lift SQL.condemn
|
|
||||||
throwError $ Error.singularityError queryTotal
|
|
||||||
else
|
|
||||||
return response
|
|
||||||
|
|
||||||
failChangesOffLimits :: Maybe Integer -> Int64 -> Wai.Response -> DbHandler Wai.Response
|
|
||||||
failChangesOffLimits (Just maxChanges) queryTotal response =
|
|
||||||
if queryTotal > fromIntegral maxChanges
|
|
||||||
then do
|
|
||||||
lift SQL.condemn
|
|
||||||
throwError $ Error.OffLimitsChangesError queryTotal maxChanges
|
|
||||||
else
|
|
||||||
return response
|
|
||||||
failChangesOffLimits _ _ response = return response
|
|
||||||
|
|
||||||
shouldCount :: Maybe PreferCount -> Bool
|
|
||||||
shouldCount preferCount =
|
|
||||||
preferCount == Just ExactCount || preferCount == Just EstimatedCount
|
|
||||||
|
|
||||||
returnsScalar :: ApiRequest.Target -> Bool
|
|
||||||
returnsScalar (TargetProc proc _) = Proc.procReturnsScalar proc
|
|
||||||
returnsScalar _ = False
|
|
||||||
|
|
||||||
readRequest :: Monad m => QualifiedIdentifier -> RequestContext -> Handler m ReadRequest
|
|
||||||
readRequest QualifiedIdentifier{..} (RequestContext AppConfig{..} dbStructure apiRequest _) =
|
|
||||||
liftEither $
|
|
||||||
ReqBuilder.readRequest qiSchema qiName configDbMaxRows
|
|
||||||
(dbRelationships dbStructure)
|
|
||||||
apiRequest
|
|
||||||
|
|
||||||
contentTypeHeaders :: RequestContext -> [HTTP.Header]
|
|
||||||
contentTypeHeaders RequestContext{..} =
|
|
||||||
MediaType.toContentType (iAcceptMediaType ctxApiRequest) : maybeToList (profileHeader ctxApiRequest)
|
|
||||||
|
|
||||||
-- | If raw(binary) output is requested, check that MediaType is one of the
|
|
||||||
-- admitted rawMediaTypes and that`?select=...` contains only one field other
|
|
||||||
-- than `*`
|
|
||||||
binaryField :: Monad m => RequestContext -> ReadRequest -> Handler m (Maybe FieldName)
|
|
||||||
binaryField RequestContext{..} readReq
|
|
||||||
| returnsScalar (iTarget ctxApiRequest) && isRawMediaType =
|
|
||||||
return $ Just "pgrst_scalar"
|
|
||||||
| isRawMediaType =
|
|
||||||
let
|
|
||||||
fldNames = fstFieldNames readReq
|
|
||||||
fieldName = headMay fldNames
|
|
||||||
in
|
|
||||||
if length fldNames == 1 && fieldName /= Just "*" then
|
|
||||||
return fieldName
|
|
||||||
else
|
|
||||||
throwError $ Error.BinaryFieldError mediaType
|
|
||||||
| otherwise =
|
|
||||||
return Nothing
|
|
||||||
where
|
|
||||||
mediaType = iAcceptMediaType ctxApiRequest
|
|
||||||
isRawMediaType = mediaType `elem` configRawMediaTypes ctxConfig `union` [MTOctetStream, MTTextPlain, MTTextXML] || isRawPlan mediaType
|
|
||||||
isRawPlan mt = case mt of
|
|
||||||
MTPlan (MTPlanAttrs (Just MTOctetStream) _ _) -> True
|
|
||||||
MTPlan (MTPlanAttrs (Just MTTextPlain) _ _) -> True
|
|
||||||
MTPlan (MTPlanAttrs (Just MTTextXML) _ _) -> True
|
|
||||||
_ -> False
|
|
||||||
|
|
||||||
profileHeader :: ApiRequest -> Maybe HTTP.Header
|
|
||||||
profileHeader ApiRequest{..} =
|
|
||||||
(,) "Content-Profile" <$> (toUtf8 <$> iProfile)
|
|
||||||
|
|||||||
+31
-19
@@ -3,8 +3,9 @@
|
|||||||
module PostgREST.AppState
|
module PostgREST.AppState
|
||||||
( AppState
|
( AppState
|
||||||
, destroy
|
, destroy
|
||||||
|
, flushPool
|
||||||
, getConfig
|
, getConfig
|
||||||
, getDbStructure
|
, getSchemaCache
|
||||||
, getIsListenerOn
|
, getIsListenerOn
|
||||||
, getJsonDbS
|
, getJsonDbS
|
||||||
, getMainThreadId
|
, getMainThreadId
|
||||||
@@ -16,12 +17,11 @@ module PostgREST.AppState
|
|||||||
, initWithPool
|
, initWithPool
|
||||||
, logWithZTime
|
, logWithZTime
|
||||||
, putConfig
|
, putConfig
|
||||||
, putDbStructure
|
, putSchemaCache
|
||||||
, putIsListenerOn
|
, putIsListenerOn
|
||||||
, putJsonDbS
|
, putJsonDbS
|
||||||
, putPgVersion
|
, putPgVersion
|
||||||
, putRetryNextIn
|
, putRetryNextIn
|
||||||
, releasePool
|
|
||||||
, signalListener
|
, signalListener
|
||||||
, usePool
|
, usePool
|
||||||
, waitListener
|
, waitListener
|
||||||
@@ -40,17 +40,19 @@ import Data.Time.Clock (UTCTime, getCurrentTime)
|
|||||||
|
|
||||||
import PostgREST.Config (AppConfig (..))
|
import PostgREST.Config (AppConfig (..))
|
||||||
import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion)
|
import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion)
|
||||||
import PostgREST.DbStructure (DbStructure)
|
import PostgREST.SchemaCache (SchemaCache)
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
|
|
||||||
data AppState = AppState
|
data AppState = AppState
|
||||||
{ statePool :: SQL.Pool -- | Connection pool, either a 'Connection' or a 'ConnectionError'
|
-- | Database connection pool
|
||||||
|
{ statePool :: SQL.Pool
|
||||||
|
-- | Database server version, will be updated by the connectionWorker
|
||||||
, statePgVersion :: IORef PgVersion
|
, statePgVersion :: IORef PgVersion
|
||||||
-- | No schema cache at the start. Will be filled in by the connectionWorker
|
-- | No schema cache at the start. Will be filled in by the connectionWorker
|
||||||
, stateDbStructure :: IORef (Maybe DbStructure)
|
, stateSchemaCache :: IORef (Maybe SchemaCache)
|
||||||
-- | Cached DbStructure in json
|
-- | Cached SchemaCache in json
|
||||||
, stateJsonDbS :: IORef ByteString
|
, stateJsonDbS :: IORef ByteString
|
||||||
-- | Binary semaphore to make sure just one connectionWorker can run at a time
|
-- | Binary semaphore to make sure just one connectionWorker can run at a time
|
||||||
, stateWorkerSem :: MVar ()
|
, stateWorkerSem :: MVar ()
|
||||||
@@ -72,12 +74,12 @@ data AppState = AppState
|
|||||||
|
|
||||||
init :: AppConfig -> IO AppState
|
init :: AppConfig -> IO AppState
|
||||||
init conf = do
|
init conf = do
|
||||||
newPool <- initPool conf
|
pool <- initPool conf
|
||||||
initWithPool newPool conf
|
initWithPool pool conf
|
||||||
|
|
||||||
initWithPool :: SQL.Pool -> AppConfig -> IO AppState
|
initWithPool :: SQL.Pool -> AppConfig -> IO AppState
|
||||||
initWithPool newPool conf =
|
initWithPool pool conf =
|
||||||
AppState newPool
|
AppState pool
|
||||||
<$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step
|
<$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step
|
||||||
<*> newIORef Nothing
|
<*> newIORef Nothing
|
||||||
<*> newIORef mempty
|
<*> newIORef mempty
|
||||||
@@ -91,17 +93,27 @@ initWithPool newPool conf =
|
|||||||
<*> newIORef 0
|
<*> newIORef 0
|
||||||
|
|
||||||
destroy :: AppState -> IO ()
|
destroy :: AppState -> IO ()
|
||||||
destroy = releasePool
|
destroy = destroyPool
|
||||||
|
|
||||||
initPool :: AppConfig -> IO SQL.Pool
|
initPool :: AppConfig -> IO SQL.Pool
|
||||||
initPool AppConfig{..} =
|
initPool AppConfig{..} =
|
||||||
SQL.acquire (configDbPoolSize, configDbPoolTimeout, toUtf8 configDbUri)
|
SQL.acquire configDbPoolSize timeoutMilliseconds $ toUtf8 configDbUri
|
||||||
|
where
|
||||||
|
timeoutMilliseconds = (* oneSecond) <$> configDbPoolAcquisitionTimeout
|
||||||
|
oneSecond = 1000000
|
||||||
|
|
||||||
|
-- | Run an action with a database connection.
|
||||||
usePool :: AppState -> SQL.Session a -> IO (Either SQL.UsageError a)
|
usePool :: AppState -> SQL.Session a -> IO (Either SQL.UsageError a)
|
||||||
usePool AppState{..} = SQL.use statePool
|
usePool AppState{..} = SQL.use statePool
|
||||||
|
|
||||||
releasePool :: AppState -> IO ()
|
-- | Flush the connection pool so that any future use of the pool will
|
||||||
releasePool AppState{..} = SQL.release statePool
|
-- use connections freshly established after this call.
|
||||||
|
flushPool :: AppState -> IO ()
|
||||||
|
flushPool AppState{..} = SQL.release statePool
|
||||||
|
|
||||||
|
-- | Destroy the pool on shutdown.
|
||||||
|
destroyPool :: AppState -> IO ()
|
||||||
|
destroyPool AppState{..} = SQL.release statePool
|
||||||
|
|
||||||
getPgVersion :: AppState -> IO PgVersion
|
getPgVersion :: AppState -> IO PgVersion
|
||||||
getPgVersion = readIORef . statePgVersion
|
getPgVersion = readIORef . statePgVersion
|
||||||
@@ -109,11 +121,11 @@ getPgVersion = readIORef . statePgVersion
|
|||||||
putPgVersion :: AppState -> PgVersion -> IO ()
|
putPgVersion :: AppState -> PgVersion -> IO ()
|
||||||
putPgVersion = atomicWriteIORef . statePgVersion
|
putPgVersion = atomicWriteIORef . statePgVersion
|
||||||
|
|
||||||
getDbStructure :: AppState -> IO (Maybe DbStructure)
|
getSchemaCache :: AppState -> IO (Maybe SchemaCache)
|
||||||
getDbStructure = readIORef . stateDbStructure
|
getSchemaCache = readIORef . stateSchemaCache
|
||||||
|
|
||||||
putDbStructure :: AppState -> Maybe DbStructure -> IO ()
|
putSchemaCache :: AppState -> Maybe SchemaCache -> IO ()
|
||||||
putDbStructure appState = atomicWriteIORef (stateDbStructure appState)
|
putSchemaCache appState = atomicWriteIORef (stateSchemaCache appState)
|
||||||
|
|
||||||
getJsonDbS :: AppState -> IO ByteString
|
getJsonDbS :: AppState -> IO ByteString
|
||||||
getJsonDbS = readIORef . stateJsonDbS
|
getJsonDbS = readIORef . stateJsonDbS
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import Text.Heredoc (str)
|
|||||||
|
|
||||||
import PostgREST.AppState (AppState)
|
import PostgREST.AppState (AppState)
|
||||||
import PostgREST.Config (AppConfig (..))
|
import PostgREST.Config (AppConfig (..))
|
||||||
import PostgREST.DbStructure (queryDbStructure)
|
import PostgREST.SchemaCache (querySchemaCache)
|
||||||
import PostgREST.Version (prettyVersion)
|
import PostgREST.Version (prettyVersion)
|
||||||
import PostgREST.Workers (reReadConfig)
|
import PostgREST.Workers (reReadConfig)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ main installSignalHandlers runAppWithSocket CLI{cliCommand, cliPath} = do
|
|||||||
CmdDumpSchema -> putStrLn =<< dumpSchema appState
|
CmdDumpSchema -> putStrLn =<< dumpSchema appState
|
||||||
CmdRun -> App.run installSignalHandlers runAppWithSocket appState)
|
CmdRun -> App.run installSignalHandlers runAppWithSocket appState)
|
||||||
|
|
||||||
-- | Dump DbStructure schema to JSON
|
-- | Dump SchemaCache schema to JSON
|
||||||
dumpSchema :: AppState -> IO LBS.ByteString
|
dumpSchema :: AppState -> IO LBS.ByteString
|
||||||
dumpSchema appState = do
|
dumpSchema appState = do
|
||||||
AppConfig{..} <- AppState.getConfig appState
|
AppConfig{..} <- AppState.getConfig appState
|
||||||
@@ -56,7 +56,7 @@ dumpSchema appState = do
|
|||||||
let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
|
let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
|
||||||
AppState.usePool appState $
|
AppState.usePool appState $
|
||||||
transaction SQL.ReadCommitted SQL.Read $
|
transaction SQL.ReadCommitted SQL.Read $
|
||||||
queryDbStructure
|
querySchemaCache
|
||||||
(toList configDbSchemas)
|
(toList configDbSchemas)
|
||||||
configDbExtraSearchPath
|
configDbExtraSearchPath
|
||||||
configDbPreparedStatements
|
configDbPreparedStatements
|
||||||
@@ -64,7 +64,7 @@ dumpSchema appState = do
|
|||||||
Left e -> do
|
Left e -> do
|
||||||
hPutStrLn stderr $ "An error ocurred when loading the schema cache:\n" <> show e
|
hPutStrLn stderr $ "An error ocurred when loading the schema cache:\n" <> show e
|
||||||
exitFailure
|
exitFailure
|
||||||
Right dbStructure -> return $ JSON.encode dbStructure
|
Right sCache -> return $ JSON.encode sCache
|
||||||
|
|
||||||
-- | Command line interface options
|
-- | Command line interface options
|
||||||
data CLI = CLI
|
data CLI = CLI
|
||||||
@@ -148,8 +148,8 @@ exampleConfigFile =
|
|||||||
|## Number of open connections in the pool
|
|## Number of open connections in the pool
|
||||||
|db-pool = 10
|
|db-pool = 10
|
||||||
|
|
|
|
||||||
|## Time to live, in seconds, for an idle database pool connection
|
|## Time in seconds to wait to acquire a slot from the connection pool
|
||||||
|db-pool-timeout = 3600
|
|# db-pool-acquisition-timeout = 10
|
||||||
|
|
|
|
||||||
|## Stored proc to exec immediately after auth
|
|## Stored proc to exec immediately after auth
|
||||||
|# db-pre-request = "stored_proc_name"
|
|# db-pre-request = "stored_proc_name"
|
||||||
|
|||||||
+39
-40
@@ -46,7 +46,6 @@ import Data.List (lookup)
|
|||||||
import Data.List.NonEmpty (fromList, toList)
|
import Data.List.NonEmpty (fromList, toList)
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.Scientific (floatingOrInteger)
|
import Data.Scientific (floatingOrInteger)
|
||||||
import Data.Time.Clock (NominalDiffTime)
|
|
||||||
import Numeric (readOct, showOct)
|
import Numeric (readOct, showOct)
|
||||||
import System.Environment (getEnvironment)
|
import System.Environment (getEnvironment)
|
||||||
import System.Posix.Types (FileMode)
|
import System.Posix.Types (FileMode)
|
||||||
@@ -55,48 +54,48 @@ import PostgREST.Config.JSPath (JSPath, JSPathExp (..),
|
|||||||
dumpJSPath, pRoleClaimKey)
|
dumpJSPath, pRoleClaimKey)
|
||||||
import PostgREST.Config.Proxy (Proxy (..),
|
import PostgREST.Config.Proxy (Proxy (..),
|
||||||
isMalformedProxyUri, toURI)
|
isMalformedProxyUri, toURI)
|
||||||
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier, dumpQi,
|
|
||||||
toQi)
|
|
||||||
import PostgREST.MediaType (MediaType (..), toMime)
|
import PostgREST.MediaType (MediaType (..), toMime)
|
||||||
|
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi,
|
||||||
|
toQi)
|
||||||
|
|
||||||
import Protolude hiding (Proxy, toList)
|
import Protolude hiding (Proxy, toList)
|
||||||
|
|
||||||
|
|
||||||
data AppConfig = AppConfig
|
data AppConfig = AppConfig
|
||||||
{ configAppSettings :: [(Text, Text)]
|
{ configAppSettings :: [(Text, Text)]
|
||||||
, configDbAnonRole :: Maybe Text
|
, configDbAnonRole :: Maybe Text
|
||||||
, configDbChannel :: Text
|
, configDbChannel :: Text
|
||||||
, configDbChannelEnabled :: Bool
|
, configDbChannelEnabled :: Bool
|
||||||
, configDbExtraSearchPath :: [Text]
|
, configDbExtraSearchPath :: [Text]
|
||||||
, configDbMaxRows :: Maybe Integer
|
, configDbMaxRows :: Maybe Integer
|
||||||
, configDbPlanEnabled :: Bool
|
, configDbPlanEnabled :: Bool
|
||||||
, configDbPoolSize :: Int
|
, configDbPoolSize :: Int
|
||||||
, configDbPoolTimeout :: NominalDiffTime
|
, configDbPoolAcquisitionTimeout :: Maybe Int
|
||||||
, configDbPreRequest :: Maybe QualifiedIdentifier
|
, configDbPreRequest :: Maybe QualifiedIdentifier
|
||||||
, configDbPreparedStatements :: Bool
|
, configDbPreparedStatements :: Bool
|
||||||
, configDbRootSpec :: Maybe QualifiedIdentifier
|
, configDbRootSpec :: Maybe QualifiedIdentifier
|
||||||
, configDbSchemas :: NonEmpty Text
|
, configDbSchemas :: NonEmpty Text
|
||||||
, configDbConfig :: Bool
|
, configDbConfig :: Bool
|
||||||
, configDbTxAllowOverride :: Bool
|
, configDbTxAllowOverride :: Bool
|
||||||
, configDbTxRollbackAll :: Bool
|
, configDbTxRollbackAll :: Bool
|
||||||
, configDbUri :: Text
|
, configDbUri :: Text
|
||||||
, configDbUseLegacyGucs :: Bool
|
, configDbUseLegacyGucs :: Bool
|
||||||
, configFilePath :: Maybe FilePath
|
, configFilePath :: Maybe FilePath
|
||||||
, configJWKS :: Maybe JWKSet
|
, configJWKS :: Maybe JWKSet
|
||||||
, configJwtAudience :: Maybe StringOrURI
|
, configJwtAudience :: Maybe StringOrURI
|
||||||
, configJwtRoleClaimKey :: JSPath
|
, configJwtRoleClaimKey :: JSPath
|
||||||
, configJwtSecret :: Maybe BS.ByteString
|
, configJwtSecret :: Maybe BS.ByteString
|
||||||
, configJwtSecretIsBase64 :: Bool
|
, configJwtSecretIsBase64 :: Bool
|
||||||
, configLogLevel :: LogLevel
|
, configLogLevel :: LogLevel
|
||||||
, configOpenApiMode :: OpenAPIMode
|
, configOpenApiMode :: OpenAPIMode
|
||||||
, configOpenApiSecurityActive :: Bool
|
, configOpenApiSecurityActive :: Bool
|
||||||
, configOpenApiServerProxyUri :: Maybe Text
|
, configOpenApiServerProxyUri :: Maybe Text
|
||||||
, configRawMediaTypes :: [MediaType]
|
, configRawMediaTypes :: [MediaType]
|
||||||
, configServerHost :: Text
|
, configServerHost :: Text
|
||||||
, configServerPort :: Int
|
, configServerPort :: Int
|
||||||
, configServerUnixSocket :: Maybe FilePath
|
, configServerUnixSocket :: Maybe FilePath
|
||||||
, configServerUnixSocketMode :: FileMode
|
, configServerUnixSocketMode :: FileMode
|
||||||
, configAdminServerPort :: Maybe Int
|
, configAdminServerPort :: Maybe Int
|
||||||
}
|
}
|
||||||
|
|
||||||
data LogLevel = LogCrit | LogError | LogWarn | LogInfo
|
data LogLevel = LogCrit | LogError | LogWarn | LogInfo
|
||||||
@@ -131,7 +130,7 @@ toText conf =
|
|||||||
,("db-max-rows", maybe "\"\"" show . configDbMaxRows)
|
,("db-max-rows", maybe "\"\"" show . configDbMaxRows)
|
||||||
,("db-plan-enabled", T.toLower . show . configDbPlanEnabled)
|
,("db-plan-enabled", T.toLower . show . configDbPlanEnabled)
|
||||||
,("db-pool", show . configDbPoolSize)
|
,("db-pool", show . configDbPoolSize)
|
||||||
,("db-pool-timeout", show . floor . configDbPoolTimeout)
|
,("db-pool-acquisition-timeout", maybe "\"\"" show . configDbPoolAcquisitionTimeout)
|
||||||
,("db-pre-request", q . maybe mempty dumpQi . configDbPreRequest)
|
,("db-pre-request", q . maybe mempty dumpQi . configDbPreRequest)
|
||||||
,("db-prepared-statements", T.toLower . show . configDbPreparedStatements)
|
,("db-prepared-statements", T.toLower . show . configDbPreparedStatements)
|
||||||
,("db-root-spec", q . maybe mempty dumpQi . configDbRootSpec)
|
,("db-root-spec", q . maybe mempty dumpQi . configDbRootSpec)
|
||||||
@@ -220,7 +219,7 @@ parser optPath env dbSettings =
|
|||||||
(optInt "max-rows")
|
(optInt "max-rows")
|
||||||
<*> (fromMaybe False <$> optBool "db-plan-enabled")
|
<*> (fromMaybe False <$> optBool "db-plan-enabled")
|
||||||
<*> (fromMaybe 10 <$> optInt "db-pool")
|
<*> (fromMaybe 10 <$> optInt "db-pool")
|
||||||
<*> (fromIntegral . fromMaybe 3600 <$> optInt "db-pool-timeout")
|
<*> optInt "db-pool-acquisition-timeout"
|
||||||
<*> (fmap toQi <$> optWithAlias (optString "db-pre-request")
|
<*> (fmap toQi <$> optWithAlias (optString "db-pre-request")
|
||||||
(optString "pre-request"))
|
(optString "pre-request"))
|
||||||
<*> (fromMaybe True <$> optBool "db-prepared-statements")
|
<*> (fromMaybe True <$> optBool "db-prepared-statements")
|
||||||
@@ -356,7 +355,7 @@ parser optPath env dbSettings =
|
|||||||
let dbSettingName = T.pack $ dashToUnderscore <$> toS key in
|
let dbSettingName = T.pack $ dashToUnderscore <$> toS key in
|
||||||
if dbSettingName `notElem` [
|
if dbSettingName `notElem` [
|
||||||
"server_host", "server_port", "server_unix_socket", "server_unix_socket_mode", "admin_server_port", "log_level",
|
"server_host", "server_port", "server_unix_socket", "server_unix_socket_mode", "admin_server_port", "log_level",
|
||||||
"db_uri", "db_channel_enabled", "db_channel", "db_pool", "db_pool_timeout", "db_config"]
|
"db_uri", "db_channel_enabled", "db_channel", "db_pool", "db_pool_acquisition_timeout", "db_config"]
|
||||||
then lookup dbSettingName dbSettings
|
then lookup dbSettingName dbSettings
|
||||||
else Nothing
|
else Nothing
|
||||||
|
|
||||||
|
|||||||
+174
-52
@@ -17,6 +17,8 @@ module PostgREST.Error
|
|||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
|
import qualified Data.FuzzySet as Fuzzy
|
||||||
|
import qualified Data.HashMap.Strict as HM
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Text.Encoding as T
|
import qualified Data.Text.Encoding as T
|
||||||
import qualified Data.Text.Encoding.Error as T
|
import qualified Data.Text.Encoding.Error as T
|
||||||
@@ -29,17 +31,20 @@ import Network.Wai (Response, responseLBS)
|
|||||||
|
|
||||||
import Network.HTTP.Types.Header (Header)
|
import Network.HTTP.Types.Header (Header)
|
||||||
|
|
||||||
import PostgREST.MediaType (MediaType (..))
|
import PostgREST.ApiRequest.Types (ApiRequestError (..),
|
||||||
import qualified PostgREST.MediaType as MediaType
|
QPError (..),
|
||||||
import PostgREST.Request.Types (ApiRequestError (..),
|
RangeError (..))
|
||||||
QPError (..))
|
import PostgREST.MediaType (MediaType (..))
|
||||||
|
import qualified PostgREST.MediaType as MediaType
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
|
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
|
||||||
import PostgREST.DbStructure.Proc (ProcDescription (..),
|
Schema)
|
||||||
|
import PostgREST.SchemaCache.Proc (ProcDescription (..),
|
||||||
ProcParam (..))
|
ProcParam (..))
|
||||||
import PostgREST.DbStructure.Relationship (Cardinality (..),
|
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||||
Junction (..),
|
Junction (..),
|
||||||
Relationship (..))
|
Relationship (..),
|
||||||
|
RelationshipsMap)
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
|
|
||||||
@@ -56,11 +61,12 @@ class (JSON.ToJSON a) => PgrstError a where
|
|||||||
instance PgrstError ApiRequestError where
|
instance PgrstError ApiRequestError where
|
||||||
status AmbiguousRelBetween{} = HTTP.status300
|
status AmbiguousRelBetween{} = HTTP.status300
|
||||||
status AmbiguousRpc{} = HTTP.status300
|
status AmbiguousRpc{} = HTTP.status300
|
||||||
|
status BinaryFieldError{} = HTTP.status406
|
||||||
status MediaTypeError{} = HTTP.status415
|
status MediaTypeError{} = HTTP.status415
|
||||||
status InvalidBody{} = HTTP.status400
|
status InvalidBody{} = HTTP.status400
|
||||||
status InvalidFilters = HTTP.status405
|
status InvalidFilters = HTTP.status405
|
||||||
status InvalidRpcMethod{} = HTTP.status405
|
status InvalidRpcMethod{} = HTTP.status405
|
||||||
status InvalidRange = HTTP.status416
|
status InvalidRange{} = HTTP.status416
|
||||||
status NotFound = HTTP.status404
|
status NotFound = HTTP.status404
|
||||||
status NoRelBetween{} = HTTP.status400
|
status NoRelBetween{} = HTTP.status400
|
||||||
status NoRpc{} = HTTP.status404
|
status NoRpc{} = HTTP.status404
|
||||||
@@ -90,10 +96,13 @@ instance JSON.ToJSON ApiRequestError where
|
|||||||
"message" .= T.decodeUtf8 errorMessage,
|
"message" .= T.decodeUtf8 errorMessage,
|
||||||
"details" .= JSON.Null,
|
"details" .= JSON.Null,
|
||||||
"hint" .= JSON.Null]
|
"hint" .= JSON.Null]
|
||||||
toJSON InvalidRange = JSON.object [
|
toJSON (InvalidRange rangeError) = JSON.object [
|
||||||
"code" .= ApiRequestErrorCode03,
|
"code" .= ApiRequestErrorCode03,
|
||||||
"message" .= ("HTTP Range error" :: Text),
|
"message" .= ("Requested range not satisfiable" :: Text),
|
||||||
"details" .= JSON.Null,
|
"details" .= (case rangeError of
|
||||||
|
NegativeLimit -> "Limit should be greater than or equal to zero."
|
||||||
|
LowerGTUpper -> "The lower boundary must be lower than or equal to the upper boundary in the Range header."
|
||||||
|
OutOfBounds lower total -> "An offset of " <> lower <> " was requested, but there are only " <> total <> " rows."),
|
||||||
"hint" .= JSON.Null]
|
"hint" .= JSON.Null]
|
||||||
toJSON (ParseRequestError message details) = JSON.object [
|
toJSON (ParseRequestError message details) = JSON.object [
|
||||||
"code" .= ApiRequestErrorCode04,
|
"code" .= ApiRequestErrorCode04,
|
||||||
@@ -128,6 +137,12 @@ instance JSON.ToJSON ApiRequestError where
|
|||||||
"details" .= JSON.Null,
|
"details" .= JSON.Null,
|
||||||
"hint" .= ("Apply an 'order' using unique column(s)" :: Text)]
|
"hint" .= ("Apply an 'order' using unique column(s)" :: Text)]
|
||||||
|
|
||||||
|
toJSON (BinaryFieldError ct) = JSON.object [
|
||||||
|
"code" .= ApiRequestErrorCode13,
|
||||||
|
"message" .= ((T.decodeUtf8 (MediaType.toMime ct) <> " requested but more than one column was selected") :: Text),
|
||||||
|
"details" .= JSON.Null,
|
||||||
|
"hint" .= JSON.Null]
|
||||||
|
|
||||||
toJSON PutRangeNotAllowedError = JSON.object [
|
toJSON PutRangeNotAllowedError = JSON.object [
|
||||||
"code" .= ApiRequestErrorCode14,
|
"code" .= ApiRequestErrorCode14,
|
||||||
"message" .= ("Range header and limit/offset querystring parameters are not allowed for PUT" :: Text),
|
"message" .= ("Range header and limit/offset querystring parameters are not allowed for PUT" :: Text),
|
||||||
@@ -140,36 +155,143 @@ instance JSON.ToJSON ApiRequestError where
|
|||||||
"details" .= JSON.Null,
|
"details" .= JSON.Null,
|
||||||
"hint" .= JSON.Null]
|
"hint" .= JSON.Null]
|
||||||
|
|
||||||
toJSON (NoRelBetween parent child schema) = JSON.object [
|
toJSON (NoRelBetween parent child embedHint schema allRels) = JSON.object [
|
||||||
"code" .= SchemaCacheErrorCode00,
|
"code" .= SchemaCacheErrorCode00,
|
||||||
"message" .= ("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache" :: Text),
|
"message" .= ("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache" :: Text),
|
||||||
"details" .= JSON.Null,
|
"details" .= ("Searched for a foreign key relationship between '" <> parent <> "' and '" <> child <> maybe mempty ("' using the hint '" <>) embedHint <> "' in the schema '" <> schema <> "', but no matches were found."),
|
||||||
"hint" .= ("Verify that '" <> parent <> "' and '" <> child <> "' exist in the schema '" <> schema <> "' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache." :: Text)]
|
"hint" .= noRelBetweenHint parent child schema allRels]
|
||||||
|
|
||||||
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [
|
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [
|
||||||
"code" .= SchemaCacheErrorCode01,
|
"code" .= SchemaCacheErrorCode01,
|
||||||
"message" .= ("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'" :: Text),
|
"message" .= ("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'" :: Text),
|
||||||
"details" .= (compressedRel <$> rels),
|
"details" .= (compressedRel <$> rels),
|
||||||
"hint" .= ("Try changing '" <> child <> "' to one of the following: " <> relHint rels <> ". Find the desired relationship in the 'details' key." :: Text)]
|
"hint" .= ("Try changing '" <> child <> "' to one of the following: " <> relHint rels <> ". Find the desired relationship in the 'details' key." :: Text)]
|
||||||
toJSON (NoRpc schema procName argumentKeys hasPreferSingleObject contentType isInvPost) =
|
toJSON (NoRpc schema procName argumentKeys hasPreferSingleObject contentType isInvPost allProcs overloadedProcs) =
|
||||||
let prms = "(" <> T.intercalate ", " argumentKeys <> ")" in JSON.object [
|
let func = schema <> "." <> procName
|
||||||
|
prms = T.intercalate ", " argumentKeys
|
||||||
|
prmsMsg = "(" <> prms <> ")"
|
||||||
|
prmsDet = " with parameter" <> (if length argumentKeys > 1 then "s " else " ") <> prms
|
||||||
|
fmtPrms p = if null argumentKeys then " without parameters" else p
|
||||||
|
onlySingleParams = hasPreferSingleObject || (isInvPost && contentType `elem` [MTTextPlain, MTTextXML, MTOctetStream])
|
||||||
|
in JSON.object [
|
||||||
"code" .= SchemaCacheErrorCode02,
|
"code" .= SchemaCacheErrorCode02,
|
||||||
"message" .= ("Could not find the " <> schema <> "." <> procName <>
|
"message" .= ("Could not find the function " <> func <> (if onlySingleParams then "" else fmtPrms prmsMsg) <> " in the schema cache"),
|
||||||
|
"details" .= ("Searched for the function " <> func <>
|
||||||
(case (hasPreferSingleObject, isInvPost, contentType) of
|
(case (hasPreferSingleObject, isInvPost, contentType) of
|
||||||
(True, _, _) -> " function with a single json or jsonb parameter"
|
(True, _, _) -> " with a single json/jsonb parameter"
|
||||||
(_, True, MTTextPlain) -> " function with a single unnamed text parameter"
|
(_, True, MTTextPlain) -> " with a single unnamed text parameter"
|
||||||
(_, True, MTTextXML) -> " function with a single unnamed xml parameter"
|
(_, True, MTTextXML) -> " with a single unnamed xml parameter"
|
||||||
(_, True, MTOctetStream) -> " function with a single unnamed bytea parameter"
|
(_, True, MTOctetStream) -> " with a single unnamed bytea parameter"
|
||||||
(_, True, MTApplicationJSON) -> prms <> " function or the " <> schema <> "." <> procName <>" function with a single unnamed json or jsonb parameter"
|
(_, True, MTApplicationJSON) -> fmtPrms prmsDet <> " or with a single unnamed json/jsonb parameter"
|
||||||
_ -> prms <> " function") <>
|
_ -> fmtPrms prmsDet) <>
|
||||||
" in the schema cache"),
|
", but no matches were found in the schema cache."),
|
||||||
"details" .= JSON.Null,
|
-- The hint will be null in the case of single unnamed parameter functions
|
||||||
"hint" .= ("If a new function was created in the database with this name and parameters, try reloading the schema cache." :: Text)]
|
"hint" .= if onlySingleParams
|
||||||
|
then Nothing
|
||||||
|
else noRpcHint schema procName argumentKeys allProcs overloadedProcs ]
|
||||||
toJSON (AmbiguousRpc procs) = JSON.object [
|
toJSON (AmbiguousRpc procs) = JSON.object [
|
||||||
"code" .= SchemaCacheErrorCode03,
|
"code" .= SchemaCacheErrorCode03,
|
||||||
"message" .= ("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs]),
|
"message" .= ("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs]),
|
||||||
"details" .= JSON.Null,
|
"details" .= JSON.Null,
|
||||||
"hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)]
|
"hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)]
|
||||||
|
|
||||||
|
-- |
|
||||||
|
-- If no relationship is found then:
|
||||||
|
--
|
||||||
|
-- Looks for parent suggestions if parent not found
|
||||||
|
-- Looks for child suggestions if parent is found but child is not
|
||||||
|
-- Gives no suggestions if both are found (it means that there is a problem with the embed hint)
|
||||||
|
--
|
||||||
|
-- >>> :set -Wno-missing-fields
|
||||||
|
-- >>> let qi t = QualifiedIdentifier "api" t
|
||||||
|
-- >>> let rel ft = Relationship{relForeignTable = qi ft}
|
||||||
|
-- >>> let rels = HM.fromList [((qi "films", "api"), [rel "directors", rel "roles", rel "actors"])]
|
||||||
|
--
|
||||||
|
-- >>> noRelBetweenHint "film" "directors" "api" rels
|
||||||
|
-- Just "Perhaps you meant 'films' instead of 'film'."
|
||||||
|
--
|
||||||
|
-- >>> noRelBetweenHint "films" "role" "api" rels
|
||||||
|
-- Just "Perhaps you meant 'roles' instead of 'role'."
|
||||||
|
--
|
||||||
|
-- >>> noRelBetweenHint "films" "role" "api" rels
|
||||||
|
-- Just "Perhaps you meant 'roles' instead of 'role'."
|
||||||
|
--
|
||||||
|
-- >>> noRelBetweenHint "films" "actors" "api" rels
|
||||||
|
-- Nothing
|
||||||
|
--
|
||||||
|
-- >>> noRelBetweenHint "noclosealternative" "roles" "api" rels
|
||||||
|
-- Nothing
|
||||||
|
--
|
||||||
|
-- >>> noRelBetweenHint "films" "noclosealternative" "api" rels
|
||||||
|
-- Nothing
|
||||||
|
--
|
||||||
|
-- >>> noRelBetweenHint "films" "noclosealternative" "noclosealternative" rels
|
||||||
|
-- Nothing
|
||||||
|
--
|
||||||
|
noRelBetweenHint :: Text -> Text -> Schema -> RelationshipsMap -> Maybe Text
|
||||||
|
noRelBetweenHint parent child schema allRels = ("Perhaps you meant '" <>) <$>
|
||||||
|
if isJust findParent
|
||||||
|
then (<> "' instead of '" <> child <> "'.") <$> suggestChild
|
||||||
|
else (<> "' instead of '" <> parent <> "'.") <$> suggestParent
|
||||||
|
where
|
||||||
|
findParent = HM.lookup (QualifiedIdentifier schema parent, schema) allRels
|
||||||
|
fuzzySetOfParents = Fuzzy.fromList [qiName (fst p) | p <- HM.keys allRels, snd p == schema]
|
||||||
|
fuzzySetOfChildren = Fuzzy.fromList [qiName (relForeignTable c) | c <- fromMaybe [] findParent]
|
||||||
|
suggestParent = Fuzzy.getOne fuzzySetOfParents parent
|
||||||
|
-- Do not give suggestion if the child is found in the relations (weight = 1.0)
|
||||||
|
suggestChild = headMay [snd k | k <- Fuzzy.get fuzzySetOfChildren child, fst k < 1.0]
|
||||||
|
|
||||||
|
-- |
|
||||||
|
-- If no function is found with the given name, it does a fuzzy search to all the functions
|
||||||
|
-- in the same schema and shows the best match as hint.
|
||||||
|
--
|
||||||
|
-- >>> :set -Wno-missing-fields
|
||||||
|
-- >>> let procs = [(QualifiedIdentifier "api" "test"), (QualifiedIdentifier "api" "another"), (QualifiedIdentifier "private" "other")]
|
||||||
|
--
|
||||||
|
-- >>> noRpcHint "api" "testt" ["val", "param", "name"] procs []
|
||||||
|
-- Just "Perhaps you meant to call the function api.test"
|
||||||
|
--
|
||||||
|
-- >>> noRpcHint "api" "other" [] procs []
|
||||||
|
-- Just "Perhaps you meant to call the function api.another"
|
||||||
|
--
|
||||||
|
-- >>> noRpcHint "api" "noclosealternative" [] procs []
|
||||||
|
-- Nothing
|
||||||
|
--
|
||||||
|
-- If a function is found with the given name, but no params match, then it does a fuzzy search
|
||||||
|
-- to all the overloaded functions' params using the form "param1, param2, param3, ..."
|
||||||
|
-- and shows the best match as hint.
|
||||||
|
--
|
||||||
|
-- >>> let procsDesc = [ProcDescription {pdParams = [ProcParam {ppName="val"}, ProcParam {ppName="param"}, ProcParam {ppName="name"}]}, ProcDescription {pdParams = [ProcParam {ppName="id"}, ProcParam {ppName="attr"}]}]
|
||||||
|
--
|
||||||
|
-- >>> noRpcHint "api" "test" ["vall", "pqaram", "nam"] procs procsDesc
|
||||||
|
-- Just "Perhaps you meant to call the function api.test(name, param, val)"
|
||||||
|
--
|
||||||
|
-- >>> noRpcHint "api" "test" ["val", "param"] procs procsDesc
|
||||||
|
-- Just "Perhaps you meant to call the function api.test(name, param, val)"
|
||||||
|
--
|
||||||
|
-- >>> noRpcHint "api" "test" ["id", "attrs"] procs procsDesc
|
||||||
|
-- Just "Perhaps you meant to call the function api.test(attr, id)"
|
||||||
|
--
|
||||||
|
-- >>> noRpcHint "api" "test" ["id"] procs procsDesc
|
||||||
|
-- Just "Perhaps you meant to call the function api.test(attr, id)"
|
||||||
|
--
|
||||||
|
-- >>> noRpcHint "api" "test" ["noclosealternative"] procs procsDesc
|
||||||
|
-- Nothing
|
||||||
|
--
|
||||||
|
noRpcHint :: Text -> Text -> [Text] -> [QualifiedIdentifier] -> [ProcDescription] -> Maybe Text
|
||||||
|
noRpcHint schema procName params allProcs overloadedProcs =
|
||||||
|
fmap (("Perhaps you meant to call the function " <> schema <> ".") <>) possibleProcs
|
||||||
|
where
|
||||||
|
fuzzySetOfProcs = Fuzzy.fromList [qiName k | k <- allProcs, qiSchema k == schema]
|
||||||
|
fuzzySetOfParams = Fuzzy.fromList $ listToText <$> [[ppName prm | prm <- pdParams ov] | ov <- overloadedProcs]
|
||||||
|
-- Cannot do a fuzzy search like: Fuzzy.getOne [[Text]] [Text], where [[Text]] is the list of params for each
|
||||||
|
-- overloaded function and [Text] the given params. This converts those lists to text to make fuzzy search possible.
|
||||||
|
-- E.g. ["val", "param", "name"] into "(name, param, val)"
|
||||||
|
listToText = ("(" <>) . (<> ")") . T.intercalate ", " . sort
|
||||||
|
possibleProcs
|
||||||
|
| null overloadedProcs = Fuzzy.getOne fuzzySetOfProcs procName
|
||||||
|
| otherwise = (procName <>) <$> Fuzzy.getOne fuzzySetOfParams (listToText params)
|
||||||
|
|
||||||
compressedRel :: Relationship -> JSON.Value
|
compressedRel :: Relationship -> JSON.Value
|
||||||
-- An ambiguousness error cannot happen for computed relationships TODO refactor so this mempty is not needed
|
-- An ambiguousness error cannot happen for computed relationships TODO refactor so this mempty is not needed
|
||||||
compressedRel ComputedRelationship{} = JSON.object mempty
|
compressedRel ComputedRelationship{} = JSON.object mempty
|
||||||
@@ -182,7 +304,7 @@ compressedRel Relationship{..} =
|
|||||||
: case relCardinality of
|
: case relCardinality of
|
||||||
M2M Junction{..} -> [
|
M2M Junction{..} -> [
|
||||||
"cardinality" .= ("many-to-many" :: Text)
|
"cardinality" .= ("many-to-many" :: Text)
|
||||||
, "relationship" .= (qiName junTable <> " using " <> junConstraint1 <> fmtEls (snd <$> junColumns1) <> " and " <> junConstraint2 <> fmtEls (snd <$> junColumns2))
|
, "relationship" .= (qiName junTable <> " using " <> junConstraint1 <> fmtEls (snd <$> junColsSource) <> " and " <> junConstraint2 <> fmtEls (snd <$> junColsTarget))
|
||||||
]
|
]
|
||||||
M2O cons relColumns -> [
|
M2O cons relColumns -> [
|
||||||
"cardinality" .= ("many-to-one" :: Text)
|
"cardinality" .= ("many-to-one" :: Text)
|
||||||
@@ -225,22 +347,27 @@ instance JSON.ToJSON PgError where
|
|||||||
toJSON (PgError _ usageError) = JSON.toJSON usageError
|
toJSON (PgError _ usageError) = JSON.toJSON usageError
|
||||||
|
|
||||||
instance JSON.ToJSON SQL.UsageError where
|
instance JSON.ToJSON SQL.UsageError where
|
||||||
toJSON (SQL.ConnectionError e) = JSON.object [
|
toJSON (SQL.ConnectionUsageError e) = JSON.object [
|
||||||
"code" .= ConnectionErrorCode00,
|
"code" .= ConnectionErrorCode00,
|
||||||
"message" .= ("Database connection error. Retrying the connection." :: Text),
|
"message" .= ("Database connection error. Retrying the connection." :: Text),
|
||||||
"details" .= (T.decodeUtf8With T.lenientDecode $ fromMaybe "" e :: Text),
|
"details" .= (T.decodeUtf8With T.lenientDecode $ fromMaybe "" e :: Text),
|
||||||
"hint" .= JSON.Null]
|
"hint" .= JSON.Null]
|
||||||
toJSON (SQL.SessionError e) = JSON.toJSON e -- SQL.Error
|
toJSON (SQL.SessionUsageError e) = JSON.toJSON e -- SQL.Error
|
||||||
|
toJSON SQL.AcquisitionTimeoutUsageError = JSON.object [
|
||||||
|
"code" .= ConnectionErrorCode00,
|
||||||
|
"message" .= ("Timed out acquiring connection from connection pool." :: Text),
|
||||||
|
"details" .= JSON.Null,
|
||||||
|
"hint" .= JSON.Null]
|
||||||
|
|
||||||
instance JSON.ToJSON SQL.QueryError where
|
instance JSON.ToJSON SQL.QueryError where
|
||||||
toJSON (SQL.QueryError _ _ e) = JSON.toJSON e
|
toJSON (SQL.QueryError _ _ e) = JSON.toJSON e
|
||||||
|
|
||||||
instance JSON.ToJSON SQL.CommandError where
|
instance JSON.ToJSON SQL.CommandError where
|
||||||
toJSON (SQL.ResultError (SQL.ServerError c m d h)) = JSON.object [
|
toJSON (SQL.ResultError (SQL.ServerError c m d h _p)) = JSON.object [
|
||||||
"code" .= (T.decodeUtf8 c :: Text),
|
"code" .= (T.decodeUtf8 c :: Text),
|
||||||
"message" .= (T.decodeUtf8 m :: Text),
|
"message" .= (T.decodeUtf8 m :: Text),
|
||||||
"details" .= (fmap T.decodeUtf8 d :: Maybe Text),
|
"details" .= (fmap T.decodeUtf8 d :: Maybe Text),
|
||||||
"hint" .= (fmap T.decodeUtf8 h :: Maybe Text)]
|
"hint" .= (fmap T.decodeUtf8 h :: Maybe Text)]
|
||||||
|
|
||||||
toJSON (SQL.ResultError resultError) = JSON.object [
|
toJSON (SQL.ResultError resultError) = JSON.object [
|
||||||
"code" .= InternalErrorCode00,
|
"code" .= InternalErrorCode00,
|
||||||
@@ -254,12 +381,14 @@ instance JSON.ToJSON SQL.CommandError where
|
|||||||
"details" .= (fmap T.decodeUtf8 d :: Maybe Text),
|
"details" .= (fmap T.decodeUtf8 d :: Maybe Text),
|
||||||
"hint" .= JSON.Null]
|
"hint" .= JSON.Null]
|
||||||
|
|
||||||
|
|
||||||
pgErrorStatus :: Bool -> SQL.UsageError -> HTTP.Status
|
pgErrorStatus :: Bool -> SQL.UsageError -> HTTP.Status
|
||||||
pgErrorStatus _ (SQL.ConnectionError _) = HTTP.status503
|
pgErrorStatus _ (SQL.ConnectionUsageError _) = HTTP.status503
|
||||||
pgErrorStatus _ (SQL.SessionError (SQL.QueryError _ _ (SQL.ClientError _))) = HTTP.status503
|
pgErrorStatus _ SQL.AcquisitionTimeoutUsageError = HTTP.status504
|
||||||
pgErrorStatus authed (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError rError))) =
|
pgErrorStatus _ (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ClientError _))) = HTTP.status503
|
||||||
|
pgErrorStatus authed (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError rError))) =
|
||||||
case rError of
|
case rError of
|
||||||
(SQL.ServerError c m _ _) ->
|
(SQL.ServerError c m _ _ _) ->
|
||||||
case BS.unpack c of
|
case BS.unpack c of
|
||||||
'0':'8':_ -> HTTP.status503 -- pg connection err
|
'0':'8':_ -> HTTP.status503 -- pg connection err
|
||||||
'0':'9':_ -> HTTP.status500 -- triggered action exception
|
'0':'9':_ -> HTTP.status500 -- triggered action exception
|
||||||
@@ -296,25 +425,25 @@ pgErrorStatus authed (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError rErr
|
|||||||
_ -> HTTP.status500
|
_ -> HTTP.status500
|
||||||
|
|
||||||
checkIsFatal :: PgError -> Maybe Text
|
checkIsFatal :: PgError -> Maybe Text
|
||||||
checkIsFatal (PgError _ (SQL.ConnectionError e))
|
checkIsFatal (PgError _ (SQL.ConnectionUsageError e))
|
||||||
| isAuthFailureMessage = Just $ toS failureMessage
|
| isAuthFailureMessage = Just $ toS failureMessage
|
||||||
| otherwise = Nothing
|
| otherwise = Nothing
|
||||||
where isAuthFailureMessage = "FATAL: password authentication failed" `isPrefixOf` failureMessage
|
where isAuthFailureMessage = "FATAL: password authentication failed" `isInfixOf` failureMessage
|
||||||
failureMessage = BS.unpack $ fromMaybe mempty e
|
failureMessage = BS.unpack $ fromMaybe mempty e
|
||||||
checkIsFatal (PgError _ (SQL.SessionError (SQL.QueryError _ _ (SQL.ResultError serverError))))
|
checkIsFatal (PgError _ (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError serverError))))
|
||||||
= case serverError of
|
= case serverError of
|
||||||
-- Check for a syntax error (42601 is the pg code). This would mean the error is on our part somehow, so we treat it as fatal.
|
-- Check for a syntax error (42601 is the pg code). This would mean the error is on our part somehow, so we treat it as fatal.
|
||||||
SQL.ServerError "42601" _ _ _
|
SQL.ServerError "42601" _ _ _ _
|
||||||
-> Just "Hint: This is probably a bug in PostgREST, please report it at https://github.com/PostgREST/postgrest/issues"
|
-> Just "Hint: This is probably a bug in PostgREST, please report it at https://github.com/PostgREST/postgrest/issues"
|
||||||
-- Check for a "prepared statement <name> already exists" error (Code 42P05: duplicate_prepared_statement).
|
-- Check for a "prepared statement <name> already exists" error (Code 42P05: duplicate_prepared_statement).
|
||||||
-- This would mean that a connection pooler in transaction mode is being used
|
-- This would mean that a connection pooler in transaction mode is being used
|
||||||
-- while prepared statements are enabled in the PostgREST configuration,
|
-- while prepared statements are enabled in the PostgREST configuration,
|
||||||
-- both of which are incompatible with each other.
|
-- both of which are incompatible with each other.
|
||||||
SQL.ServerError "42P05" _ _ _
|
SQL.ServerError "42P05" _ _ _ _
|
||||||
-> Just "Hint: If you are using connection poolers in transaction mode, try setting db-prepared-statements to false."
|
-> Just "Hint: If you are using connection poolers in transaction mode, try setting db-prepared-statements to false."
|
||||||
-- Check for a "transaction blocks not allowed in statement pooling mode" error (Code 08P01: protocol_violation).
|
-- Check for a "transaction blocks not allowed in statement pooling mode" error (Code 08P01: protocol_violation).
|
||||||
-- This would mean that a connection pooler in statement mode is being used which is not supported in PostgREST.
|
-- This would mean that a connection pooler in statement mode is being used which is not supported in PostgREST.
|
||||||
SQL.ServerError "08P01" "transaction blocks not allowed in statement pooling mode" _ _
|
SQL.ServerError "08P01" "transaction blocks not allowed in statement pooling mode" _ _ _
|
||||||
-> Just "Hint: Connection poolers in statement mode are not supported."
|
-> Just "Hint: Connection poolers in statement mode are not supported."
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
checkIsFatal _ = Nothing
|
checkIsFatal _ = Nothing
|
||||||
@@ -322,7 +451,6 @@ checkIsFatal _ = Nothing
|
|||||||
|
|
||||||
data Error
|
data Error
|
||||||
= ApiRequestError ApiRequestError
|
= ApiRequestError ApiRequestError
|
||||||
| BinaryFieldError MediaType
|
|
||||||
| GucHeadersError
|
| GucHeadersError
|
||||||
| GucStatusError
|
| GucStatusError
|
||||||
| JwtTokenInvalid Text
|
| JwtTokenInvalid Text
|
||||||
@@ -336,7 +464,6 @@ data Error
|
|||||||
|
|
||||||
instance PgrstError Error where
|
instance PgrstError Error where
|
||||||
status (ApiRequestError err) = status err
|
status (ApiRequestError err) = status err
|
||||||
status BinaryFieldError{} = HTTP.status406
|
|
||||||
status GucHeadersError = HTTP.status500
|
status GucHeadersError = HTTP.status500
|
||||||
status GucStatusError = HTTP.status500
|
status GucStatusError = HTTP.status500
|
||||||
status JwtTokenInvalid{} = HTTP.unauthorized401
|
status JwtTokenInvalid{} = HTTP.unauthorized401
|
||||||
@@ -394,11 +521,6 @@ instance JSON.ToJSON Error where
|
|||||||
"message" .= ("response.status guc must be a valid status code" :: Text),
|
"message" .= ("response.status guc must be a valid status code" :: Text),
|
||||||
"details" .= JSON.Null,
|
"details" .= JSON.Null,
|
||||||
"hint" .= JSON.Null]
|
"hint" .= JSON.Null]
|
||||||
toJSON (BinaryFieldError ct) = JSON.object [
|
|
||||||
"code" .= ApiRequestErrorCode13,
|
|
||||||
"message" .= ((T.decodeUtf8 (MediaType.toMime ct) <> " requested but more than one column was selected") :: Text),
|
|
||||||
"details" .= JSON.Null,
|
|
||||||
"hint" .= JSON.Null]
|
|
||||||
|
|
||||||
toJSON PutMatchingPkError = JSON.object [
|
toJSON PutMatchingPkError = JSON.object [
|
||||||
"code" .= ApiRequestErrorCode15,
|
"code" .= ApiRequestErrorCode15,
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
{-|
|
|
||||||
Module : PostgREST.Middleware
|
|
||||||
Description : Sets CORS policy. Also the PostgreSQL GUCs, role, search_path and pre-request function.
|
|
||||||
-}
|
|
||||||
{-# LANGUAGE BlockArguments #-}
|
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
|
||||||
module PostgREST.Middleware
|
|
||||||
( runPgLocals
|
|
||||||
, optionalRollback
|
|
||||||
) where
|
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
|
||||||
import qualified Data.Aeson.Key as K
|
|
||||||
import qualified Data.Aeson.KeyMap as KM
|
|
||||||
import qualified Data.ByteString.Lazy.Char8 as LBS
|
|
||||||
import qualified Data.HashMap.Strict as HM
|
|
||||||
import qualified Data.Text.Encoding as T
|
|
||||||
import qualified Hasql.Decoders as HD
|
|
||||||
import qualified Hasql.DynamicStatements.Snippet as SQL hiding (sql)
|
|
||||||
import qualified Hasql.DynamicStatements.Statement as SQL
|
|
||||||
import qualified Hasql.Transaction as SQL
|
|
||||||
import qualified Network.Wai as Wai
|
|
||||||
|
|
||||||
|
|
||||||
import Control.Arrow ((***))
|
|
||||||
|
|
||||||
import Data.Scientific (FPFormat (..), formatScientific, isInteger)
|
|
||||||
|
|
||||||
import PostgREST.Config (AppConfig (..))
|
|
||||||
import PostgREST.Config.PgVersion (PgVersion (..), pgVersion140)
|
|
||||||
import PostgREST.Error (Error, errorResponseFor)
|
|
||||||
import PostgREST.GucHeader (addHeadersIfNotIncluded)
|
|
||||||
import PostgREST.Query.SqlFragment (fromQi, intercalateSnippet,
|
|
||||||
pgFmtIdentList, unknownEncoder)
|
|
||||||
import PostgREST.Request.ApiRequest (ApiRequest (..), Target (..))
|
|
||||||
|
|
||||||
import PostgREST.Request.Preferences
|
|
||||||
|
|
||||||
import Protolude
|
|
||||||
|
|
||||||
-- | Runs local(transaction scoped) GUCs for every request, plus the pre-request function
|
|
||||||
runPgLocals :: AppConfig -> KM.KeyMap JSON.Value -> Text ->
|
|
||||||
(ApiRequest -> ExceptT Error SQL.Transaction Wai.Response) ->
|
|
||||||
ApiRequest -> ByteString -> PgVersion -> ExceptT Error SQL.Transaction Wai.Response
|
|
||||||
runPgLocals conf claims role app req jsonDbS actualPgVersion = do
|
|
||||||
lift $ SQL.statement mempty $ SQL.dynamicallyParameterized
|
|
||||||
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql ++ specSql))
|
|
||||||
HD.noResult (configDbPreparedStatements conf)
|
|
||||||
lift $ traverse_ SQL.sql preReqSql
|
|
||||||
app req
|
|
||||||
where
|
|
||||||
methodSql = setConfigLocal mempty ("request.method", iMethod req)
|
|
||||||
pathSql = setConfigLocal mempty ("request.path", iPath req)
|
|
||||||
headersSql = if usesLegacyGucs
|
|
||||||
then setConfigLocal "request.header." <$> iHeaders req
|
|
||||||
else setConfigLocalJson "request.headers" (iHeaders req)
|
|
||||||
cookiesSql = if usesLegacyGucs
|
|
||||||
then setConfigLocal "request.cookie." <$> iCookies req
|
|
||||||
else setConfigLocalJson "request.cookies" (iCookies req)
|
|
||||||
claimsSql = if usesLegacyGucs
|
|
||||||
then setConfigLocal "request.jwt.claim." <$> [(toUtf8 $ K.toText c, toUtf8 $ unquoted v) | (c,v) <- KM.toList claims]
|
|
||||||
else [setConfigLocal mempty ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
|
|
||||||
roleSql = [setConfigLocal mempty ("role", toUtf8 role)]
|
|
||||||
appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings conf)
|
|
||||||
searchPathSql =
|
|
||||||
let schemas = pgFmtIdentList (iSchema req : configDbExtraSearchPath conf) in
|
|
||||||
setConfigLocal mempty ("search_path", schemas)
|
|
||||||
preReqSql = (\f -> "select " <> fromQi f <> "();") <$> configDbPreRequest conf
|
|
||||||
specSql = case iTarget req of
|
|
||||||
TargetProc{tpIsRootSpec=True} -> [setConfigLocal mempty ("request.spec", jsonDbS)]
|
|
||||||
_ -> mempty
|
|
||||||
usesLegacyGucs = configDbUseLegacyGucs conf && actualPgVersion < pgVersion140
|
|
||||||
|
|
||||||
unquoted :: JSON.Value -> Text
|
|
||||||
unquoted (JSON.String t) = t
|
|
||||||
unquoted (JSON.Number n) =
|
|
||||||
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
|
|
||||||
unquoted (JSON.Bool b) = show b
|
|
||||||
unquoted v = T.decodeUtf8 . LBS.toStrict $ JSON.encode v
|
|
||||||
|
|
||||||
-- | Set a transaction to eventually roll back if requested and set respective
|
|
||||||
-- headers on the response.
|
|
||||||
optionalRollback
|
|
||||||
:: AppConfig
|
|
||||||
-> ApiRequest
|
|
||||||
-> ExceptT Error SQL.Transaction Wai.Response
|
|
||||||
-> ExceptT Error SQL.Transaction Wai.Response
|
|
||||||
optionalRollback AppConfig{..} ApiRequest{..} transaction = do
|
|
||||||
resp <- catchError transaction $ return . errorResponseFor
|
|
||||||
when (shouldRollback || (configDbTxRollbackAll && not shouldCommit)) $ lift do
|
|
||||||
SQL.sql "SET CONSTRAINTS ALL IMMEDIATE"
|
|
||||||
SQL.condemn
|
|
||||||
return $ Wai.mapResponseHeaders preferenceApplied resp
|
|
||||||
where
|
|
||||||
shouldCommit =
|
|
||||||
configDbTxAllowOverride && iPreferTransaction == Just Commit
|
|
||||||
shouldRollback =
|
|
||||||
configDbTxAllowOverride && iPreferTransaction == Just Rollback
|
|
||||||
preferenceApplied
|
|
||||||
| shouldCommit =
|
|
||||||
addHeadersIfNotIncluded
|
|
||||||
[toAppliedHeader Commit]
|
|
||||||
| shouldRollback =
|
|
||||||
addHeadersIfNotIncluded
|
|
||||||
[toAppliedHeader Rollback]
|
|
||||||
| otherwise =
|
|
||||||
identity
|
|
||||||
|
|
||||||
-- | Do a pg set_config(setting, value, true) call. This is equivalent to a SET LOCAL.
|
|
||||||
setConfigLocal :: ByteString -> (ByteString, ByteString) -> SQL.Snippet
|
|
||||||
setConfigLocal prefix (k, v) =
|
|
||||||
"set_config(" <> unknownEncoder (prefix <> k) <> ", " <> unknownEncoder v <> ", true)"
|
|
||||||
|
|
||||||
-- | Starting from PostgreSQL v14, some characters are not allowed for config names (mostly affecting headers with "-").
|
|
||||||
-- | A JSON format string is used to avoid this problem. See https://github.com/PostgREST/postgrest/issues/1857
|
|
||||||
setConfigLocalJson :: ByteString -> [(ByteString, ByteString)] -> [SQL.Snippet]
|
|
||||||
setConfigLocalJson prefix keyVals = [setConfigLocal mempty (prefix, gucJsonVal keyVals)]
|
|
||||||
where
|
|
||||||
gucJsonVal :: [(ByteString, ByteString)] -> ByteString
|
|
||||||
gucJsonVal = LBS.toStrict . JSON.encode . HM.fromList . arrayByteStringToText
|
|
||||||
arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)]
|
|
||||||
arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{-|
|
{-|
|
||||||
Module : PostgREST.Request.DbRequestBuilder
|
Module : PostgREST.Plan
|
||||||
Description : PostgREST database request builder
|
Description : PostgREST Request Planner
|
||||||
|
|
||||||
This module is in charge of building an intermediate
|
This module is in charge of building an intermediate
|
||||||
representation(ReadRequest, MutateRequest) between the HTTP request and the
|
representation between the HTTP request and the
|
||||||
final resulting SQL query.
|
final resulting SQL query.
|
||||||
|
|
||||||
A query tree is built in case of resource embedding. By inferring the
|
A query tree is built in case of resource embedding. By inferring the
|
||||||
@@ -15,142 +15,172 @@ resource.
|
|||||||
{-# LANGUAGE NamedFieldPuns #-}
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
|
||||||
module PostgREST.Request.DbRequestBuilder
|
module PostgREST.Plan
|
||||||
( readRequest
|
( readPlan
|
||||||
, mutateRequest
|
, mutateReadPlan
|
||||||
, callRequest
|
, callReadPlan
|
||||||
|
, MutateReadPlan(..)
|
||||||
|
, CallReadPlan(..)
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Data.HashMap.Strict as HM
|
import qualified Data.HashMap.Strict as HM
|
||||||
import qualified Data.Set as S
|
import qualified Data.Set as S
|
||||||
|
import qualified PostgREST.SchemaCache.Proc as Proc
|
||||||
|
|
||||||
import Data.Either.Combinators (mapLeft)
|
import Data.Either.Combinators (mapLeft)
|
||||||
import Data.List (delete)
|
import Data.List (delete)
|
||||||
import Data.Tree (Tree (..))
|
import Data.Tree (Tree (..))
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
import PostgREST.ApiRequest (Action (..),
|
||||||
QualifiedIdentifier (..),
|
|
||||||
Schema, TableName)
|
|
||||||
import PostgREST.DbStructure.Proc (ProcDescription (..),
|
|
||||||
ProcParam (..),
|
|
||||||
procReturnsScalar)
|
|
||||||
import PostgREST.DbStructure.Relationship (Cardinality (..),
|
|
||||||
Junction (..),
|
|
||||||
Relationship (..),
|
|
||||||
RelationshipsMap)
|
|
||||||
import PostgREST.Error (Error (..))
|
|
||||||
import PostgREST.Query.SqlFragment (sourceCTEName)
|
|
||||||
import PostgREST.RangeQuery (NonnegRange, allRange,
|
|
||||||
restrictRange)
|
|
||||||
import PostgREST.Request.ApiRequest (Action (..),
|
|
||||||
ApiRequest (..),
|
ApiRequest (..),
|
||||||
InvokeMethod (..),
|
InvokeMethod (..),
|
||||||
Mutation (..),
|
Mutation (..),
|
||||||
Payload (..))
|
Payload (..))
|
||||||
|
import PostgREST.Config (AppConfig (..))
|
||||||
|
import PostgREST.Error (Error (..))
|
||||||
|
import PostgREST.Query.SqlFragment (sourceCTEName)
|
||||||
|
import PostgREST.RangeQuery (NonnegRange, allRange,
|
||||||
|
convertToLimitZeroRange,
|
||||||
|
restrictRange)
|
||||||
|
import PostgREST.SchemaCache (SchemaCache (..))
|
||||||
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
|
QualifiedIdentifier (..),
|
||||||
|
Schema)
|
||||||
|
import PostgREST.SchemaCache.Proc (ProcDescription (..),
|
||||||
|
ProcParam (..),
|
||||||
|
procReturnsScalar)
|
||||||
|
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||||
|
Junction (..),
|
||||||
|
Relationship (..),
|
||||||
|
RelationshipsMap)
|
||||||
|
import PostgREST.SchemaCache.Table (tablePKCols)
|
||||||
|
|
||||||
import PostgREST.Request.MutateQuery
|
import PostgREST.Plan.CallPlan
|
||||||
import PostgREST.Request.Preferences
|
import PostgREST.Plan.MutatePlan
|
||||||
import PostgREST.Request.ReadQuery as ReadQuery
|
import PostgREST.Plan.ReadPlan as ReadPlan
|
||||||
import PostgREST.Request.Types
|
|
||||||
|
|
||||||
import qualified PostgREST.Request.QueryParams as QueryParams
|
import PostgREST.ApiRequest.Preferences
|
||||||
|
import PostgREST.ApiRequest.Types
|
||||||
|
|
||||||
|
import qualified PostgREST.ApiRequest.QueryParams as QueryParams
|
||||||
|
|
||||||
import Protolude hiding (from)
|
import Protolude hiding (from)
|
||||||
|
|
||||||
-- | Builds the ReadRequest tree on a number of stages.
|
data MutateReadPlan = MutateReadPlan {
|
||||||
|
mrReadPlan :: ReadPlanTree
|
||||||
|
, mrMutatePlan :: MutatePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
data CallReadPlan = CallReadPlan {
|
||||||
|
crReadPlan :: ReadPlanTree
|
||||||
|
, crCallPlan :: CallPlan
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error MutateReadPlan
|
||||||
|
mutateReadPlan mutation apiRequest identifier conf sCache = do
|
||||||
|
rPlan <- readPlan identifier conf sCache apiRequest
|
||||||
|
mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan
|
||||||
|
return $ MutateReadPlan rPlan mPlan
|
||||||
|
|
||||||
|
callReadPlan :: ProcDescription -> AppConfig -> SchemaCache -> ApiRequest -> Either Error CallReadPlan
|
||||||
|
callReadPlan proc conf sCache apiRequest = do
|
||||||
|
let identifier = QualifiedIdentifier (pdSchema proc) (fromMaybe (pdName proc) $ Proc.procTableName proc)
|
||||||
|
rPlan <- readPlan identifier conf sCache apiRequest
|
||||||
|
let cPlan = callPlan proc apiRequest rPlan
|
||||||
|
return $ CallReadPlan rPlan cPlan
|
||||||
|
|
||||||
|
-- | Builds the ReadPlan tree on a number of stages.
|
||||||
-- | Adds filters, order, limits on its respective nodes.
|
-- | Adds filters, order, limits on its respective nodes.
|
||||||
-- | Adds joins conditions obtained from resource embedding.
|
-- | Adds joins conditions obtained from resource embedding.
|
||||||
readRequest :: Schema -> TableName -> Maybe Integer -> RelationshipsMap -> ApiRequest -> Either Error ReadRequest
|
readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
|
||||||
readRequest schema rootTableName maxRows allRels apiRequest =
|
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows} SchemaCache{dbRelationships} apiRequest =
|
||||||
mapLeft ApiRequestError $
|
mapLeft ApiRequestError $
|
||||||
treeRestrictRange maxRows (iAction apiRequest) =<<
|
treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
|
||||||
augmentRequestWithJoin schema allRels =<<
|
addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<<
|
||||||
addLogicTrees apiRequest =<<
|
addLogicTrees apiRequest =<<
|
||||||
addRanges apiRequest =<<
|
addRanges apiRequest =<<
|
||||||
addOrders apiRequest =<<
|
addOrders apiRequest =<<
|
||||||
addFilters apiRequest (initReadRequest rootName rootAlias qsSelect)
|
addFilters apiRequest (initReadRequest qi $ QueryParams.qsSelect $ iQueryParams apiRequest)
|
||||||
where
|
|
||||||
QueryParams.QueryParams{..} = iQueryParams apiRequest
|
|
||||||
(rootName, rootAlias) = case iAction apiRequest of
|
|
||||||
ActionRead _ -> (QualifiedIdentifier schema rootTableName, Nothing)
|
|
||||||
-- the CTE we use for non-read cases has a sourceCTEName(see Statements.hs) as the WITH name so we use the table name as an alias so findRel can find the right relationship
|
|
||||||
_ -> (QualifiedIdentifier mempty $ decodeUtf8 sourceCTEName, Just rootTableName)
|
|
||||||
|
|
||||||
-- Build the initial tree with a Depth attribute so when a self join occurs we
|
-- Build the initial read plan tree
|
||||||
-- can differentiate the parent and child tables by having an alias like
|
initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadPlanTree
|
||||||
-- "table_depth", this is related to
|
initReadRequest qi@QualifiedIdentifier{..} =
|
||||||
-- http://github.com/PostgREST/postgrest/issues/987.
|
foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi, relName=qiName, depth=rootDepth} []
|
||||||
initReadRequest :: QualifiedIdentifier -> Maybe Alias -> [Tree SelectItem] -> ReadRequest
|
|
||||||
initReadRequest rootQi rootAlias =
|
|
||||||
foldr (treeEntry rootDepth) initial
|
|
||||||
where
|
where
|
||||||
rootDepth = 0
|
rootDepth = 0
|
||||||
rootSchema = qiSchema rootQi
|
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing rootDepth
|
||||||
rootName = qiName rootQi
|
treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree
|
||||||
initial = Node (Select [] rootQi rootAlias [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, Nothing, rootDepth)) []
|
treeEntry depth (Node SelectRelation{..} fldForest) (Node q rForest) =
|
||||||
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
|
||||||
treeEntry depth (Node fld@((fn, _),_,alias, hint, joinType) fldForest) (Node (q, i) rForest) =
|
|
||||||
let nxtDepth = succ depth in
|
let nxtDepth = succ depth in
|
||||||
case fldForest of
|
Node q $
|
||||||
[] -> Node (q {select=fld:select q}, i) rForest
|
foldr (treeEntry nxtDepth)
|
||||||
_ -> Node (q, i) $
|
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relAlias=selAlias, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth} [])
|
||||||
foldr (treeEntry nxtDepth)
|
fldForest:rForest
|
||||||
(Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] allRange,
|
treeEntry _ (Node SelectField{..} _) (Node q rForest) = Node q{select=(selField, selCast, selAlias):select q} rForest
|
||||||
(fn, Nothing, alias, hint, joinType, nxtDepth)) [])
|
|
||||||
fldForest:rForest
|
|
||||||
|
|
||||||
-- | Enforces the `max-rows` config on the result
|
-- | Enforces the `max-rows` config on the result
|
||||||
treeRestrictRange :: Maybe Integer -> Action -> ReadRequest -> Either ApiRequestError ReadRequest
|
treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
treeRestrictRange _ (ActionMutate _) request = Right request
|
treeRestrictRange _ (ActionMutate _) request = Right request
|
||||||
treeRestrictRange maxRows _ request = pure $ nodeRestrictRange maxRows <$> request
|
treeRestrictRange maxRows _ request = pure $ nodeRestrictRange maxRows <$> request
|
||||||
where
|
where
|
||||||
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
nodeRestrictRange :: Maybe Integer -> ReadPlan -> ReadPlan
|
||||||
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
nodeRestrictRange m q@ReadPlan{range_=r} = q{range_= convertToLimitZeroRange r (restrictRange m r) }
|
||||||
|
|
||||||
augmentRequestWithJoin :: Schema -> RelationshipsMap -> ReadRequest -> Either ApiRequestError ReadRequest
|
-- add relationships to the nodes of the tree by traversing the forest while keeping track of the parentNode(https://stackoverflow.com/questions/22721064/get-the-parent-of-a-node-in-data-tree-haskell#comment34627048_22721064)
|
||||||
augmentRequestWithJoin schema allRels request =
|
-- also adds aliasing
|
||||||
addJoinConditions Nothing <$> addRels schema allRels Nothing request
|
addRels :: Schema -> Action -> RelationshipsMap -> Maybe ReadPlanTree -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
|
addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,relAlias,depth} forest) =
|
||||||
addRels :: Schema -> RelationshipsMap -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
||||||
addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, joinType, depth)) forest) =
|
|
||||||
case parentNode of
|
case parentNode of
|
||||||
Just (Node (Select{from=parentNodeQi, fromAlias=aliasQi}, _) _) ->
|
Just (Node ReadPlan{from=parentNodeQi, fromAlias=parentAlias} _) ->
|
||||||
let newFrom r = if qiName tbl == nodeName then relForeignTable r else tbl
|
let
|
||||||
newReadNode = (\r ->
|
newReadPlan = (\r ->
|
||||||
if not $ relIsSelf r -- add alias if self rel TODO consolidate aliasing in another function
|
let newAlias = Just (qiName (relForeignTable r) <> "_" <> show depth)
|
||||||
then (query{from=newFrom r}, (nodeName, Just r, alias, hint, joinType, depth))
|
aggAlias = qiName (relTable r) <> "_" <> fromMaybe relName relAlias <> "_" <> show depth in
|
||||||
else (query{from=newFrom r, fromAlias=Just (qiName (newFrom r) <> "_" <> show depth)}, (nodeName, Just r, alias, hint, joinType, depth))
|
case r of
|
||||||
) <$> rel
|
Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing
|
||||||
origin = if depth == 1 -- Only on depth 1 we check if the root(depth 0) has an alias so the sourceCTEName alias can be found as a relationship
|
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r}
|
||||||
then fromMaybe (qiName parentNodeQi) aliasQi
|
ComputedRelationship{} ->
|
||||||
else qiName parentNodeQi
|
rPlan{from=relForeignTable r, relToParent=Just r{relTable=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias}
|
||||||
rel = findRel schema allRels origin nodeName hint
|
_ ->
|
||||||
|
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, fromAlias=newAlias, relJoinConds=getJoinConditions newAlias parentAlias r}
|
||||||
|
) <$> rel
|
||||||
|
origin = if depth == 1 -- Only on depth 1 we check if the root(depth 0) has an alias so the sourceCTEName alias can be found as a relationship
|
||||||
|
then fromMaybe (qiName parentNodeQi) parentAlias
|
||||||
|
else qiName parentNodeQi
|
||||||
|
rel = findRel schema allRels origin relName relHint
|
||||||
in
|
in
|
||||||
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
|
Node <$> newReadPlan <*> (updateForest . hush $ Node <$> newReadPlan <*> pure forest)
|
||||||
_ ->
|
Nothing -> -- root case
|
||||||
let rn = (query, (nodeName, Nothing, alias, Nothing, joinType, depth)) in
|
let
|
||||||
Node rn <$> updateForest (Just $ Node rn forest)
|
newFrom = QualifiedIdentifier mempty $ decodeUtf8 sourceCTEName
|
||||||
|
newAlias = Just (qiName $ from rPlan)
|
||||||
|
newReadPlan = case action of
|
||||||
|
-- the CTE for mutations/rpc is used as WITH sourceCTEName .. SELECT .. FROM sourceCTEName as alias,
|
||||||
|
-- we use the table name as an alias so findRel can find the right relationship.
|
||||||
|
ActionMutate _ -> rPlan{from=newFrom, fromAlias=newAlias}
|
||||||
|
ActionInvoke _ -> rPlan{from=newFrom, fromAlias=newAlias}
|
||||||
|
_ -> rPlan
|
||||||
|
in
|
||||||
|
Node newReadPlan <$> updateForest (Just $ Node newReadPlan forest)
|
||||||
where
|
where
|
||||||
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
updateForest :: Maybe ReadPlanTree -> Either ApiRequestError [ReadPlanTree]
|
||||||
updateForest rq = addRels schema allRels rq `traverse` forest
|
updateForest rq = addRels schema action allRels rq `traverse` forest
|
||||||
|
|
||||||
-- applies aliasing to join conditions TODO refactor, this should go into the querybuilder module
|
getJoinConditions :: Maybe Alias -> Maybe Alias -> Relationship -> [JoinCondition]
|
||||||
addJoinConditions :: Maybe Alias -> ReadRequest -> ReadRequest
|
getJoinConditions _ _ ComputedRelationship{} = []
|
||||||
addJoinConditions _ (Node node@(Select{fromAlias=tblAlias}, (_, Nothing, _, _, _, _)) forest) = Node node (addJoinConditions tblAlias <$> forest)
|
getJoinConditions tblAlias parentAlias Relationship{relTable=qi,relForeignTable=fQi,relCardinality=card} =
|
||||||
addJoinConditions _ (Node node@(Select{fromAlias=tblAlias}, (_, Just ComputedRelationship{}, _, _, _, _)) forest) = Node node (addJoinConditions tblAlias <$> forest)
|
case card of
|
||||||
addJoinConditions previousAlias (Node (query@Select{fromAlias=tblAlias}, nodeProps@(_, Just (Relationship QualifiedIdentifier{qiSchema=tSchema, qiName=tN} QualifiedIdentifier{qiName=ftN} _ card _ _), _, _, _, _)) forest) =
|
M2M (Junction QualifiedIdentifier{qiName=jtn} _ _ jcols1 jcols2) ->
|
||||||
Node (query{joinConditions=joinConds}, nodeProps) (addJoinConditions tblAlias <$> forest)
|
(toJoinCondition Nothing Nothing ftN jtn <$> jcols2) ++ (toJoinCondition parentAlias tblAlias tN jtn <$> jcols1)
|
||||||
|
O2M _ cols ->
|
||||||
|
toJoinCondition parentAlias tblAlias tN ftN <$> cols
|
||||||
|
M2O _ cols ->
|
||||||
|
toJoinCondition parentAlias tblAlias tN ftN <$> cols
|
||||||
|
O2O _ cols ->
|
||||||
|
toJoinCondition parentAlias tblAlias tN ftN <$> cols
|
||||||
where
|
where
|
||||||
joinConds =
|
QualifiedIdentifier{qiSchema=tSchema, qiName=tN} = qi
|
||||||
case card of
|
QualifiedIdentifier{qiName=ftN} = fQi
|
||||||
M2M (Junction QualifiedIdentifier{qiName=jtn} _ _ jcols1 jcols2) ->
|
|
||||||
(toJoinCondition Nothing Nothing ftN jtn <$> jcols2) ++ (toJoinCondition previousAlias tblAlias tN jtn <$> jcols1)
|
|
||||||
O2M _ cols ->
|
|
||||||
toJoinCondition previousAlias tblAlias tN ftN <$> cols
|
|
||||||
M2O _ cols ->
|
|
||||||
toJoinCondition previousAlias tblAlias tN ftN <$> cols
|
|
||||||
O2O _ cols ->
|
|
||||||
toJoinCondition previousAlias tblAlias tN ftN <$> cols
|
|
||||||
toJoinCondition :: Maybe Alias -> Maybe Alias -> Text -> Text -> (FieldName, FieldName) -> JoinCondition
|
toJoinCondition :: Maybe Alias -> Maybe Alias -> Text -> Text -> (FieldName, FieldName) -> JoinCondition
|
||||||
toJoinCondition prAl newAl tb ftb (c, fc) =
|
toJoinCondition prAl newAl tb ftb (c, fc) =
|
||||||
let qi1 = QualifiedIdentifier tSchema ftb
|
let qi1 = QualifiedIdentifier tSchema ftb
|
||||||
@@ -166,7 +196,7 @@ addJoinConditions previousAlias (Node (query@Select{fromAlias=tblAlias}, nodePro
|
|||||||
findRel :: Schema -> RelationshipsMap -> NodeName -> NodeName -> Maybe Hint -> Either ApiRequestError Relationship
|
findRel :: Schema -> RelationshipsMap -> NodeName -> NodeName -> Maybe Hint -> Either ApiRequestError Relationship
|
||||||
findRel schema allRels origin target hint =
|
findRel schema allRels origin target hint =
|
||||||
case rels of
|
case rels of
|
||||||
[] -> Left $ NoRelBetween origin target schema
|
[] -> Left $ NoRelBetween origin target hint schema allRels
|
||||||
[r] -> Right r
|
[r] -> Right r
|
||||||
rs -> Left $ AmbiguousRelBetween origin target rs
|
rs -> Left $ AmbiguousRelBetween origin target rs
|
||||||
where
|
where
|
||||||
@@ -241,7 +271,7 @@ findRel schema allRels origin target hint =
|
|||||||
)
|
)
|
||||||
) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier schema origin, schema) allRels
|
) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier schema origin, schema) allRels
|
||||||
|
|
||||||
addFilters :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
addFilters :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addFilters ApiRequest{..} rReq =
|
addFilters ApiRequest{..} rReq =
|
||||||
foldr addFilterToNode (Right rReq) flts
|
foldr addFilterToNode (Right rReq) flts
|
||||||
where
|
where
|
||||||
@@ -254,11 +284,11 @@ addFilters ApiRequest{..} rReq =
|
|||||||
ActionRead _ -> qsFilters
|
ActionRead _ -> qsFilters
|
||||||
_ -> qsFiltersNotRoot
|
_ -> qsFiltersNotRoot
|
||||||
|
|
||||||
addFilterToNode :: (EmbedPath, Filter) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest
|
addFilterToNode :: (EmbedPath, Filter) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addFilterToNode =
|
addFilterToNode =
|
||||||
updateNode (\flt (Node (q@Select {where_=lf}, i) f) -> Node (q{ReadQuery.where_=addFilterToLogicForest flt lf}, i) f)
|
updateNode (\flt (Node q@ReadPlan{where_=lf} f) -> Node q{ReadPlan.where_=addFilterToLogicForest flt lf} f)
|
||||||
|
|
||||||
addOrders :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
addOrders :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addOrders ApiRequest{..} rReq =
|
addOrders ApiRequest{..} rReq =
|
||||||
case iAction of
|
case iAction of
|
||||||
ActionMutate _ -> Right rReq
|
ActionMutate _ -> Right rReq
|
||||||
@@ -266,10 +296,10 @@ addOrders ApiRequest{..} rReq =
|
|||||||
where
|
where
|
||||||
QueryParams.QueryParams{..} = iQueryParams
|
QueryParams.QueryParams{..} = iQueryParams
|
||||||
|
|
||||||
addOrderToNode :: (EmbedPath, [OrderTerm]) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest
|
addOrderToNode :: (EmbedPath, [OrderTerm]) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addOrderToNode = updateNode (\o (Node (q,i) f) -> Node (q{order=o}, i) f)
|
addOrderToNode = updateNode (\o (Node q f) -> Node q{order=o} f)
|
||||||
|
|
||||||
addRanges :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
addRanges :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addRanges ApiRequest{..} rReq =
|
addRanges ApiRequest{..} rReq =
|
||||||
case iAction of
|
case iAction of
|
||||||
ActionMutate _ -> Right rReq
|
ActionMutate _ -> Right rReq
|
||||||
@@ -278,20 +308,20 @@ addRanges ApiRequest{..} rReq =
|
|||||||
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
|
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
|
||||||
ranges = first QueryParamError $ QueryParams.pRequestRange `traverse` HM.toList iRange
|
ranges = first QueryParamError $ QueryParams.pRequestRange `traverse` HM.toList iRange
|
||||||
|
|
||||||
addRangeToNode :: (EmbedPath, NonnegRange) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest
|
addRangeToNode :: (EmbedPath, NonnegRange) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addRangeToNode = updateNode (\r (Node (q,i) f) -> Node (q{range_=r}, i) f)
|
addRangeToNode = updateNode (\r (Node q f) -> Node q{range_=r} f)
|
||||||
|
|
||||||
addLogicTrees :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
addLogicTrees :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addLogicTrees ApiRequest{..} rReq =
|
addLogicTrees ApiRequest{..} rReq =
|
||||||
foldr addLogicTreeToNode (Right rReq) qsLogic
|
foldr addLogicTreeToNode (Right rReq) qsLogic
|
||||||
where
|
where
|
||||||
QueryParams.QueryParams{..} = iQueryParams
|
QueryParams.QueryParams{..} = iQueryParams
|
||||||
|
|
||||||
addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest
|
addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addLogicTreeToNode = updateNode (\t (Node (q@Select{where_=lf},i) f) -> Node (q{ReadQuery.where_=t:lf}, i) f)
|
addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{where_=lf} f) -> Node q{ReadPlan.where_=t:lf} f)
|
||||||
|
|
||||||
-- Find a Node of the Tree and apply a function to it
|
-- Find a Node of the Tree and apply a function to it
|
||||||
updateNode :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> Either ApiRequestError ReadRequest -> Either ApiRequestError ReadRequest
|
updateNode :: (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
updateNode f ([], a) rr = f a <$> rr
|
updateNode f ([], a) rr = f a <$> rr
|
||||||
updateNode _ _ (Left e) = Left e
|
updateNode _ _ (Left e) = Left e
|
||||||
updateNode f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) =
|
updateNode f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) =
|
||||||
@@ -301,14 +331,14 @@ updateNode f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) =
|
|||||||
(\node -> Node rootNode $ node : delete target forest) <$>
|
(\node -> Node rootNode $ node : delete target forest) <$>
|
||||||
updateNode f (remainingPath, a) (Right target)
|
updateNode f (remainingPath, a) (Right target)
|
||||||
where
|
where
|
||||||
findNode :: Maybe ReadRequest
|
findNode :: Maybe ReadPlanTree
|
||||||
findNode = find (\(Node (_,(nodeName,_,alias,_,_, _)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
|
findNode = find (\(Node ReadPlan{relName, relAlias} _) -> relName == targetNodeName || relAlias == Just targetNodeName) forest
|
||||||
|
|
||||||
mutateRequest :: Mutation -> Schema -> TableName -> ApiRequest -> [FieldName] -> ReadRequest -> Either Error MutateRequest
|
mutatePlan :: Mutation -> QualifiedIdentifier -> ApiRequest -> SchemaCache -> ReadPlanTree -> Either Error MutatePlan
|
||||||
mutateRequest mutation schema tName ApiRequest{..} pkCols readReq = mapLeft ApiRequestError $
|
mutatePlan mutation qi ApiRequest{..} sCache readReq = mapLeft ApiRequestError $
|
||||||
case mutation of
|
case mutation of
|
||||||
MutationCreate ->
|
MutationCreate ->
|
||||||
Right $ Insert qi iColumns body ((,) <$> iPreferResolution <*> Just confCols) [] returnings
|
Right $ Insert qi iColumns body ((,) <$> iPreferResolution <*> Just confCols) [] returnings pkCols
|
||||||
MutationUpdate -> Right $ Update qi iColumns body combinedLogic iTopLevelRange rootOrder returnings
|
MutationUpdate -> Right $ Update qi iColumns body combinedLogic iTopLevelRange rootOrder returnings
|
||||||
MutationSingleUpsert ->
|
MutationSingleUpsert ->
|
||||||
if null qsLogic &&
|
if null qsLogic &&
|
||||||
@@ -317,31 +347,31 @@ mutateRequest mutation schema tName ApiRequest{..} pkCols readReq = mapLeft ApiR
|
|||||||
all (\case
|
all (\case
|
||||||
Filter _ (OpExpr False (Op OpEqual _)) -> True
|
Filter _ (OpExpr False (Op OpEqual _)) -> True
|
||||||
_ -> False) qsFiltersRoot
|
_ -> False) qsFiltersRoot
|
||||||
then Right $ Insert qi iColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings
|
then Right $ Insert qi iColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings mempty
|
||||||
else
|
else
|
||||||
Left InvalidFilters
|
Left InvalidFilters
|
||||||
MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings
|
MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings
|
||||||
where
|
where
|
||||||
confCols = fromMaybe pkCols qsOnConflict
|
confCols = fromMaybe pkCols qsOnConflict
|
||||||
QueryParams.QueryParams{..} = iQueryParams
|
QueryParams.QueryParams{..} = iQueryParams
|
||||||
qi = QualifiedIdentifier schema tName
|
|
||||||
returnings =
|
returnings =
|
||||||
if iPreferRepresentation == None
|
if iPreferRepresentation == None
|
||||||
then []
|
then []
|
||||||
else returningCols readReq pkCols
|
else inferColsEmbedNeeds readReq pkCols
|
||||||
|
pkCols = maybe mempty tablePKCols $ HM.lookup qi $ dbTables sCache
|
||||||
logic = map snd qsLogic
|
logic = map snd qsLogic
|
||||||
rootOrder = maybe [] snd $ find (\(x, _) -> null x) qsOrder
|
rootOrder = maybe [] snd $ find (\(x, _) -> null x) qsOrder
|
||||||
combinedLogic = foldr addFilterToLogicForest logic qsFiltersRoot
|
combinedLogic = foldr addFilterToLogicForest logic qsFiltersRoot
|
||||||
body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates)
|
body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates)
|
||||||
|
|
||||||
callRequest :: ProcDescription -> ApiRequest -> ReadRequest -> CallRequest
|
callPlan :: ProcDescription -> ApiRequest -> ReadPlanTree -> CallPlan
|
||||||
callRequest proc apiReq readReq = FunctionCall {
|
callPlan proc apiReq readReq = FunctionCall {
|
||||||
funCQi = QualifiedIdentifier (pdSchema proc) (pdName proc)
|
funCQi = QualifiedIdentifier (pdSchema proc) (pdName proc)
|
||||||
, funCParams = callParams
|
, funCParams = callParams
|
||||||
, funCArgs = payRaw <$> iPayload apiReq
|
, funCArgs = payRaw <$> iPayload apiReq
|
||||||
, funCScalar = procReturnsScalar proc
|
, funCScalar = procReturnsScalar proc
|
||||||
, funCMultipleCall = iPreferParameters apiReq == Just MultipleObjects
|
, funCMultipleCall = iPreferParameters apiReq == Just MultipleObjects
|
||||||
, funCReturning = returningCols readReq []
|
, funCReturning = inferColsEmbedNeeds readReq []
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
paramsAsSingleObject = iPreferParameters apiReq == Just SingleObject
|
paramsAsSingleObject = iPreferParameters apiReq == Just SingleObject
|
||||||
@@ -352,32 +382,40 @@ callRequest proc apiReq readReq = FunctionCall {
|
|||||||
prms -> KeyParams $ specifiedParams prms
|
prms -> KeyParams $ specifiedParams prms
|
||||||
specifiedParams = filter (\x -> ppName x `S.member` iColumns apiReq)
|
specifiedParams = filter (\x -> ppName x `S.member` iColumns apiReq)
|
||||||
|
|
||||||
returningCols :: ReadRequest -> [FieldName] -> [FieldName]
|
-- | Infers the columns needed for an embed to be successful after a mutation or a function call.
|
||||||
returningCols rr@(Node _ forest) pkCols
|
inferColsEmbedNeeds :: ReadPlanTree -> [FieldName] -> [FieldName]
|
||||||
|
inferColsEmbedNeeds (Node ReadPlan{select} forest) pkCols
|
||||||
-- if * is part of the select, we must not add pk or fk columns manually -
|
-- if * is part of the select, we must not add pk or fk columns manually -
|
||||||
-- otherwise those would be selected and output twice
|
-- otherwise those would be selected and output twice
|
||||||
| "*" `elem` fldNames = ["*"]
|
| "*" `elem` fldNames = ["*"]
|
||||||
| otherwise = returnings
|
| otherwise = returnings
|
||||||
where
|
where
|
||||||
fldNames = fstFieldNames rr
|
fldNames = (\((fld, _), _, _) -> fld) <$> select
|
||||||
-- Without fkCols, when a mutateRequest to
|
-- Without fkCols, when a mutatePlan to
|
||||||
-- /projects?select=name,clients(name) occurs, the RETURNING SQL part would
|
-- /projects?select=name,clients(name) occurs, the RETURNING SQL part would
|
||||||
-- be `RETURNING name`(see QueryBuilder). This would make the embedding
|
-- be `RETURNING name`(see QueryBuilder). This would make the embedding
|
||||||
-- fail because the following JOIN would need the "client_id" column from
|
-- fail because the following JOIN would need the "client_id" column from
|
||||||
-- projects. So this adds the foreign key columns to ensure the embedding
|
-- projects. So this adds the foreign key columns to ensure the embedding
|
||||||
-- succeeds, result would be `RETURNING name, client_id`.
|
-- succeeds, result would be `RETURNING name, client_id`.
|
||||||
fkCols = concat $ mapMaybe (\case
|
fkCols = concat $ mapMaybe (\case
|
||||||
Node (_, (_, Just Relationship{relCardinality=O2M _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols
|
Node ReadPlan{relToParent=Just Relationship{relCardinality=O2M _ cols}} _ ->
|
||||||
Node (_, (_, Just Relationship{relCardinality=M2O _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols
|
Just $ fst <$> cols
|
||||||
Node (_, (_, Just Relationship{relCardinality=O2O _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols
|
Node ReadPlan{relToParent=Just Relationship{relCardinality=M2O _ cols}} _ ->
|
||||||
Node (_, (_, Just Relationship{relCardinality=M2M Junction{junColumns1, junColumns2}}, _, _, _, _)) _ -> Just $ (fst <$> junColumns1) ++ (fst <$> junColumns2)
|
Just $ fst <$> cols
|
||||||
_ -> Nothing
|
Node ReadPlan{relToParent=Just Relationship{relCardinality=O2O _ cols}} _ ->
|
||||||
|
Just $ fst <$> cols
|
||||||
|
Node ReadPlan{relToParent=Just Relationship{relCardinality=M2M Junction{junColsSource=cols}}} _ ->
|
||||||
|
Just $ fst <$> cols
|
||||||
|
Node ReadPlan{relToParent=Just ComputedRelationship{}} _ ->
|
||||||
|
Nothing
|
||||||
|
Node ReadPlan{relToParent=Nothing} _ ->
|
||||||
|
Nothing
|
||||||
) forest
|
) forest
|
||||||
hasComputedRel = isJust $ find (\case
|
hasComputedRel = isJust $ find (\case
|
||||||
Node (_, (_, Just ComputedRelationship{}, _, _, _, _)) _ -> True
|
Node ReadPlan{relToParent=Just ComputedRelationship{}} _ -> True
|
||||||
_ -> False
|
_ -> False
|
||||||
) forest
|
) forest
|
||||||
-- However if the "client_id" is present, e.g. mutateRequest to
|
-- However if the "client_id" is present, e.g. mutatePlan to
|
||||||
-- /projects?select=client_id,name,clients(name) we would get `RETURNING
|
-- /projects?select=client_id,name,clients(name) we would get `RETURNING
|
||||||
-- client_id, name, client_id` and then we would produce the "column
|
-- client_id, name, client_id` and then we would produce the "column
|
||||||
-- reference \"client_id\" is ambiguous" error from PostgreSQL. So we
|
-- reference \"client_id\" is ambiguous" error from PostgreSQL. So we
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module PostgREST.Plan.CallPlan
|
||||||
|
( CallPlan(..)
|
||||||
|
, CallParams(..)
|
||||||
|
)
|
||||||
|
where
|
||||||
|
|
||||||
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
|
QualifiedIdentifier)
|
||||||
|
import PostgREST.SchemaCache.Proc (ProcParam (..))
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
|
||||||
|
data CallPlan = FunctionCall
|
||||||
|
{ funCQi :: QualifiedIdentifier
|
||||||
|
, funCParams :: CallParams
|
||||||
|
, funCArgs :: Maybe LBS.ByteString
|
||||||
|
, funCScalar :: Bool
|
||||||
|
, funCMultipleCall :: Bool
|
||||||
|
, funCReturning :: [FieldName]
|
||||||
|
}
|
||||||
|
|
||||||
|
data CallParams
|
||||||
|
= KeyParams [ProcParam] -- ^ Call with key params: func(a := val1, b:= val2)
|
||||||
|
| OnePosParam ProcParam -- ^ Call with positional params(only one supported): func(val)
|
||||||
@@ -1,23 +1,20 @@
|
|||||||
module PostgREST.Request.MutateQuery
|
module PostgREST.Plan.MutatePlan
|
||||||
( MutateQuery(..)
|
( MutatePlan(..)
|
||||||
, MutateRequest
|
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
import qualified Data.Set as S
|
import qualified Data.Set as S
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
import PostgREST.ApiRequest.Preferences (PreferResolution)
|
||||||
QualifiedIdentifier)
|
import PostgREST.ApiRequest.Types (LogicTree, OrderTerm)
|
||||||
import PostgREST.RangeQuery (NonnegRange)
|
import PostgREST.RangeQuery (NonnegRange)
|
||||||
import PostgREST.Request.Preferences (PreferResolution)
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
import PostgREST.Request.Types (LogicTree, OrderTerm)
|
QualifiedIdentifier)
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
type MutateRequest = MutateQuery
|
data MutatePlan
|
||||||
|
|
||||||
data MutateQuery
|
|
||||||
= Insert
|
= Insert
|
||||||
{ in_ :: QualifiedIdentifier
|
{ in_ :: QualifiedIdentifier
|
||||||
, insCols :: S.Set FieldName
|
, insCols :: S.Set FieldName
|
||||||
@@ -25,6 +22,7 @@ data MutateQuery
|
|||||||
, onConflict :: Maybe (PreferResolution, [FieldName])
|
, onConflict :: Maybe (PreferResolution, [FieldName])
|
||||||
, where_ :: [LogicTree]
|
, where_ :: [LogicTree]
|
||||||
, returning :: [FieldName]
|
, returning :: [FieldName]
|
||||||
|
, insPkCols :: [FieldName]
|
||||||
}
|
}
|
||||||
| Update
|
| Update
|
||||||
{ in_ :: QualifiedIdentifier
|
{ in_ :: QualifiedIdentifier
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
module PostgREST.Plan.ReadPlan
|
||||||
|
( ReadPlanTree
|
||||||
|
, ReadPlan(..)
|
||||||
|
, JoinCondition(..)
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Data.Tree (Tree (..))
|
||||||
|
|
||||||
|
import PostgREST.ApiRequest.Types (Alias, Cast, Depth, Field,
|
||||||
|
Hint, JoinType, LogicTree,
|
||||||
|
NodeName, OrderTerm)
|
||||||
|
import PostgREST.RangeQuery (NonnegRange)
|
||||||
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
|
QualifiedIdentifier)
|
||||||
|
import PostgREST.SchemaCache.Relationship (Relationship)
|
||||||
|
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
|
||||||
|
type ReadPlanTree = Tree ReadPlan
|
||||||
|
|
||||||
|
data JoinCondition =
|
||||||
|
JoinCondition
|
||||||
|
(QualifiedIdentifier, FieldName)
|
||||||
|
(QualifiedIdentifier, FieldName)
|
||||||
|
deriving (Eq)
|
||||||
|
|
||||||
|
data ReadPlan = ReadPlan
|
||||||
|
{ select :: [(Field, Maybe Cast, Maybe Alias)]
|
||||||
|
, from :: QualifiedIdentifier
|
||||||
|
, fromAlias :: Maybe Alias
|
||||||
|
, where_ :: [LogicTree]
|
||||||
|
, order :: [OrderTerm]
|
||||||
|
, range_ :: NonnegRange
|
||||||
|
, relName :: NodeName
|
||||||
|
, relToParent :: Maybe Relationship
|
||||||
|
, relJoinConds :: [JoinCondition]
|
||||||
|
, relAlias :: Maybe Alias
|
||||||
|
, relAggAlias :: Alias
|
||||||
|
, relHint :: Maybe Hint
|
||||||
|
, relJoinType :: Maybe JoinType
|
||||||
|
, depth :: Depth
|
||||||
|
-- ^ used for aliasing
|
||||||
|
}
|
||||||
|
deriving (Eq)
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
module PostgREST.Query
|
||||||
|
( createQuery
|
||||||
|
, deleteQuery
|
||||||
|
, invokeQuery
|
||||||
|
, openApiQuery
|
||||||
|
, readQuery
|
||||||
|
, singleUpsertQuery
|
||||||
|
, txMode
|
||||||
|
, updateQuery
|
||||||
|
, setPgLocals
|
||||||
|
, DbHandler
|
||||||
|
) where
|
||||||
|
|
||||||
|
import qualified Data.Aeson as JSON
|
||||||
|
import qualified Data.Aeson.Key as K
|
||||||
|
import qualified Data.Aeson.KeyMap as KM
|
||||||
|
import qualified Data.ByteString.Lazy.Char8 as LBS
|
||||||
|
import qualified Data.HashMap.Strict as HM
|
||||||
|
import qualified Data.Set as S
|
||||||
|
import qualified Data.Text.Encoding as T
|
||||||
|
import qualified Hasql.Decoders as HD
|
||||||
|
import qualified Hasql.DynamicStatements.Snippet as SQL (Snippet)
|
||||||
|
import qualified Hasql.DynamicStatements.Statement as SQL
|
||||||
|
import qualified Hasql.Transaction as SQL
|
||||||
|
import qualified Hasql.Transaction.Sessions as SQL
|
||||||
|
|
||||||
|
import qualified PostgREST.Error as Error
|
||||||
|
import qualified PostgREST.Query.QueryBuilder as QueryBuilder
|
||||||
|
import qualified PostgREST.Query.Statements as Statements
|
||||||
|
import qualified PostgREST.RangeQuery as RangeQuery
|
||||||
|
import qualified PostgREST.SchemaCache as SchemaCache
|
||||||
|
import qualified PostgREST.SchemaCache.Proc as Proc
|
||||||
|
|
||||||
|
import Data.Scientific (FPFormat (..), formatScientific, isInteger)
|
||||||
|
|
||||||
|
import PostgREST.ApiRequest (Action (..),
|
||||||
|
ApiRequest (..),
|
||||||
|
InvokeMethod (..),
|
||||||
|
Target (..))
|
||||||
|
import PostgREST.ApiRequest.Preferences (PreferCount (..),
|
||||||
|
PreferParameters (..),
|
||||||
|
PreferTransaction (..),
|
||||||
|
shouldCount)
|
||||||
|
import PostgREST.Config (AppConfig (..),
|
||||||
|
OpenAPIMode (..))
|
||||||
|
import PostgREST.Config.PgVersion (PgVersion (..),
|
||||||
|
pgVersion140)
|
||||||
|
import PostgREST.Error (Error)
|
||||||
|
import PostgREST.MediaType (MediaType (..))
|
||||||
|
import PostgREST.Plan (CallReadPlan (..),
|
||||||
|
MutateReadPlan (..))
|
||||||
|
import PostgREST.Plan.MutatePlan (MutatePlan (..))
|
||||||
|
import PostgREST.Plan.ReadPlan (ReadPlanTree)
|
||||||
|
import PostgREST.Query.SqlFragment (fromQi, intercalateSnippet,
|
||||||
|
pgFmtIdentList,
|
||||||
|
setConfigLocal,
|
||||||
|
setConfigLocalJson)
|
||||||
|
import PostgREST.Query.Statements (ResultSet (..))
|
||||||
|
import PostgREST.SchemaCache (SchemaCache (..))
|
||||||
|
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
|
||||||
|
Schema)
|
||||||
|
import PostgREST.SchemaCache.Proc (ProcDescription (..),
|
||||||
|
ProcVolatility (..),
|
||||||
|
ProcsMap)
|
||||||
|
import PostgREST.SchemaCache.Table (TablesMap)
|
||||||
|
|
||||||
|
import Protolude hiding (Handler)
|
||||||
|
|
||||||
|
type DbHandler = ExceptT Error SQL.Transaction
|
||||||
|
|
||||||
|
readQuery :: ReadPlanTree -> AppConfig -> ApiRequest -> DbHandler ResultSet
|
||||||
|
readQuery req conf@AppConfig{..} apiReq@ApiRequest{..} = do
|
||||||
|
let countQuery = QueryBuilder.readPlanToCountQuery req
|
||||||
|
resultSet <-
|
||||||
|
lift . SQL.statement mempty $
|
||||||
|
Statements.prepareRead
|
||||||
|
(QueryBuilder.readPlanToQuery req)
|
||||||
|
(if iPreferCount == Just EstimatedCount then
|
||||||
|
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
|
||||||
|
QueryBuilder.limitedQuery countQuery ((+ 1) <$> configDbMaxRows)
|
||||||
|
else
|
||||||
|
countQuery
|
||||||
|
)
|
||||||
|
(shouldCount iPreferCount)
|
||||||
|
iAcceptMediaType
|
||||||
|
iBinaryField
|
||||||
|
configDbPreparedStatements
|
||||||
|
failNotSingular iAcceptMediaType resultSet
|
||||||
|
optionalRollback conf apiReq
|
||||||
|
resultSetWTotal conf apiReq resultSet countQuery
|
||||||
|
|
||||||
|
resultSetWTotal :: AppConfig -> ApiRequest -> ResultSet -> SQL.Snippet -> DbHandler ResultSet
|
||||||
|
resultSetWTotal _ _ rs@RSPlan{} _ = return rs
|
||||||
|
resultSetWTotal AppConfig{..} ApiRequest{..} rs@RSStandard{rsTableTotal=tableTotal} countQuery =
|
||||||
|
case iPreferCount of
|
||||||
|
Just PlannedCount -> do
|
||||||
|
total <- explain
|
||||||
|
return rs{rsTableTotal=total}
|
||||||
|
Just EstimatedCount ->
|
||||||
|
if tableTotal > (fromIntegral <$> configDbMaxRows) then do
|
||||||
|
total <- max tableTotal <$> explain
|
||||||
|
return rs{rsTableTotal=total}
|
||||||
|
else
|
||||||
|
return rs
|
||||||
|
Just ExactCount ->
|
||||||
|
return rs
|
||||||
|
Nothing ->
|
||||||
|
return rs
|
||||||
|
where
|
||||||
|
explain =
|
||||||
|
lift . SQL.statement mempty . Statements.preparePlanRows countQuery $
|
||||||
|
configDbPreparedStatements
|
||||||
|
|
||||||
|
createQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||||
|
createQuery mrPlan apiReq@ApiRequest{..} conf = do
|
||||||
|
resultSet <- writeQuery mrPlan apiReq conf
|
||||||
|
failNotSingular iAcceptMediaType resultSet
|
||||||
|
optionalRollback conf apiReq
|
||||||
|
pure resultSet
|
||||||
|
|
||||||
|
updateQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||||
|
updateQuery mrPlan apiReq@ApiRequest{..} conf = do
|
||||||
|
resultSet <- writeQuery mrPlan apiReq conf
|
||||||
|
failNotSingular iAcceptMediaType resultSet
|
||||||
|
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
|
||||||
|
optionalRollback conf apiReq
|
||||||
|
pure resultSet
|
||||||
|
|
||||||
|
singleUpsertQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||||
|
singleUpsertQuery mrPlan apiReq conf = do
|
||||||
|
resultSet <- writeQuery mrPlan apiReq conf
|
||||||
|
failPut resultSet
|
||||||
|
optionalRollback conf apiReq
|
||||||
|
pure resultSet
|
||||||
|
|
||||||
|
-- Makes sure the querystring pk matches the payload pk
|
||||||
|
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted,
|
||||||
|
-- PUT /items?id=eq.14 { "id" : 2, .. } is rejected.
|
||||||
|
-- If this condition is not satisfied then nothing is inserted,
|
||||||
|
-- check the WHERE for INSERT in QueryBuilder.hs to see how it's done
|
||||||
|
failPut :: ResultSet -> DbHandler ()
|
||||||
|
failPut RSPlan{} = pure ()
|
||||||
|
failPut RSStandard{rsQueryTotal=queryTotal} =
|
||||||
|
when (queryTotal /= 1) $ do
|
||||||
|
lift SQL.condemn
|
||||||
|
throwError Error.PutMatchingPkError
|
||||||
|
|
||||||
|
deleteQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||||
|
deleteQuery mrPlan apiReq@ApiRequest{..} conf = do
|
||||||
|
resultSet <- writeQuery mrPlan apiReq conf
|
||||||
|
failNotSingular iAcceptMediaType resultSet
|
||||||
|
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
|
||||||
|
optionalRollback conf apiReq
|
||||||
|
pure resultSet
|
||||||
|
|
||||||
|
invokeQuery :: ProcDescription -> CallReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||||
|
invokeQuery proc CallReadPlan{crReadPlan, crCallPlan} apiReq@ApiRequest{..} conf@AppConfig{..} = do
|
||||||
|
resultSet <-
|
||||||
|
lift . SQL.statement mempty $
|
||||||
|
Statements.prepareCall
|
||||||
|
(Proc.procReturnsScalar proc)
|
||||||
|
(Proc.procReturnsSingle proc)
|
||||||
|
(QueryBuilder.callPlanToQuery crCallPlan)
|
||||||
|
(QueryBuilder.readPlanToQuery crReadPlan)
|
||||||
|
(QueryBuilder.readPlanToCountQuery crReadPlan)
|
||||||
|
(shouldCount iPreferCount)
|
||||||
|
iAcceptMediaType
|
||||||
|
(iPreferParameters == Just MultipleObjects)
|
||||||
|
iBinaryField
|
||||||
|
configDbPreparedStatements
|
||||||
|
|
||||||
|
optionalRollback conf apiReq
|
||||||
|
failNotSingular iAcceptMediaType resultSet
|
||||||
|
pure resultSet
|
||||||
|
|
||||||
|
openApiQuery :: SchemaCache -> PgVersion -> AppConfig -> Schema -> DbHandler (Maybe (TablesMap, ProcsMap, Maybe Text))
|
||||||
|
openApiQuery sCache pgVer AppConfig{..} tSchema =
|
||||||
|
lift $ case configOpenApiMode of
|
||||||
|
OAFollowPriv -> do
|
||||||
|
tableAccess <- SQL.statement [tSchema] (SchemaCache.accessibleTables pgVer configDbPreparedStatements)
|
||||||
|
Just <$> ((,,)
|
||||||
|
(HM.filterWithKey (\qi _ -> S.member qi tableAccess) $ SchemaCache.dbTables sCache)
|
||||||
|
<$> SQL.statement tSchema (SchemaCache.accessibleProcs pgVer configDbPreparedStatements)
|
||||||
|
<*> SQL.statement tSchema (SchemaCache.schemaDescription configDbPreparedStatements))
|
||||||
|
OAIgnorePriv ->
|
||||||
|
Just <$> ((,,)
|
||||||
|
(HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ SchemaCache.dbTables sCache)
|
||||||
|
(HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ SchemaCache.dbProcs sCache)
|
||||||
|
<$> SQL.statement tSchema (SchemaCache.schemaDescription configDbPreparedStatements))
|
||||||
|
OADisabled ->
|
||||||
|
pure Nothing
|
||||||
|
|
||||||
|
txMode :: ApiRequest -> SQL.Mode
|
||||||
|
txMode ApiRequest{..} =
|
||||||
|
case (iAction, iTarget) of
|
||||||
|
(ActionRead _, _) ->
|
||||||
|
SQL.Read
|
||||||
|
(ActionInspect _, _) ->
|
||||||
|
SQL.Read
|
||||||
|
(ActionInvoke InvGet, _) ->
|
||||||
|
SQL.Read
|
||||||
|
(ActionInvoke InvHead, _) ->
|
||||||
|
SQL.Read
|
||||||
|
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Stable} _) ->
|
||||||
|
SQL.Read
|
||||||
|
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Immutable} _) ->
|
||||||
|
SQL.Read
|
||||||
|
_ ->
|
||||||
|
SQL.Write
|
||||||
|
|
||||||
|
writeQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||||
|
writeQuery MutateReadPlan{mrReadPlan, mrMutatePlan} apiReq conf =
|
||||||
|
let
|
||||||
|
(isInsert, pkCols) = case mrMutatePlan of {Insert{insPkCols} -> (True, insPkCols); _ -> (False, mempty);}
|
||||||
|
in
|
||||||
|
lift . SQL.statement mempty $
|
||||||
|
Statements.prepareWrite
|
||||||
|
(QueryBuilder.readPlanToQuery mrReadPlan)
|
||||||
|
(QueryBuilder.mutatePlanToQuery mrMutatePlan)
|
||||||
|
isInsert
|
||||||
|
(iAcceptMediaType apiReq)
|
||||||
|
(iPreferRepresentation apiReq)
|
||||||
|
pkCols
|
||||||
|
(configDbPreparedStatements conf)
|
||||||
|
|
||||||
|
-- |
|
||||||
|
-- Fail a response if a single JSON object was requested and not exactly one
|
||||||
|
-- was found.
|
||||||
|
failNotSingular :: MediaType -> ResultSet -> DbHandler ()
|
||||||
|
failNotSingular _ RSPlan{} = pure ()
|
||||||
|
failNotSingular mediaType RSStandard{rsQueryTotal=queryTotal} =
|
||||||
|
when (mediaType == MTSingularJSON && queryTotal /= 1) $ do
|
||||||
|
lift SQL.condemn
|
||||||
|
throwError $ Error.singularityError queryTotal
|
||||||
|
|
||||||
|
failsChangesOffLimits :: Maybe Integer -> ResultSet -> DbHandler ()
|
||||||
|
failsChangesOffLimits _ RSPlan{} = pure ()
|
||||||
|
failsChangesOffLimits Nothing _ = pure ()
|
||||||
|
failsChangesOffLimits (Just maxChanges) RSStandard{rsQueryTotal=queryTotal} =
|
||||||
|
when (queryTotal > fromIntegral maxChanges) $ do
|
||||||
|
lift SQL.condemn
|
||||||
|
throwError $ Error.OffLimitsChangesError queryTotal maxChanges
|
||||||
|
|
||||||
|
-- | Set a transaction to roll back if requested
|
||||||
|
optionalRollback :: AppConfig -> ApiRequest -> DbHandler ()
|
||||||
|
optionalRollback AppConfig{..} ApiRequest{..} = do
|
||||||
|
lift $ when (shouldRollback || (configDbTxRollbackAll && not shouldCommit)) $ do
|
||||||
|
SQL.sql "SET CONSTRAINTS ALL IMMEDIATE"
|
||||||
|
SQL.condemn
|
||||||
|
where
|
||||||
|
shouldCommit =
|
||||||
|
configDbTxAllowOverride && iPreferTransaction == Just Commit
|
||||||
|
shouldRollback =
|
||||||
|
configDbTxAllowOverride && iPreferTransaction == Just Rollback
|
||||||
|
|
||||||
|
-- | Runs local(transaction scoped) GUCs for every request, plus the pre-request function
|
||||||
|
setPgLocals :: AppConfig -> KM.KeyMap JSON.Value -> Text ->
|
||||||
|
ApiRequest -> ByteString -> PgVersion -> DbHandler ()
|
||||||
|
setPgLocals conf claims role req jsonDbS actualPgVersion = do
|
||||||
|
lift $ SQL.statement mempty $ SQL.dynamicallyParameterized
|
||||||
|
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql ++ specSql))
|
||||||
|
HD.noResult (configDbPreparedStatements conf)
|
||||||
|
lift $ traverse_ SQL.sql preReqSql
|
||||||
|
where
|
||||||
|
methodSql = setConfigLocal mempty ("request.method", iMethod req)
|
||||||
|
pathSql = setConfigLocal mempty ("request.path", iPath req)
|
||||||
|
headersSql = if usesLegacyGucs
|
||||||
|
then setConfigLocal "request.header." <$> iHeaders req
|
||||||
|
else setConfigLocalJson "request.headers" (iHeaders req)
|
||||||
|
cookiesSql = if usesLegacyGucs
|
||||||
|
then setConfigLocal "request.cookie." <$> iCookies req
|
||||||
|
else setConfigLocalJson "request.cookies" (iCookies req)
|
||||||
|
claimsSql = if usesLegacyGucs
|
||||||
|
then setConfigLocal "request.jwt.claim." <$> [(toUtf8 $ K.toText c, toUtf8 $ unquoted v) | (c,v) <- KM.toList claims]
|
||||||
|
else [setConfigLocal mempty ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
|
||||||
|
roleSql = [setConfigLocal mempty ("role", toUtf8 role)]
|
||||||
|
appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings conf)
|
||||||
|
searchPathSql =
|
||||||
|
let schemas = pgFmtIdentList (iSchema req : configDbExtraSearchPath conf) in
|
||||||
|
setConfigLocal mempty ("search_path", schemas)
|
||||||
|
preReqSql = (\f -> "select " <> fromQi f <> "();") <$> configDbPreRequest conf
|
||||||
|
specSql = case iTarget req of
|
||||||
|
TargetProc{tpIsRootSpec=True} -> [setConfigLocal mempty ("request.spec", jsonDbS)]
|
||||||
|
_ -> mempty
|
||||||
|
usesLegacyGucs = configDbUseLegacyGucs conf && actualPgVersion < pgVersion140
|
||||||
|
|
||||||
|
unquoted :: JSON.Value -> Text
|
||||||
|
unquoted (JSON.String t) = t
|
||||||
|
unquoted (JSON.Number n) =
|
||||||
|
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
|
||||||
|
unquoted (JSON.Bool b) = show b
|
||||||
|
unquoted v = T.decodeUtf8 . LBS.toStrict $ JSON.encode v
|
||||||
@@ -5,14 +5,14 @@ Module : PostgREST.Query.QueryBuilder
|
|||||||
Description : PostgREST SQL queries generating functions.
|
Description : PostgREST SQL queries generating functions.
|
||||||
|
|
||||||
This module provides functions to consume data types that
|
This module provides functions to consume data types that
|
||||||
represent database queries (e.g. ReadRequest, MutateRequest) and SqlFragment
|
represent database queries (e.g. ReadPlanTree, MutatePlan) and SqlFragment
|
||||||
to produce SqlQuery type outputs.
|
to produce SqlQuery type outputs.
|
||||||
-}
|
-}
|
||||||
module PostgREST.Query.QueryBuilder
|
module PostgREST.Query.QueryBuilder
|
||||||
( readRequestToQuery
|
( readPlanToQuery
|
||||||
, mutateRequestToQuery
|
, mutatePlanToQuery
|
||||||
, readRequestToCountQuery
|
, readPlanToCountQuery
|
||||||
, requestToCallProcQuery
|
, callPlanToQuery
|
||||||
, limitedQuery
|
, limitedQuery
|
||||||
) where
|
) where
|
||||||
|
|
||||||
@@ -22,46 +22,45 @@ import qualified Hasql.DynamicStatements.Snippet as SQL
|
|||||||
|
|
||||||
import Data.Tree (Tree (..))
|
import Data.Tree (Tree (..))
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
|
import PostgREST.ApiRequest.Preferences (PreferResolution (..))
|
||||||
import PostgREST.DbStructure.Proc (ProcParam (..))
|
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
|
||||||
import PostgREST.DbStructure.Relationship (Cardinality (..),
|
import PostgREST.SchemaCache.Proc (ProcParam (..))
|
||||||
|
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||||
Junction (..),
|
Junction (..),
|
||||||
Relationship (..))
|
Relationship (..))
|
||||||
import PostgREST.Request.Preferences (PreferResolution (..))
|
|
||||||
|
|
||||||
|
import PostgREST.ApiRequest.Types
|
||||||
|
import PostgREST.Plan.CallPlan
|
||||||
|
import PostgREST.Plan.MutatePlan
|
||||||
|
import PostgREST.Plan.ReadPlan
|
||||||
import PostgREST.Query.SqlFragment
|
import PostgREST.Query.SqlFragment
|
||||||
import PostgREST.RangeQuery (allRange)
|
import PostgREST.RangeQuery (allRange)
|
||||||
import PostgREST.Request.MutateQuery
|
|
||||||
import PostgREST.Request.ReadQuery
|
|
||||||
import PostgREST.Request.Types
|
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
readRequestToQuery :: ReadRequest -> SQL.Snippet
|
readPlanToQuery :: ReadPlanTree -> SQL.Snippet
|
||||||
readRequestToQuery (Node (Select colSelects mainQi tblAlias logicForest joinConditions_ ordts range, (_, rel, _, _, _, _)) forest) =
|
readPlanToQuery (Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds} forest) =
|
||||||
"SELECT " <>
|
"SELECT " <>
|
||||||
intercalateSnippet ", " ((pgFmtSelectItem qi <$> colSelects) ++ selects) <> " " <>
|
intercalateSnippet ", " ((pgFmtSelectItem qi <$> select) ++ selects) <> " " <>
|
||||||
fromFrag <> " " <>
|
fromFrag <> " " <>
|
||||||
intercalateSnippet " " joins <> " " <>
|
intercalateSnippet " " joins <> " " <>
|
||||||
(if null logicForest && null joinConditions_
|
(if null logicForest && null relJoinConds
|
||||||
then mempty
|
then mempty
|
||||||
else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_)) <> " " <>
|
else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition relJoinConds)) <> " " <>
|
||||||
orderF qi ordts <> " " <>
|
orderF qi order <> " " <>
|
||||||
limitOffsetF range
|
limitOffsetF readRange
|
||||||
where
|
where
|
||||||
fromFrag = fromF rel mainQi tblAlias
|
fromFrag = fromF relToParent mainQi fromAlias
|
||||||
qi = getQualifiedIdentifier rel mainQi tblAlias
|
qi = getQualifiedIdentifier relToParent mainQi fromAlias
|
||||||
(selects, joins) = foldr getSelectsJoins ([],[]) forest
|
(selects, joins) = foldr getSelectsJoins ([],[]) forest
|
||||||
|
|
||||||
getSelectsJoins :: ReadRequest -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet])
|
getSelectsJoins :: ReadPlanTree -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet])
|
||||||
getSelectsJoins (Node (_, (_, Nothing, _, _, _, _)) _) _ = ([], [])
|
getSelectsJoins (Node ReadPlan{relToParent=Nothing} _) _ = ([], [])
|
||||||
getSelectsJoins rr@(Node (_, (name, Just rel, alias, _, joinType, _)) _) (selects,joins) =
|
getSelectsJoins rr@(Node ReadPlan{relName, relToParent=Just rel, relAggAlias, relAlias, relJoinType=joinType} _) (selects,joins) =
|
||||||
let
|
let
|
||||||
subquery = readRequestToQuery rr
|
subquery = readPlanToQuery rr
|
||||||
aliasOrName = fromMaybe name alias
|
aliasOrName = pgFmtIdent $ fromMaybe relName relAlias
|
||||||
locTblName = qiName (relTable rel) <> "_" <> aliasOrName
|
aggAlias = pgFmtIdent relAggAlias
|
||||||
localTableName = pgFmtIdent locTblName
|
|
||||||
internalTableName = pgFmtIdent $ "_" <> locTblName
|
|
||||||
correlatedSubquery sub al cond =
|
correlatedSubquery sub al cond =
|
||||||
(if joinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> SQL.sql al <> " ON " <> cond
|
(if joinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> SQL.sql al <> " ON " <> cond
|
||||||
isToOne = case rel of
|
isToOne = case rel of
|
||||||
@@ -71,19 +70,19 @@ getSelectsJoins rr@(Node (_, (name, Just rel, alias, _, joinType, _)) _) (select
|
|||||||
_ -> False
|
_ -> False
|
||||||
(sel, joi) = if isToOne
|
(sel, joi) = if isToOne
|
||||||
then
|
then
|
||||||
( SQL.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName)
|
( SQL.sql ("row_to_json(" <> aggAlias <> ".*) AS " <> aliasOrName)
|
||||||
, correlatedSubquery subquery localTableName "TRUE")
|
, correlatedSubquery subquery aggAlias "TRUE")
|
||||||
else
|
else
|
||||||
( SQL.sql $ "COALESCE( " <> localTableName <> "." <> internalTableName <> ", '[]') AS " <> pgFmtIdent aliasOrName
|
( SQL.sql $ "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> aliasOrName
|
||||||
, correlatedSubquery (
|
, correlatedSubquery (
|
||||||
"SELECT json_agg(" <> SQL.sql internalTableName <> ") AS " <> SQL.sql internalTableName <>
|
"SELECT json_agg(" <> SQL.sql aggAlias <> ") AS " <> SQL.sql aggAlias <>
|
||||||
"FROM (" <> subquery <> " ) AS " <> SQL.sql internalTableName
|
"FROM (" <> subquery <> " ) AS " <> SQL.sql aggAlias
|
||||||
) localTableName $ if joinType == Just JTInner then SQL.sql localTableName <> " IS NOT NULL" else "TRUE")
|
) aggAlias $ if joinType == Just JTInner then SQL.sql aggAlias <> " IS NOT NULL" else "TRUE")
|
||||||
in
|
in
|
||||||
(sel:selects, joi:joins)
|
(sel:selects, joi:joins)
|
||||||
|
|
||||||
mutateRequestToQuery :: MutateRequest -> SQL.Snippet
|
mutatePlanToQuery :: MutatePlan -> SQL.Snippet
|
||||||
mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returnings) =
|
mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _) =
|
||||||
"WITH " <> normalizedBody body <> " " <>
|
"WITH " <> normalizedBody body <> " " <>
|
||||||
"INSERT INTO " <> SQL.sql (fromQi mainQi) <> SQL.sql (if S.null iCols then " " else "(" <> cols <> ") ") <>
|
"INSERT INTO " <> SQL.sql (fromQi mainQi) <> SQL.sql (if S.null iCols then " " else "(" <> cols <> ") ") <>
|
||||||
"SELECT " <> SQL.sql cols <> " " <>
|
"SELECT " <> SQL.sql cols <> " " <>
|
||||||
@@ -95,7 +94,7 @@ mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returning
|
|||||||
if null oncCols then
|
if null oncCols then
|
||||||
mempty
|
mempty
|
||||||
else
|
else
|
||||||
"ON CONFLICT(" <> BS.intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
|
" ON CONFLICT(" <> BS.intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
|
||||||
IgnoreDuplicates ->
|
IgnoreDuplicates ->
|
||||||
"DO NOTHING"
|
"DO NOTHING"
|
||||||
MergeDuplicates ->
|
MergeDuplicates ->
|
||||||
@@ -109,7 +108,7 @@ mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returning
|
|||||||
cols = BS.intercalate ", " $ pgFmtIdent <$> S.toList iCols
|
cols = BS.intercalate ", " $ pgFmtIdent <$> S.toList iCols
|
||||||
|
|
||||||
-- An update without a limit is always filtered with a WHERE
|
-- An update without a limit is always filtered with a WHERE
|
||||||
mutateRequestToQuery (Update mainQi uCols body logicForest range ordts returnings)
|
mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings)
|
||||||
| S.null uCols =
|
| S.null uCols =
|
||||||
-- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
|
-- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
|
||||||
-- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select=
|
-- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select=
|
||||||
@@ -145,7 +144,7 @@ mutateRequestToQuery (Update mainQi uCols body logicForest range ordts returning
|
|||||||
rangeCols = BS.intercalate ", " ((\col -> pgFmtIdent col <> " = (SELECT " <> pgFmtIdent col <> " FROM pgrst_update_body) ") <$> S.toList uCols)
|
rangeCols = BS.intercalate ", " ((\col -> pgFmtIdent col <> " = (SELECT " <> pgFmtIdent col <> " FROM pgrst_update_body) ") <$> S.toList uCols)
|
||||||
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts)
|
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts)
|
||||||
|
|
||||||
mutateRequestToQuery (Delete mainQi logicForest range ordts returnings)
|
mutatePlanToQuery (Delete mainQi logicForest range ordts returnings)
|
||||||
| range == allRange =
|
| range == allRange =
|
||||||
"DELETE FROM " <> SQL.sql (fromQi mainQi) <> " " <>
|
"DELETE FROM " <> SQL.sql (fromQi mainQi) <> " " <>
|
||||||
whereLogic <> " " <>
|
whereLogic <> " " <>
|
||||||
@@ -168,8 +167,8 @@ mutateRequestToQuery (Delete mainQi logicForest range ordts returnings)
|
|||||||
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
|
whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)
|
||||||
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts)
|
(whereRangeIdF, rangeIdF) = mutRangeF mainQi (fst . otTerm <$> ordts)
|
||||||
|
|
||||||
requestToCallProcQuery :: CallRequest -> SQL.Snippet
|
callPlanToQuery :: CallPlan -> SQL.Snippet
|
||||||
requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall returnings) =
|
callPlanToQuery (FunctionCall qi params args returnsScalar multipleCall returnings) =
|
||||||
prmsCTE <> argsBody
|
prmsCTE <> argsBody
|
||||||
where
|
where
|
||||||
(prmsCTE, argFrag) = case params of
|
(prmsCTE, argFrag) = case params of
|
||||||
@@ -223,31 +222,31 @@ requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall r
|
|||||||
-- For this case, we use a WHERE EXISTS instead of an INNER JOIN on the count query.
|
-- For this case, we use a WHERE EXISTS instead of an INNER JOIN on the count query.
|
||||||
-- See https://github.com/PostgREST/postgrest/issues/2009#issuecomment-977473031
|
-- See https://github.com/PostgREST/postgrest/issues/2009#issuecomment-977473031
|
||||||
-- Only for the nodes that have an INNER JOIN linked to the root level.
|
-- Only for the nodes that have an INNER JOIN linked to the root level.
|
||||||
readRequestToCountQuery :: ReadRequest -> SQL.Snippet
|
readPlanToCountQuery :: ReadPlanTree -> SQL.Snippet
|
||||||
readRequestToCountQuery (Node (Select{from=mainQi, fromAlias=tblAlias, where_=logicForest, joinConditions=joinConditions_}, (_, rel, _, _, _, _)) forest) =
|
readPlanToCountQuery (Node ReadPlan{from=mainQi, fromAlias=tblAlias, where_=logicForest, relToParent=rel, relJoinConds} forest) =
|
||||||
"SELECT 1 " <> fromFrag <>
|
"SELECT 1 " <> fromFrag <>
|
||||||
(if null logicForest && null joinConditions_ && null subQueries
|
(if null logicForest && null relJoinConds && null subQueries
|
||||||
then mempty
|
then mempty
|
||||||
else " WHERE " ) <>
|
else " WHERE " ) <>
|
||||||
intercalateSnippet " AND " (
|
intercalateSnippet " AND " (
|
||||||
map (pgFmtLogicTree qi) logicForest ++
|
map (pgFmtLogicTree qi) logicForest ++
|
||||||
map pgFmtJoinCondition joinConditions_ ++
|
map pgFmtJoinCondition relJoinConds ++
|
||||||
subQueries
|
subQueries
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
qi = getQualifiedIdentifier rel mainQi tblAlias
|
qi = getQualifiedIdentifier rel mainQi tblAlias
|
||||||
fromFrag = fromF rel mainQi tblAlias
|
fromFrag = fromF rel mainQi tblAlias
|
||||||
subQueries = foldr existsSubquery [] forest
|
subQueries = foldr existsSubquery [] forest
|
||||||
existsSubquery :: ReadRequest -> [SQL.Snippet] -> [SQL.Snippet]
|
existsSubquery :: ReadPlanTree -> [SQL.Snippet] -> [SQL.Snippet]
|
||||||
existsSubquery readReq@(Node (_, (_, _, _, _, joinType, _)) _) rest =
|
existsSubquery readReq@(Node ReadPlan{relJoinType=joinType} _) rest =
|
||||||
if joinType == Just JTInner
|
if joinType == Just JTInner
|
||||||
then ("EXISTS (" <> readRequestToCountQuery readReq <> " )"):rest
|
then ("EXISTS (" <> readPlanToCountQuery readReq <> " )"):rest
|
||||||
else rest
|
else rest
|
||||||
|
|
||||||
limitedQuery :: SQL.Snippet -> Maybe Integer -> SQL.Snippet
|
limitedQuery :: SQL.Snippet -> Maybe Integer -> SQL.Snippet
|
||||||
limitedQuery query maxRows = query <> SQL.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows)
|
limitedQuery query maxRows = query <> SQL.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows)
|
||||||
|
|
||||||
-- TODO refactor so this function is uneeded and ComputedRelationship QualifiedIdentifier comes from the ReadQuery type
|
-- TODO refactor so this function is uneeded and ComputedRelationship QualifiedIdentifier comes from the ReadPlan type
|
||||||
getQualifiedIdentifier :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> QualifiedIdentifier
|
getQualifiedIdentifier :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> QualifiedIdentifier
|
||||||
getQualifiedIdentifier rel mainQi tblAlias = case rel of
|
getQualifiedIdentifier rel mainQi tblAlias = case rel of
|
||||||
Just ComputedRelationship{relFunction} -> QualifiedIdentifier mempty $ fromMaybe (qiName relFunction) tblAlias
|
Just ComputedRelationship{relFunction} -> QualifiedIdentifier mempty $ fromMaybe (qiName relFunction) tblAlias
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{-# LANGUAGE LambdaCase #-}
|
{-# LANGUAGE LambdaCase #-}
|
||||||
{-# LANGUAGE QuasiQuotes #-}
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
{-# LANGUAGE QuasiQuotes #-}
|
||||||
{-|
|
{-|
|
||||||
Module : PostgREST.Query.SqlFragment
|
Module : PostgREST.Query.SqlFragment
|
||||||
Description : Helper functions for PostgREST.QueryBuilder.
|
Description : Helper functions for PostgREST.QueryBuilder.
|
||||||
@@ -38,27 +39,27 @@ module PostgREST.Query.SqlFragment
|
|||||||
, unknownEncoder
|
, unknownEncoder
|
||||||
, intercalateSnippet
|
, intercalateSnippet
|
||||||
, explainF
|
, explainF
|
||||||
|
, setConfigLocal
|
||||||
|
, setConfigLocalJson
|
||||||
) where
|
) where
|
||||||
|
|
||||||
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
|
import qualified Data.HashMap.Strict as HM
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
import qualified Data.Text.Encoding as T
|
||||||
import qualified Hasql.DynamicStatements.Snippet as SQL
|
import qualified Hasql.DynamicStatements.Snippet as SQL
|
||||||
import qualified Hasql.Encoders as HE
|
import qualified Hasql.Encoders as HE
|
||||||
|
|
||||||
|
import Control.Arrow ((***))
|
||||||
|
|
||||||
import Data.Foldable (foldr1)
|
import Data.Foldable (foldr1)
|
||||||
import Text.InterpolatedString.Perl6 (qc)
|
import Text.InterpolatedString.Perl6 (qc)
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
import PostgREST.ApiRequest.Types (Alias, Cast, Field,
|
||||||
QualifiedIdentifier (..))
|
Filter (..),
|
||||||
import PostgREST.MediaType (MTPlanFormat (..),
|
|
||||||
MTPlanOption (..))
|
|
||||||
import PostgREST.RangeQuery (NonnegRange, allRange,
|
|
||||||
rangeLimit, rangeOffset)
|
|
||||||
import PostgREST.Request.ReadQuery (SelectItem)
|
|
||||||
import PostgREST.Request.Types (Alias, Field, Filter (..),
|
|
||||||
FtsOperator (..),
|
FtsOperator (..),
|
||||||
JoinCondition (..),
|
|
||||||
JsonOperand (..),
|
JsonOperand (..),
|
||||||
JsonOperation (..),
|
JsonOperation (..),
|
||||||
JsonPath,
|
JsonPath,
|
||||||
@@ -70,6 +71,13 @@ import PostgREST.Request.Types (Alias, Field, Filter (..),
|
|||||||
OrderTerm (..),
|
OrderTerm (..),
|
||||||
SimpleOperator (..),
|
SimpleOperator (..),
|
||||||
TrileanVal (..))
|
TrileanVal (..))
|
||||||
|
import PostgREST.MediaType (MTPlanFormat (..),
|
||||||
|
MTPlanOption (..))
|
||||||
|
import PostgREST.Plan.ReadPlan (JoinCondition (..))
|
||||||
|
import PostgREST.RangeQuery (NonnegRange, allRange,
|
||||||
|
rangeLimit, rangeOffset)
|
||||||
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
|
QualifiedIdentifier (..))
|
||||||
|
|
||||||
import Protolude hiding (cast)
|
import Protolude hiding (cast)
|
||||||
|
|
||||||
@@ -128,7 +136,7 @@ normalizedBody body =
|
|||||||
"END AS val",
|
"END AS val",
|
||||||
"FROM pgrst_payload)"])
|
"FROM pgrst_payload)"])
|
||||||
where
|
where
|
||||||
jsonPlaceHolder = SQL.encoderAndParam (HE.nullable HE.unknown) (LBS.toStrict <$> body) <> "::json"
|
jsonPlaceHolder = SQL.encoderAndParam (HE.nullable HE.jsonLazyBytes) body
|
||||||
|
|
||||||
singleParameter :: Maybe LBS.ByteString -> ByteString -> SQL.Snippet
|
singleParameter :: Maybe LBS.ByteString -> ByteString -> SQL.Snippet
|
||||||
singleParameter body typ =
|
singleParameter body typ =
|
||||||
@@ -227,12 +235,12 @@ pgFmtField table (c, []) = SQL.sql (pgFmtColumn table c)
|
|||||||
-- "operator does not exist: json = unknown"
|
-- "operator does not exist: json = unknown"
|
||||||
pgFmtField table (c, jp) = SQL.sql ("to_jsonb(" <> pgFmtColumn table c <> ")") <> pgFmtJsonPath jp
|
pgFmtField table (c, jp) = SQL.sql ("to_jsonb(" <> pgFmtColumn table c <> ")") <> pgFmtJsonPath jp
|
||||||
|
|
||||||
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SQL.Snippet
|
pgFmtSelectItem :: QualifiedIdentifier -> (Field, Maybe Cast, Maybe Alias) -> SQL.Snippet
|
||||||
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _, _) = pgFmtField table f <> SQL.sql (pgFmtAs fName jp alias)
|
pgFmtSelectItem table (f@(fName, jp), Nothing, alias) = pgFmtField table f <> SQL.sql (pgFmtAs fName jp alias)
|
||||||
-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc.
|
-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc.
|
||||||
-- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting.
|
-- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting.
|
||||||
-- Not quoting should be fine, we validate the input on Parsers.
|
-- Not quoting should be fine, we validate the input on Parsers.
|
||||||
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _, _) = "CAST (" <> pgFmtField table f <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> SQL.sql (pgFmtAs fName jp alias)
|
pgFmtSelectItem table (f@(fName, jp), Just cast, alias) = "CAST (" <> pgFmtField table f <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> SQL.sql (pgFmtAs fName jp alias)
|
||||||
|
|
||||||
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SQL.Snippet
|
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SQL.Snippet
|
||||||
pgFmtOrderTerm qi ot =
|
pgFmtOrderTerm qi ot =
|
||||||
@@ -386,3 +394,18 @@ explainF fmt opts snip =
|
|||||||
|
|
||||||
fmtPlanFmt PlanJSON = "FORMAT JSON"
|
fmtPlanFmt PlanJSON = "FORMAT JSON"
|
||||||
fmtPlanFmt PlanText = "FORMAT TEXT"
|
fmtPlanFmt PlanText = "FORMAT TEXT"
|
||||||
|
|
||||||
|
-- | Do a pg set_config(setting, value, true) call. This is equivalent to a SET LOCAL.
|
||||||
|
setConfigLocal :: ByteString -> (ByteString, ByteString) -> SQL.Snippet
|
||||||
|
setConfigLocal prefix (k, v) =
|
||||||
|
"set_config(" <> unknownEncoder (prefix <> k) <> ", " <> unknownEncoder v <> ", true)"
|
||||||
|
|
||||||
|
-- | Starting from PostgreSQL v14, some characters are not allowed for config names (mostly affecting headers with "-").
|
||||||
|
-- | A JSON format string is used to avoid this problem. See https://github.com/PostgREST/postgrest/issues/1857
|
||||||
|
setConfigLocalJson :: ByteString -> [(ByteString, ByteString)] -> [SQL.Snippet]
|
||||||
|
setConfigLocalJson prefix keyVals = [setConfigLocal mempty (prefix, gucJsonVal keyVals)]
|
||||||
|
where
|
||||||
|
gucJsonVal :: [(ByteString, ByteString)] -> ByteString
|
||||||
|
gucJsonVal = LBS.toStrict . JSON.encode . HM.fromList . arrayByteStringToText
|
||||||
|
arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)]
|
||||||
|
arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal
|
||||||
|
|||||||
@@ -15,30 +15,23 @@ module PostgREST.Query.Statements
|
|||||||
, ResultSet (..)
|
, ResultSet (..)
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
|
||||||
import qualified Data.Aeson.Lens as L
|
import qualified Data.Aeson.Lens as L
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
|
||||||
import qualified Hasql.Decoders as HD
|
import qualified Hasql.Decoders as HD
|
||||||
import qualified Hasql.DynamicStatements.Snippet as SQL
|
import qualified Hasql.DynamicStatements.Snippet as SQL
|
||||||
import qualified Hasql.DynamicStatements.Statement as SQL
|
import qualified Hasql.DynamicStatements.Statement as SQL
|
||||||
import qualified Hasql.Statement as SQL
|
import qualified Hasql.Statement as SQL
|
||||||
|
|
||||||
import Control.Lens ((^?))
|
import Control.Lens ((^?))
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.Text.Read (decimal)
|
|
||||||
import Network.HTTP.Types.Status (Status)
|
|
||||||
|
|
||||||
import PostgREST.Error (Error (..))
|
import PostgREST.ApiRequest.Preferences
|
||||||
import PostgREST.GucHeader (GucHeader)
|
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName)
|
|
||||||
import PostgREST.MediaType (MTPlanAttrs (..),
|
import PostgREST.MediaType (MTPlanAttrs (..),
|
||||||
MTPlanFormat (..),
|
MTPlanFormat (..),
|
||||||
MediaType (..),
|
MediaType (..),
|
||||||
getMediaType)
|
getMediaType)
|
||||||
import PostgREST.Query.SqlFragment
|
import PostgREST.Query.SqlFragment
|
||||||
import PostgREST.Request.Preferences
|
import PostgREST.SchemaCache.Identifiers (FieldName)
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
@@ -54,9 +47,9 @@ data ResultSet
|
|||||||
-- variable bindings like @"k1=eq.42"@, or the empty list if there is no location header.
|
-- variable bindings like @"k1=eq.42"@, or the empty list if there is no location header.
|
||||||
, rsBody :: BS.ByteString
|
, rsBody :: BS.ByteString
|
||||||
-- ^ the aggregated body of the query
|
-- ^ the aggregated body of the query
|
||||||
, rsGucHeaders :: Either Error [GucHeader]
|
, rsGucHeaders :: Maybe BS.ByteString
|
||||||
-- ^ the HTTP headers to be added to the response
|
-- ^ the HTTP headers to be added to the response
|
||||||
, rsGucStatus :: Either Error (Maybe Status)
|
, rsGucStatus :: Maybe Text
|
||||||
-- ^ the HTTP status to be added to the response
|
-- ^ the HTTP status to be added to the response
|
||||||
}
|
}
|
||||||
| RSPlan BS.ByteString -- ^ the plan of the query
|
| RSPlan BS.ByteString -- ^ the plan of the query
|
||||||
@@ -104,7 +97,7 @@ prepareWrite selectQuery mutateQuery isInsert mt rep pKeys =
|
|||||||
decodeIt :: HD.Result ResultSet
|
decodeIt :: HD.Result ResultSet
|
||||||
decodeIt = case mt of
|
decodeIt = case mt of
|
||||||
MTPlan{} -> planRow
|
MTPlan{} -> planRow
|
||||||
_ -> fromMaybe (RSStandard Nothing 0 mempty mempty (Right []) (Right Nothing)) <$> HD.rowMaybe (standardRow False)
|
_ -> fromMaybe (RSStandard Nothing 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow False)
|
||||||
|
|
||||||
prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> Maybe FieldName -> Bool -> SQL.Statement () ResultSet
|
prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> Maybe FieldName -> Bool -> SQL.Statement () ResultSet
|
||||||
prepareRead selectQuery countQuery countTotal mt binaryField =
|
prepareRead selectQuery countQuery countTotal mt binaryField =
|
||||||
@@ -169,7 +162,7 @@ prepareCall returnsScalar returnsSingle callProcQuery selectQuery countQuery cou
|
|||||||
decodeIt :: HD.Result ResultSet
|
decodeIt :: HD.Result ResultSet
|
||||||
decodeIt = case mt of
|
decodeIt = case mt of
|
||||||
MTPlan{} -> planRow
|
MTPlan{} -> planRow
|
||||||
_ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty (Right []) (Right Nothing)) <$> HD.rowMaybe (standardRow True)
|
_ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow True)
|
||||||
|
|
||||||
preparePlanRows :: SQL.Snippet -> Bool -> SQL.Statement () (Maybe Int64)
|
preparePlanRows :: SQL.Snippet -> Bool -> SQL.Statement () (Maybe Int64)
|
||||||
preparePlanRows countQuery =
|
preparePlanRows countQuery =
|
||||||
@@ -185,8 +178,8 @@ standardRow :: Bool -> HD.Row ResultSet
|
|||||||
standardRow noLocation =
|
standardRow noLocation =
|
||||||
RSStandard <$> nullableColumn HD.int8 <*> column HD.int8
|
RSStandard <$> nullableColumn HD.int8 <*> column HD.int8
|
||||||
<*> (if noLocation then pure mempty else fmap splitKeyValue <$> arrayColumn HD.bytea) <*> column HD.bytea
|
<*> (if noLocation then pure mempty else fmap splitKeyValue <$> arrayColumn HD.bytea) <*> column HD.bytea
|
||||||
<*> (fromMaybe (Right []) <$> nullableColumn decodeGucHeaders)
|
<*> nullableColumn HD.bytea
|
||||||
<*> (fromMaybe (Right Nothing) <$> nullableColumn decodeGucStatus)
|
<*> nullableColumn HD.text
|
||||||
where
|
where
|
||||||
splitKeyValue :: ByteString -> (ByteString, ByteString)
|
splitKeyValue :: ByteString -> (ByteString, ByteString)
|
||||||
splitKeyValue kv =
|
splitKeyValue kv =
|
||||||
@@ -202,12 +195,6 @@ mtSnippet mediaType snippet = case mediaType of
|
|||||||
planRow :: HD.Result ResultSet
|
planRow :: HD.Result ResultSet
|
||||||
planRow = RSPlan . BS.unlines <$> HD.rowList (column HD.bytea)
|
planRow = RSPlan . BS.unlines <$> HD.rowList (column HD.bytea)
|
||||||
|
|
||||||
decodeGucHeaders :: HD.Value (Either Error [GucHeader])
|
|
||||||
decodeGucHeaders = first (const GucHeadersError) . JSON.eitherDecode . LBS.fromStrict <$> HD.bytea
|
|
||||||
|
|
||||||
decodeGucStatus :: HD.Value (Either Error (Maybe Status))
|
|
||||||
decodeGucStatus = first (const GucStatusError) . fmap (Just . toEnum . fst) . decimal <$> HD.text
|
|
||||||
|
|
||||||
column :: HD.Value a -> HD.Row a
|
column :: HD.Value a -> HD.Row a
|
||||||
column = HD.column . HD.nonNullable
|
column = HD.column . HD.nonNullable
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ module PostgREST.RangeQuery (
|
|||||||
, allRange
|
, allRange
|
||||||
, limitZeroRange
|
, limitZeroRange
|
||||||
, hasLimitZero
|
, hasLimitZero
|
||||||
|
, convertToLimitZeroRange
|
||||||
, NonnegRange
|
, NonnegRange
|
||||||
, rangeStatusHeader
|
, rangeStatusHeader
|
||||||
, contentRangeH
|
, contentRangeH
|
||||||
@@ -86,6 +87,12 @@ limitZeroRange = Range (BoundaryBelow 0) (BoundaryAbove (-1))
|
|||||||
hasLimitZero :: Range Integer -> Bool
|
hasLimitZero :: Range Integer -> Bool
|
||||||
hasLimitZero r = rangeUpper r == rangeUpper limitZeroRange
|
hasLimitZero r = rangeUpper r == rangeUpper limitZeroRange
|
||||||
|
|
||||||
|
-- Used to convert a range into a special limitZeroRange if it has a
|
||||||
|
-- limit=0 in order to bypass validations for empty ranges.
|
||||||
|
convertToLimitZeroRange :: Range Integer -> Range Integer -> Range Integer
|
||||||
|
convertToLimitZeroRange range fallbackRange =
|
||||||
|
if hasLimitZero range then limitZeroRange else fallbackRange
|
||||||
|
|
||||||
rangeStatusHeader :: NonnegRange -> Int64 -> Maybe Int64 -> (Status, Header)
|
rangeStatusHeader :: NonnegRange -> Int64 -> Maybe Int64 -> (Status, Header)
|
||||||
rangeStatusHeader topLevelRange queryTotal tableTotal =
|
rangeStatusHeader topLevelRange queryTotal tableTotal =
|
||||||
let lower = rangeOffset topLevelRange
|
let lower = rangeOffset topLevelRange
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
module PostgREST.Request.ReadQuery
|
|
||||||
( ReadNode
|
|
||||||
, ReadQuery(..)
|
|
||||||
, ReadRequest
|
|
||||||
, SelectItem
|
|
||||||
, fstFieldNames
|
|
||||||
) where
|
|
||||||
|
|
||||||
import Data.Tree (Tree (..))
|
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
|
||||||
QualifiedIdentifier)
|
|
||||||
import PostgREST.DbStructure.Relationship (Relationship)
|
|
||||||
import PostgREST.RangeQuery (NonnegRange)
|
|
||||||
import PostgREST.Request.Types (Alias, Cast, Depth, Field,
|
|
||||||
Hint, JoinCondition,
|
|
||||||
JoinType, LogicTree,
|
|
||||||
NodeName, OrderTerm)
|
|
||||||
|
|
||||||
|
|
||||||
import Protolude
|
|
||||||
|
|
||||||
type ReadRequest = Tree ReadNode
|
|
||||||
|
|
||||||
type ReadNode =
|
|
||||||
(ReadQuery, (NodeName, Maybe Relationship, Maybe Alias, Maybe Hint, Maybe JoinType, Depth))
|
|
||||||
|
|
||||||
-- | The select value in `/tbl?select=alias:field::cast`
|
|
||||||
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe Hint, Maybe JoinType)
|
|
||||||
|
|
||||||
data ReadQuery = Select
|
|
||||||
{ select :: [SelectItem]
|
|
||||||
, from :: QualifiedIdentifier
|
|
||||||
, fromAlias :: Maybe Alias
|
|
||||||
-- ^ A table alias is used in case of self joins
|
|
||||||
, where_ :: [LogicTree]
|
|
||||||
, joinConditions :: [JoinCondition]
|
|
||||||
, order :: [OrderTerm]
|
|
||||||
, range_ :: NonnegRange
|
|
||||||
}
|
|
||||||
deriving (Eq)
|
|
||||||
|
|
||||||
-- First level FieldNames(e.g get a,b from /table?select=a,b,other(c,d))
|
|
||||||
fstFieldNames :: ReadRequest -> [FieldName]
|
|
||||||
fstFieldNames (Node (sel, _) _) =
|
|
||||||
fst . (\(f, _, _, _, _) -> f) <$> select sel
|
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
module PostgREST.Response
|
||||||
|
( createResponse
|
||||||
|
, deleteResponse
|
||||||
|
, infoResponse
|
||||||
|
, invokeResponse
|
||||||
|
, openApiResponse
|
||||||
|
, readResponse
|
||||||
|
, singleUpsertResponse
|
||||||
|
, updateResponse
|
||||||
|
, addRetryHint
|
||||||
|
, isServiceUnavailable
|
||||||
|
, optionalRollback
|
||||||
|
) where
|
||||||
|
|
||||||
|
import qualified Data.Aeson as JSON
|
||||||
|
import qualified Data.ByteString.Char8 as BS
|
||||||
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
|
import qualified Data.HashMap.Strict as HM
|
||||||
|
import Data.Text.Read (decimal)
|
||||||
|
import qualified Network.HTTP.Types.Header as HTTP
|
||||||
|
import qualified Network.HTTP.Types.Status as HTTP
|
||||||
|
import qualified Network.HTTP.Types.URI as HTTP
|
||||||
|
import qualified Network.Wai as Wai
|
||||||
|
|
||||||
|
import qualified PostgREST.Error as Error
|
||||||
|
import qualified PostgREST.MediaType as MediaType
|
||||||
|
import qualified PostgREST.RangeQuery as RangeQuery
|
||||||
|
import qualified PostgREST.Response.OpenAPI as OpenAPI
|
||||||
|
|
||||||
|
import PostgREST.ApiRequest (ApiRequest (..),
|
||||||
|
InvokeMethod (..),
|
||||||
|
Target (..))
|
||||||
|
import PostgREST.ApiRequest.Preferences (PreferRepresentation (..),
|
||||||
|
PreferTransaction (..),
|
||||||
|
shouldCount,
|
||||||
|
toAppliedHeader)
|
||||||
|
import PostgREST.ApiRequest.QueryParams (QueryParams (..))
|
||||||
|
import PostgREST.Config (AppConfig (..))
|
||||||
|
import PostgREST.MediaType (MediaType (..))
|
||||||
|
import PostgREST.Plan (MutateReadPlan (..))
|
||||||
|
import PostgREST.Plan.MutatePlan (MutatePlan (..))
|
||||||
|
import PostgREST.Query.Statements (ResultSet (..))
|
||||||
|
import PostgREST.Response.GucHeader (GucHeader, unwrapGucHeader)
|
||||||
|
import PostgREST.SchemaCache (SchemaCache (..))
|
||||||
|
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
|
||||||
|
Schema)
|
||||||
|
import PostgREST.SchemaCache.Proc (ProcDescription (..),
|
||||||
|
ProcVolatility (..),
|
||||||
|
ProcsMap)
|
||||||
|
import PostgREST.SchemaCache.Table (Table (..), TablesMap)
|
||||||
|
|
||||||
|
import qualified PostgREST.ApiRequest.Types as ApiRequestTypes
|
||||||
|
import qualified PostgREST.SchemaCache.Proc as Proc
|
||||||
|
|
||||||
|
import Protolude hiding (Handler, toS)
|
||||||
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
|
readResponse :: Bool -> QualifiedIdentifier -> ApiRequest -> ResultSet -> Wai.Response
|
||||||
|
readResponse headersOnly identifier ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
|
||||||
|
RSStandard{..} -> do
|
||||||
|
let
|
||||||
|
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
|
||||||
|
response = gucResponse rsGucStatus rsGucHeaders
|
||||||
|
headers =
|
||||||
|
[ contentRange
|
||||||
|
, ( "Content-Location"
|
||||||
|
, "/"
|
||||||
|
<> toUtf8 (qiName identifier)
|
||||||
|
<> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams
|
||||||
|
)
|
||||||
|
]
|
||||||
|
++ contentTypeHeaders ctxApiRequest
|
||||||
|
rsOrErrBody = if status == HTTP.status416
|
||||||
|
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
|
||||||
|
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
|
||||||
|
else LBS.fromStrict rsBody
|
||||||
|
|
||||||
|
response status headers $ if headersOnly then mempty else rsOrErrBody
|
||||||
|
|
||||||
|
RSPlan plan ->
|
||||||
|
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||||
|
|
||||||
|
createResponse :: QualifiedIdentifier -> MutateReadPlan -> ApiRequest -> ResultSet -> Wai.Response
|
||||||
|
createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan} ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
|
||||||
|
RSStandard{..} -> do
|
||||||
|
let
|
||||||
|
pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;}
|
||||||
|
response = gucResponse rsGucStatus rsGucHeaders
|
||||||
|
headers =
|
||||||
|
catMaybes
|
||||||
|
[ if null rsLocation then
|
||||||
|
Nothing
|
||||||
|
else
|
||||||
|
Just
|
||||||
|
( HTTP.hLocation
|
||||||
|
, "/"
|
||||||
|
<> toUtf8 qiName
|
||||||
|
<> HTTP.renderSimpleQuery True rsLocation
|
||||||
|
)
|
||||||
|
, Just . RangeQuery.contentRangeH 1 0 $
|
||||||
|
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
|
||||||
|
, if null pkCols && isNothing (qsOnConflict iQueryParams) then
|
||||||
|
Nothing
|
||||||
|
else
|
||||||
|
toAppliedHeader <$> iPreferResolution
|
||||||
|
]
|
||||||
|
|
||||||
|
if iPreferRepresentation == Full then
|
||||||
|
response HTTP.status201 (headers ++ contentTypeHeaders ctxApiRequest) (LBS.fromStrict rsBody)
|
||||||
|
else
|
||||||
|
response HTTP.status201 headers mempty
|
||||||
|
|
||||||
|
RSPlan plan ->
|
||||||
|
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||||
|
|
||||||
|
updateResponse :: ApiRequest -> ResultSet -> Wai.Response
|
||||||
|
updateResponse ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
|
||||||
|
RSStandard{..} -> do
|
||||||
|
let
|
||||||
|
response = gucResponse rsGucStatus rsGucHeaders
|
||||||
|
contentRangeHeader =
|
||||||
|
RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
|
||||||
|
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
|
||||||
|
headers = [contentRangeHeader]
|
||||||
|
|
||||||
|
if iPreferRepresentation == Full then
|
||||||
|
response HTTP.status200
|
||||||
|
(headers ++ contentTypeHeaders ctxApiRequest)
|
||||||
|
(LBS.fromStrict rsBody)
|
||||||
|
else
|
||||||
|
response HTTP.status204 headers mempty
|
||||||
|
|
||||||
|
RSPlan plan ->
|
||||||
|
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||||
|
|
||||||
|
singleUpsertResponse :: ApiRequest -> ResultSet -> Wai.Response
|
||||||
|
singleUpsertResponse ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
|
||||||
|
RSStandard {..} -> do
|
||||||
|
let
|
||||||
|
response = gucResponse rsGucStatus rsGucHeaders
|
||||||
|
|
||||||
|
if iPreferRepresentation == Full then
|
||||||
|
response HTTP.status200 (contentTypeHeaders ctxApiRequest) (LBS.fromStrict rsBody)
|
||||||
|
else
|
||||||
|
response HTTP.status204 [] mempty
|
||||||
|
|
||||||
|
RSPlan plan ->
|
||||||
|
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||||
|
|
||||||
|
deleteResponse :: ApiRequest -> ResultSet -> Wai.Response
|
||||||
|
deleteResponse ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
|
||||||
|
RSStandard {..} -> do
|
||||||
|
let
|
||||||
|
response = gucResponse rsGucStatus rsGucHeaders
|
||||||
|
contentRangeHeader =
|
||||||
|
RangeQuery.contentRangeH 1 0 $
|
||||||
|
if shouldCount iPreferCount then Just rsQueryTotal else Nothing
|
||||||
|
headers = [contentRangeHeader]
|
||||||
|
|
||||||
|
if iPreferRepresentation == Full then
|
||||||
|
response HTTP.status200
|
||||||
|
(headers ++ contentTypeHeaders ctxApiRequest)
|
||||||
|
(LBS.fromStrict rsBody)
|
||||||
|
else
|
||||||
|
response HTTP.status204 headers mempty
|
||||||
|
|
||||||
|
RSPlan plan ->
|
||||||
|
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||||
|
|
||||||
|
infoResponse :: Target -> SchemaCache -> Wai.Response
|
||||||
|
infoResponse target sCache =
|
||||||
|
case target of
|
||||||
|
TargetIdent identifier ->
|
||||||
|
case HM.lookup identifier (dbTables sCache) of
|
||||||
|
Just tbl -> respondInfo $ allowH tbl
|
||||||
|
Nothing -> Error.errorResponseFor $ Error.ApiRequestError ApiRequestTypes.NotFound
|
||||||
|
TargetProc pd _
|
||||||
|
| pdVolatility pd == Volatile -> respondInfo "OPTIONS,POST"
|
||||||
|
| otherwise -> respondInfo "OPTIONS,GET,HEAD,POST"
|
||||||
|
TargetDefaultSpec _ -> respondInfo "OPTIONS,GET,HEAD"
|
||||||
|
where
|
||||||
|
respondInfo allowHeader = Wai.responseLBS HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty
|
||||||
|
allOrigins = ("Access-Control-Allow-Origin", "*")
|
||||||
|
allowH table =
|
||||||
|
let hasPK = not . null $ tablePKCols table in
|
||||||
|
BS.intercalate "," $
|
||||||
|
["OPTIONS,GET,HEAD"] ++
|
||||||
|
["POST" | tableInsertable table] ++
|
||||||
|
["PUT" | tableInsertable table && tableUpdatable table && hasPK] ++
|
||||||
|
["PATCH" | tableUpdatable table] ++
|
||||||
|
["DELETE" | tableDeletable table]
|
||||||
|
|
||||||
|
invokeResponse :: InvokeMethod -> ProcDescription -> ApiRequest -> ResultSet -> Wai.Response
|
||||||
|
invokeResponse invMethod proc ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
|
||||||
|
RSStandard {..} -> do
|
||||||
|
let
|
||||||
|
response = gucResponse rsGucStatus rsGucHeaders
|
||||||
|
(status, contentRange) =
|
||||||
|
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
|
||||||
|
rsOrErrBody = if status == HTTP.status416
|
||||||
|
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
|
||||||
|
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
|
||||||
|
else LBS.fromStrict rsBody
|
||||||
|
headers = [contentRange]
|
||||||
|
|
||||||
|
if Proc.procReturnsVoid proc then
|
||||||
|
response HTTP.status204 headers mempty
|
||||||
|
else
|
||||||
|
response status
|
||||||
|
(headers ++ contentTypeHeaders ctxApiRequest)
|
||||||
|
(if invMethod == InvHead then mempty else rsOrErrBody)
|
||||||
|
|
||||||
|
RSPlan plan ->
|
||||||
|
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||||
|
|
||||||
|
openApiResponse :: Bool -> Maybe (TablesMap, ProcsMap, Maybe Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Wai.Response
|
||||||
|
openApiResponse headersOnly body conf sCache schema negotiatedByProfile =
|
||||||
|
Wai.responseLBS HTTP.status200
|
||||||
|
(MediaType.toContentType MTOpenAPI : maybeToList (profileHeader schema negotiatedByProfile))
|
||||||
|
(maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode conf sCache x y z) body)
|
||||||
|
|
||||||
|
-- | Response with headers and status overridden from GUCs.
|
||||||
|
gucResponse
|
||||||
|
:: Maybe Text
|
||||||
|
-> Maybe BS.ByteString
|
||||||
|
-> HTTP.Status
|
||||||
|
-> [HTTP.Header]
|
||||||
|
-> LBS.ByteString
|
||||||
|
-> Wai.Response
|
||||||
|
gucResponse rsGucStatus rsGucHeaders status headers body =
|
||||||
|
case (,) <$> decodeGucStatus rsGucStatus <*> decodeGucHeaders rsGucHeaders of
|
||||||
|
Left err -> Error.errorResponseFor err
|
||||||
|
Right (gucStatus, gucHeaders) ->
|
||||||
|
Wai.responseLBS (fromMaybe status gucStatus) (addHeadersIfNotIncluded headers (map unwrapGucHeader gucHeaders)) body
|
||||||
|
|
||||||
|
decodeGucHeaders :: Maybe BS.ByteString -> Either Error.Error [GucHeader]
|
||||||
|
decodeGucHeaders =
|
||||||
|
maybe (Right []) $ first (const Error.GucHeadersError) . JSON.eitherDecode . LBS.fromStrict
|
||||||
|
|
||||||
|
decodeGucStatus :: Maybe Text -> Either Error.Error (Maybe HTTP.Status)
|
||||||
|
decodeGucStatus =
|
||||||
|
maybe (Right Nothing) $ first (const Error.GucStatusError) . fmap (Just . toEnum . fst) . decimal
|
||||||
|
|
||||||
|
contentTypeHeaders :: ApiRequest -> [HTTP.Header]
|
||||||
|
contentTypeHeaders ApiRequest{..} =
|
||||||
|
MediaType.toContentType iAcceptMediaType : maybeToList (profileHeader iSchema iNegotiatedByProfile)
|
||||||
|
|
||||||
|
profileHeader :: Schema -> Bool -> Maybe HTTP.Header
|
||||||
|
profileHeader schema negotiatedByProfile =
|
||||||
|
if negotiatedByProfile
|
||||||
|
then Just $ (,) "Content-Profile" (toS schema)
|
||||||
|
else
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
addRetryHint :: Int -> Wai.Response -> Wai.Response
|
||||||
|
addRetryHint delay response = do
|
||||||
|
let h = ("Retry-After", BS.pack $ show delay)
|
||||||
|
Wai.mapResponseHeaders (\hs -> if isServiceUnavailable response then h:hs else hs) response
|
||||||
|
|
||||||
|
isServiceUnavailable :: Wai.Response -> Bool
|
||||||
|
isServiceUnavailable response = Wai.responseStatus response == HTTP.status503
|
||||||
|
|
||||||
|
optionalRollback :: AppConfig -> ApiRequest -> ExceptT Error.Error IO Wai.Response -> ExceptT Error.Error IO Wai.Response
|
||||||
|
optionalRollback AppConfig{..} ApiRequest{..} resp = do
|
||||||
|
newRes <- catchError resp $ return . Error.errorResponseFor
|
||||||
|
return $ Wai.mapResponseHeaders preferenceApplied newRes
|
||||||
|
where
|
||||||
|
shouldCommit =
|
||||||
|
configDbTxAllowOverride && iPreferTransaction == Just Commit
|
||||||
|
shouldRollback =
|
||||||
|
configDbTxAllowOverride && iPreferTransaction == Just Rollback
|
||||||
|
preferenceApplied
|
||||||
|
| shouldCommit =
|
||||||
|
addHeadersIfNotIncluded
|
||||||
|
[toAppliedHeader Commit]
|
||||||
|
| shouldRollback =
|
||||||
|
addHeadersIfNotIncluded
|
||||||
|
[toAppliedHeader Rollback]
|
||||||
|
| otherwise =
|
||||||
|
identity
|
||||||
|
|
||||||
|
-- | Add headers not already included to allow the user to override them instead of duplicating them
|
||||||
|
addHeadersIfNotIncluded :: [HTTP.Header] -> [HTTP.Header] -> [HTTP.Header]
|
||||||
|
addHeadersIfNotIncluded newHeaders initialHeaders =
|
||||||
|
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
|
||||||
|
initialHeaders
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
module PostgREST.GucHeader
|
module PostgREST.Response.GucHeader
|
||||||
( GucHeader
|
( GucHeader
|
||||||
, unwrapGucHeader
|
, unwrapGucHeader
|
||||||
, addHeadersIfNotIncluded
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
@@ -29,9 +28,3 @@ instance JSON.FromJSON GucHeader where
|
|||||||
|
|
||||||
unwrapGucHeader :: GucHeader -> Header
|
unwrapGucHeader :: GucHeader -> Header
|
||||||
unwrapGucHeader (GucHeader (k, v)) = (k, v)
|
unwrapGucHeader (GucHeader (k, v)) = (k, v)
|
||||||
|
|
||||||
-- | Add headers not already included to allow the user to override them instead of duplicating them
|
|
||||||
addHeadersIfNotIncluded :: [Header] -> [Header] -> [Header]
|
|
||||||
addHeadersIfNotIncluded newHeaders initialHeaders =
|
|
||||||
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
|
|
||||||
initialHeaders
|
|
||||||
@@ -4,7 +4,7 @@ Description : Generates the OpenAPI output
|
|||||||
-}
|
-}
|
||||||
{-# LANGUAGE LambdaCase #-}
|
{-# LANGUAGE LambdaCase #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
module PostgREST.OpenAPI (encode) where
|
module PostgREST.Response.OpenAPI (encode) where
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
@@ -26,14 +26,14 @@ import Data.Swagger
|
|||||||
|
|
||||||
import PostgREST.Config (AppConfig (..), Proxy (..),
|
import PostgREST.Config (AppConfig (..), Proxy (..),
|
||||||
isMalformedProxyUri, toURI)
|
isMalformedProxyUri, toURI)
|
||||||
import PostgREST.DbStructure (DbStructure (..))
|
import PostgREST.SchemaCache (SchemaCache (..))
|
||||||
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
|
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
|
||||||
import PostgREST.DbStructure.Proc (ProcDescription (..),
|
import PostgREST.SchemaCache.Proc (ProcDescription (..),
|
||||||
ProcParam (..))
|
ProcParam (..))
|
||||||
import PostgREST.DbStructure.Relationship (Cardinality (..),
|
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||||
Relationship (..),
|
Relationship (..),
|
||||||
RelationshipsMap)
|
RelationshipsMap)
|
||||||
import PostgREST.DbStructure.Table (Column (..), Table (..),
|
import PostgREST.SchemaCache.Table (Column (..), Table (..),
|
||||||
TablesMap)
|
TablesMap)
|
||||||
import PostgREST.Version (docsVersion, prettyVersion)
|
import PostgREST.Version (docsVersion, prettyVersion)
|
||||||
|
|
||||||
@@ -41,11 +41,11 @@ import PostgREST.MediaType
|
|||||||
|
|
||||||
import Protolude hiding (Proxy, get)
|
import Protolude hiding (Proxy, get)
|
||||||
|
|
||||||
encode :: AppConfig -> DbStructure -> TablesMap -> HM.HashMap k [ProcDescription] -> Maybe Text -> LBS.ByteString
|
encode :: AppConfig -> SchemaCache -> TablesMap -> HM.HashMap k [ProcDescription] -> Maybe Text -> LBS.ByteString
|
||||||
encode conf dbStructure tables procs schemaDescription =
|
encode conf sCache tables procs schemaDescription =
|
||||||
JSON.encode $
|
JSON.encode $
|
||||||
postgrestSpec
|
postgrestSpec
|
||||||
(dbRelationships dbStructure)
|
(dbRelationships sCache)
|
||||||
(concat $ HM.elems procs)
|
(concat $ HM.elems procs)
|
||||||
(snd <$> HM.toList tables)
|
(snd <$> HM.toList tables)
|
||||||
(proxyUri conf)
|
(proxyUri conf)
|
||||||
@@ -66,10 +66,16 @@ toSwaggerType "bigint" = Just SwaggerInteger
|
|||||||
toSwaggerType "numeric" = Just SwaggerNumber
|
toSwaggerType "numeric" = Just SwaggerNumber
|
||||||
toSwaggerType "real" = Just SwaggerNumber
|
toSwaggerType "real" = Just SwaggerNumber
|
||||||
toSwaggerType "double precision" = Just SwaggerNumber
|
toSwaggerType "double precision" = Just SwaggerNumber
|
||||||
toSwaggerType "ARRAY" = Just SwaggerArray
|
|
||||||
toSwaggerType "json" = Nothing
|
toSwaggerType "json" = Nothing
|
||||||
toSwaggerType "jsonb" = Nothing
|
toSwaggerType "jsonb" = Nothing
|
||||||
toSwaggerType _ = Just SwaggerString
|
toSwaggerType colType = case T.takeEnd 2 colType of
|
||||||
|
"[]" -> Just SwaggerArray
|
||||||
|
_ -> Just SwaggerString
|
||||||
|
|
||||||
|
makeSwaggerItemType :: Maybe (SwaggerType t) -> Text -> Maybe (Referenced Schema)
|
||||||
|
makeSwaggerItemType itemType colType = case itemType of
|
||||||
|
Just SwaggerArray -> Just $ Inline (mempty & type_ .~ toSwaggerType (T.dropEnd 2 colType))
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
parseDefault :: Text -> Text -> Text
|
parseDefault :: Text -> Text -> Text
|
||||||
parseDefault colType colDefault =
|
parseDefault colType colDefault =
|
||||||
@@ -97,11 +103,14 @@ makeProperty tbl rels col = (colName col, Inline s)
|
|||||||
fk :: Maybe Text
|
fk :: Maybe Text
|
||||||
fk =
|
fk =
|
||||||
let
|
let
|
||||||
|
searchedRels = fromMaybe mempty $ HM.lookup (QualifiedIdentifier (tableSchema tbl) (tableName tbl), tableSchema tbl) rels
|
||||||
|
-- Sorts the relationship list to get tables first
|
||||||
|
relsSortedByIsView = sortOn relFTableIsView [ r | r@Relationship{} <- searchedRels]
|
||||||
-- Finds the relationship that has a single column foreign key
|
-- Finds the relationship that has a single column foreign key
|
||||||
rel = find (\case
|
rel = find (\case
|
||||||
Relationship{relCardinality=(M2O _ relColumns)} -> [colName col] == (fst <$> relColumns)
|
Relationship{relCardinality=(M2O _ relColumns)} -> [colName col] == (fst <$> relColumns)
|
||||||
_ -> False
|
_ -> False
|
||||||
) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier (tableSchema tbl) (tableName tbl), tableSchema tbl) rels
|
) relsSortedByIsView
|
||||||
fCol = (headMay . (\r -> snd <$> relColumns (relCardinality r)) =<< rel)
|
fCol = (headMay . (\r -> snd <$> relColumns (relCardinality r)) =<< rel)
|
||||||
fTbl = qiName . relForeignTable <$> rel
|
fTbl = qiName . relForeignTable <$> rel
|
||||||
fTblCol = (,) <$> fTbl <*> fCol
|
fTblCol = (,) <$> fTbl <*> fCol
|
||||||
@@ -119,6 +128,7 @@ makeProperty tbl rels col = (colName col, Inline s)
|
|||||||
Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n)
|
Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n)
|
||||||
else
|
else
|
||||||
colDescription col
|
colDescription col
|
||||||
|
pType = toSwaggerType (colType col)
|
||||||
s =
|
s =
|
||||||
(mempty :: Schema)
|
(mempty :: Schema)
|
||||||
& default_ .~ (JSON.decode . toUtf8Lazy . parseDefault (colType col) =<< colDefault col)
|
& default_ .~ (JSON.decode . toUtf8Lazy . parseDefault (colType col) =<< colDefault col)
|
||||||
@@ -126,7 +136,8 @@ makeProperty tbl rels col = (colName col, Inline s)
|
|||||||
& enum_ .~ e
|
& enum_ .~ e
|
||||||
& format ?~ colType col
|
& format ?~ colType col
|
||||||
& maxLength .~ (fromIntegral <$> colMaxLen col)
|
& maxLength .~ (fromIntegral <$> colMaxLen col)
|
||||||
& type_ .~ toSwaggerType (colType col)
|
& type_ .~ pType
|
||||||
|
& items .~ (SwaggerItemsObject <$> makeSwaggerItemType pType (colType col))
|
||||||
|
|
||||||
makeProcSchema :: ProcDescription -> Schema
|
makeProcSchema :: ProcDescription -> Schema
|
||||||
makeProcSchema pd =
|
makeProcSchema pd =
|
||||||
@@ -141,6 +152,7 @@ makeProcProperty (ProcParam n t _ _) = (n, Inline s)
|
|||||||
where
|
where
|
||||||
s = (mempty :: Schema)
|
s = (mempty :: Schema)
|
||||||
& type_ .~ toSwaggerType t
|
& type_ .~ toSwaggerType t
|
||||||
|
& items .~ (SwaggerItemsObject <$> makeSwaggerItemType (toSwaggerType t) t)
|
||||||
& format ?~ t
|
& format ?~ t
|
||||||
|
|
||||||
makePreferParam :: [Text] -> Param
|
makePreferParam :: [Text] -> Param
|
||||||
@@ -152,7 +164,15 @@ makePreferParam ts =
|
|||||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
& in_ .~ ParamHeader
|
& in_ .~ ParamHeader
|
||||||
& type_ ?~ SwaggerString
|
& type_ ?~ SwaggerString
|
||||||
& enum_ .~ JSON.decode (JSON.encode ts))
|
& enum_ .~ JSON.decode (JSON.encode $ foldl (<>) [] (val <$> ts)))
|
||||||
|
where
|
||||||
|
val :: Text -> [Text]
|
||||||
|
val = \case
|
||||||
|
"count" -> ["count=none"]
|
||||||
|
"params" -> ["params=single-object"]
|
||||||
|
"return" -> ["return=representation", "return=minimal", "return=none"]
|
||||||
|
"resolution" -> ["resolution=ignore-duplicates", "resolution=merge-duplicates"]
|
||||||
|
_ -> []
|
||||||
|
|
||||||
makeProcParam :: ProcDescription -> [Referenced Param]
|
makeProcParam :: ProcDescription -> [Referenced Param]
|
||||||
makeProcParam pd =
|
makeProcParam pd =
|
||||||
@@ -165,9 +185,11 @@ makeProcParam pd =
|
|||||||
|
|
||||||
makeParamDefs :: [Table] -> [(Text, Param)]
|
makeParamDefs :: [Table] -> [(Text, Param)]
|
||||||
makeParamDefs ti =
|
makeParamDefs ti =
|
||||||
[ ("preferParams", makePreferParam ["params=single-object"])
|
-- TODO: create Prefer for each method (GET, PATCH, etc.)
|
||||||
, ("preferReturn", makePreferParam ["return=representation", "return=minimal", "return=none"])
|
[ ("preferParams", makePreferParam ["params"])
|
||||||
, ("preferCount", makePreferParam ["count=none"])
|
, ("preferReturn", makePreferParam ["return"])
|
||||||
|
, ("preferCount", makePreferParam ["count"])
|
||||||
|
, ("preferPost", makePreferParam ["return", "resolution"])
|
||||||
, ("select", (mempty :: Param)
|
, ("select", (mempty :: Param)
|
||||||
& name .~ "select"
|
& name .~ "select"
|
||||||
& description ?~ "Filtering Columns"
|
& description ?~ "Filtering Columns"
|
||||||
@@ -267,7 +289,7 @@ makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
postOp = tOp
|
postOp = tOp
|
||||||
& parameters .~ fmap ref ["body." <> tn, "select", "preferReturn"]
|
& parameters .~ fmap ref ["body." <> tn, "select", "preferPost"]
|
||||||
& at 201 ?~ "Created"
|
& at 201 ?~ "Created"
|
||||||
patchOp = tOp
|
patchOp = tOp
|
||||||
& parameters .~ fmap ref (rs <> ["body." <> tn, "preferReturn"])
|
& parameters .~ fmap ref (rs <> ["body." <> tn, "preferReturn"])
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{-|
|
{-|
|
||||||
Module : PostgREST.DbStructure
|
Module : PostgREST.SchemaCache
|
||||||
Description : PostgREST schema cache
|
Description : PostgREST schema cache
|
||||||
|
|
||||||
This module contains queries that target PostgreSQL system catalogs, these are used to build the schema cache(DbStructure).
|
This module(used to be named DbStructure) contains queries that target PostgreSQL system catalogs, these are used to build the schema cache(SchemaCache).
|
||||||
|
|
||||||
The schema cache is necessary for resource embedding, foreign keys are used for inferring the relationships between tables.
|
The schema cache is necessary for resource embedding, foreign keys are used for inferring the relationships between tables.
|
||||||
|
|
||||||
@@ -18,9 +18,9 @@ These queries are executed once at startup or when PostgREST is reloaded.
|
|||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
{-# LANGUAGE TypeSynonymInstances #-}
|
{-# LANGUAGE TypeSynonymInstances #-}
|
||||||
|
|
||||||
module PostgREST.DbStructure
|
module PostgREST.SchemaCache
|
||||||
( DbStructure(..)
|
( SchemaCache(..)
|
||||||
, queryDbStructure
|
, querySchemaCache
|
||||||
, accessibleTables
|
, accessibleTables
|
||||||
, accessibleProcs
|
, accessibleProcs
|
||||||
, schemaDescription
|
, schemaDescription
|
||||||
@@ -40,25 +40,25 @@ import Text.InterpolatedString.Perl6 (q)
|
|||||||
import PostgREST.Config.Database (pgVersionStatement)
|
import PostgREST.Config.Database (pgVersionStatement)
|
||||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
|
import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
|
||||||
pgVersion110)
|
pgVersion110)
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
import PostgREST.SchemaCache.Identifiers (AccessSet, FieldName,
|
||||||
QualifiedIdentifier (..),
|
QualifiedIdentifier (..),
|
||||||
Schema)
|
Schema)
|
||||||
import PostgREST.DbStructure.Proc (PgType (..),
|
import PostgREST.SchemaCache.Proc (PgType (..),
|
||||||
ProcDescription (..),
|
ProcDescription (..),
|
||||||
ProcParam (..),
|
ProcParam (..),
|
||||||
ProcVolatility (..),
|
ProcVolatility (..),
|
||||||
ProcsMap, RetType (..))
|
ProcsMap, RetType (..))
|
||||||
import PostgREST.DbStructure.Relationship (Cardinality (..),
|
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||||
Junction (..),
|
Junction (..),
|
||||||
Relationship (..),
|
Relationship (..),
|
||||||
RelationshipsMap)
|
RelationshipsMap)
|
||||||
import PostgREST.DbStructure.Table (Column (..), Table (..),
|
import PostgREST.SchemaCache.Table (Column (..), Table (..),
|
||||||
TablesMap)
|
TablesMap)
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
|
|
||||||
data DbStructure = DbStructure
|
data SchemaCache = SchemaCache
|
||||||
{ dbTables :: TablesMap
|
{ dbTables :: TablesMap
|
||||||
, dbRelationships :: RelationshipsMap
|
, dbRelationships :: RelationshipsMap
|
||||||
, dbProcs :: ProcsMap
|
, dbProcs :: ProcsMap
|
||||||
@@ -66,12 +66,33 @@ data DbStructure = DbStructure
|
|||||||
deriving (Generic, JSON.ToJSON)
|
deriving (Generic, JSON.ToJSON)
|
||||||
|
|
||||||
-- | A view foreign key or primary key dependency detected on its source table
|
-- | A view foreign key or primary key dependency detected on its source table
|
||||||
|
-- Each column of the key could be referenced multiple times in the view, e.g.
|
||||||
|
--
|
||||||
|
-- create view projects_view as
|
||||||
|
-- select
|
||||||
|
-- id as id_1,
|
||||||
|
-- id as id_2,
|
||||||
|
-- id as id_3,
|
||||||
|
-- name
|
||||||
|
-- from projects
|
||||||
|
--
|
||||||
|
-- In this case, the keyDepCols mapping maps projects.id to all three of the columns:
|
||||||
|
--
|
||||||
|
-- [('id', ['id_1', 'id_2', 'id_3'])]
|
||||||
|
--
|
||||||
|
-- Depending on key type, we can then choose how to handle this case. Primary keys
|
||||||
|
-- can arbitrarily choose one of the columns, but for foreign keys we need to create
|
||||||
|
-- relationships for each possible mutations.
|
||||||
|
--
|
||||||
|
-- Previously, we stored a (FieldName, FieldName) tuple only, but then we had no
|
||||||
|
-- way to make a difference between a multi-column-key and a single-column-key with multiple
|
||||||
|
-- references in the view. Or even worse in the multi-column-key-multi-reference case...
|
||||||
data ViewKeyDependency = ViewKeyDependency {
|
data ViewKeyDependency = ViewKeyDependency {
|
||||||
keyDepTable :: QualifiedIdentifier
|
keyDepTable :: QualifiedIdentifier
|
||||||
, keyDepView :: QualifiedIdentifier
|
, keyDepView :: QualifiedIdentifier
|
||||||
, keyDepCons :: Text
|
, keyDepCons :: Text
|
||||||
, keyDepType :: KeyDep
|
, keyDepType :: KeyDep
|
||||||
, keyDepCols :: [(FieldName, FieldName)] -- ^ First element is the table column, second is the view column
|
, keyDepCols :: [(FieldName, [FieldName])] -- ^ First element is the table column, second is a list of view columns
|
||||||
} deriving (Eq)
|
} deriving (Eq)
|
||||||
data KeyDep
|
data KeyDep
|
||||||
= PKDep -- ^ PK dependency
|
= PKDep -- ^ PK dependency
|
||||||
@@ -82,8 +103,8 @@ data KeyDep
|
|||||||
-- | A SQL query that can be executed independently
|
-- | A SQL query that can be executed independently
|
||||||
type SqlQuery = ByteString
|
type SqlQuery = ByteString
|
||||||
|
|
||||||
queryDbStructure :: [Schema] -> [Schema] -> Bool -> SQL.Transaction DbStructure
|
querySchemaCache :: [Schema] -> [Schema] -> Bool -> SQL.Transaction SchemaCache
|
||||||
queryDbStructure schemas extraSearchPath prepared = do
|
querySchemaCache schemas extraSearchPath prepared = do
|
||||||
SQL.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
|
SQL.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
|
||||||
pgVer <- SQL.statement mempty pgVersionStatement
|
pgVer <- SQL.statement mempty pgVersionStatement
|
||||||
tabs <- SQL.statement schemas $ allTables pgVer prepared
|
tabs <- SQL.statement schemas $ allTables pgVer prepared
|
||||||
@@ -95,7 +116,7 @@ queryDbStructure schemas extraSearchPath prepared = do
|
|||||||
let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps
|
let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps
|
||||||
rels = addInverseRels $ addM2MRels tabsWViewsPks $ addViewM2OAndO2ORels keyDeps m2oRels
|
rels = addInverseRels $ addM2MRels tabsWViewsPks $ addViewM2OAndO2ORels keyDeps m2oRels
|
||||||
|
|
||||||
return $ removeInternal schemas $ DbStructure {
|
return $ removeInternal schemas $ SchemaCache {
|
||||||
dbTables = tabsWViewsPks
|
dbTables = tabsWViewsPks
|
||||||
, dbRelationships = getOverrideRelationshipsMap rels cRels
|
, dbRelationships = getOverrideRelationshipsMap rels cRels
|
||||||
, dbProcs = procs
|
, dbProcs = procs
|
||||||
@@ -121,10 +142,10 @@ getOverrideRelationshipsMap rels cRels =
|
|||||||
deformedRelMap = HM.fromListWith (++) . fmap addDeformedRelKey . HM.toList
|
deformedRelMap = HM.fromListWith (++) . fmap addDeformedRelKey . HM.toList
|
||||||
addDeformedRelKey ((relT, relFT), rls) = ((relT, qiSchema relFT), rls)
|
addDeformedRelKey ((relT, relFT), rls) = ((relT, qiSchema relFT), rls)
|
||||||
|
|
||||||
-- | Remove db objects that belong to an internal schema(not exposed through the API) from the DbStructure.
|
-- | Remove db objects that belong to an internal schema(not exposed through the API) from the SchemaCache.
|
||||||
removeInternal :: [Schema] -> DbStructure -> DbStructure
|
removeInternal :: [Schema] -> SchemaCache -> SchemaCache
|
||||||
removeInternal schemas dbStruct =
|
removeInternal schemas dbStruct =
|
||||||
DbStructure {
|
SchemaCache {
|
||||||
dbTables = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch `elem` schemas) $ dbTables dbStruct
|
dbTables = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch `elem` schemas) $ dbTables dbStruct
|
||||||
, dbRelationships = filter (\r -> qiSchema (relForeignTable r) `elem` schemas && not (hasInternalJunction r)) <$>
|
, dbRelationships = filter (\r -> qiSchema (relForeignTable r) `elem` schemas && not (hasInternalJunction r)) <$>
|
||||||
HM.filterWithKey (\(QualifiedIdentifier sch _, _) _ -> sch `elem` schemas ) (dbRelationships dbStruct)
|
HM.filterWithKey (\(QualifiedIdentifier sch _, _) _ -> sch `elem` schemas ) (dbRelationships dbStruct)
|
||||||
@@ -136,6 +157,14 @@ removeInternal schemas dbStruct =
|
|||||||
M2M Junction{junTable} -> qiSchema junTable `notElem` schemas
|
M2M Junction{junTable} -> qiSchema junTable `notElem` schemas
|
||||||
_ -> False
|
_ -> False
|
||||||
|
|
||||||
|
decodeAccessibleIdentifiers :: HD.Result AccessSet
|
||||||
|
decodeAccessibleIdentifiers =
|
||||||
|
S.fromList <$> HD.rowList row
|
||||||
|
where
|
||||||
|
row = QualifiedIdentifier
|
||||||
|
<$> column HD.text
|
||||||
|
<*> column HD.text
|
||||||
|
|
||||||
decodeTables :: HD.Result TablesMap
|
decodeTables :: HD.Result TablesMap
|
||||||
decodeTables =
|
decodeTables =
|
||||||
HM.fromList . map (\tbl@Table{tableSchema, tableName} -> (QualifiedIdentifier tableSchema tableName, tbl)) <$> HD.rowList tblRow
|
HM.fromList . map (\tbl@Table{tableSchema, tableName} -> (QualifiedIdentifier tableSchema tableName, tbl)) <$> HD.rowList tblRow
|
||||||
@@ -184,9 +213,9 @@ decodeViewKeyDeps =
|
|||||||
<*> compositeArrayColumn
|
<*> compositeArrayColumn
|
||||||
((,)
|
((,)
|
||||||
<$> compositeField HD.text
|
<$> compositeField HD.text
|
||||||
<*> compositeField HD.text)
|
<*> compositeFieldArray HD.text)
|
||||||
|
|
||||||
viewKeyDepFromRow :: (Text,Text,Text,Text,Text,Text,[(Text, Text)]) -> ViewKeyDependency
|
viewKeyDepFromRow :: (Text,Text,Text,Text,Text,Text,[(Text, [Text])]) -> ViewKeyDependency
|
||||||
viewKeyDepFromRow (s1,t1,s2,v2,cons,consType,sCols) = ViewKeyDependency (QualifiedIdentifier s1 t1) (QualifiedIdentifier s2 v2) cons keyDep sCols
|
viewKeyDepFromRow (s1,t1,s2,v2,cons,consType,sCols) = ViewKeyDependency (QualifiedIdentifier s1 t1) (QualifiedIdentifier s2 v2) cons keyDep sCols
|
||||||
where
|
where
|
||||||
keyDep | consType == "p" = PKDep
|
keyDep | consType == "p" = PKDep
|
||||||
@@ -331,11 +360,27 @@ schemaDescription =
|
|||||||
where
|
where
|
||||||
n.nspname = $1 |]
|
n.nspname = $1 |]
|
||||||
|
|
||||||
accessibleTables :: PgVersion -> Bool -> SQL.Statement [Schema] TablesMap
|
accessibleTables :: PgVersion -> Bool -> SQL.Statement [Schema] AccessSet
|
||||||
accessibleTables pgVer =
|
accessibleTables pgVer =
|
||||||
SQL.Statement sql (arrayParam HE.text) decodeTables
|
SQL.Statement sql (arrayParam HE.text) decodeAccessibleIdentifiers
|
||||||
where
|
where
|
||||||
sql = tablesSqlQuery False pgVer
|
sql = [q|
|
||||||
|
SELECT
|
||||||
|
n.nspname AS table_schema,
|
||||||
|
c.relname AS table_name
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind IN ('v','r','m','f','p')
|
||||||
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
AND n.nspname = ANY($1)
|
||||||
|
AND (
|
||||||
|
pg_has_role(c.relowner, 'USAGE')
|
||||||
|
or has_table_privilege(c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER')
|
||||||
|
or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES')
|
||||||
|
) |] <>
|
||||||
|
relIsPartition <>
|
||||||
|
"ORDER BY table_schema, table_name"
|
||||||
|
relIsPartition = if pgVer >= pgVersion100 then " AND not c.relispartition " else mempty
|
||||||
|
|
||||||
{-
|
{-
|
||||||
Adds M2O and O2O relationships for views to tables, tables to views, and views to views. The example below is taken from the test fixtures, but the views names/colnames were modified.
|
Adds M2O and O2O relationships for views to tables, tables to views, and views to views. The example below is taken from the test fixtures, but the views names/colnames were modified.
|
||||||
@@ -370,19 +415,21 @@ addViewM2OAndO2ORels keyDeps rels =
|
|||||||
(keyDepView vwTbl)
|
(keyDepView vwTbl)
|
||||||
relForeignTable
|
relForeignTable
|
||||||
False
|
False
|
||||||
((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vCol) (_, fCol)-> (vCol, fCol)) (keyDepCols vwTbl) relCols)
|
((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vCol) (_, fCol)-> (vCol, fCol)) keyDepColsVwTbl relCols)
|
||||||
True
|
True
|
||||||
False
|
False
|
||||||
| vwTbl <- viewTableRels ]
|
| vwTbl <- viewTableRels
|
||||||
|
, keyDepColsVwTbl <- expandKeyDepCols $ keyDepCols vwTbl ]
|
||||||
++
|
++
|
||||||
[ Relationship
|
[ Relationship
|
||||||
relTable
|
relTable
|
||||||
(keyDepView tblVw)
|
(keyDepView tblVw)
|
||||||
False
|
False
|
||||||
((if isM2O card then M2O else O2O) cons $ zipWith (\(tCol, _) (_, vCol) -> (tCol, vCol)) relCols (keyDepCols tblVw))
|
((if isM2O card then M2O else O2O) cons $ zipWith (\(tCol, _) (_, vCol) -> (tCol, vCol)) relCols keyDepColsTblVw)
|
||||||
False
|
False
|
||||||
True
|
True
|
||||||
| tblVw <- tableViewRels ]
|
| tblVw <- tableViewRels
|
||||||
|
, keyDepColsTblVw <- expandKeyDepCols $ keyDepCols tblVw ]
|
||||||
++
|
++
|
||||||
[
|
[
|
||||||
let
|
let
|
||||||
@@ -393,13 +440,16 @@ addViewM2OAndO2ORels keyDeps rels =
|
|||||||
vw1
|
vw1
|
||||||
vw2
|
vw2
|
||||||
(vw1 == vw2)
|
(vw1 == vw2)
|
||||||
((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vcol1) (_, vcol2) -> (vcol1, vcol2)) (keyDepCols vwTbl) (keyDepCols tblVw))
|
((if isM2O card then M2O else O2O) cons $ zipWith (\(_, vcol1) (_, vcol2) -> (vcol1, vcol2)) keyDepColsVwTbl keyDepColsTblVw)
|
||||||
True
|
True
|
||||||
True
|
True
|
||||||
| vwTbl <- viewTableRels
|
| vwTbl <- viewTableRels
|
||||||
, tblVw <- tableViewRels ]
|
, keyDepColsVwTbl <- expandKeyDepCols $ keyDepCols vwTbl
|
||||||
|
, tblVw <- tableViewRels
|
||||||
|
, keyDepColsTblVw <- expandKeyDepCols $ keyDepCols tblVw ]
|
||||||
else []
|
else []
|
||||||
viewRels _ = []
|
viewRels _ = []
|
||||||
|
expandKeyDepCols kdc = zip (fst <$> kdc) <$> sequenceA (snd <$> kdc)
|
||||||
|
|
||||||
addInverseRels :: [Relationship] -> [Relationship]
|
addInverseRels :: [Relationship] -> [Relationship]
|
||||||
addInverseRels rels =
|
addInverseRels rels =
|
||||||
@@ -428,18 +478,24 @@ addViewPrimaryKeys tabs keyDeps =
|
|||||||
else tbl) <$> tabs
|
else tbl) <$> tabs
|
||||||
where
|
where
|
||||||
findViewPKCols sch vw =
|
findViewPKCols sch vw =
|
||||||
maybe [] (\(ViewKeyDependency _ _ _ _ pkCols) -> snd <$> pkCols) $
|
concatMap (\(ViewKeyDependency _ _ _ _ pkCols) -> takeFirstPK pkCols) $
|
||||||
find (\(ViewKeyDependency _ viewQi _ dep _) -> dep == PKDep && viewQi == QualifiedIdentifier sch vw) keyDeps
|
filter (\(ViewKeyDependency _ viewQi _ dep _) -> dep == PKDep && viewQi == QualifiedIdentifier sch vw) keyDeps
|
||||||
|
-- In the case of multiple reference to the same PK (see comment for ViewKeyDependency) we take the first reference available.
|
||||||
|
-- We assume this to be safe to do, because:
|
||||||
|
-- * We don't have any logic that requires the client to name a PK column (compared to the column hints in embedding for FKs),
|
||||||
|
-- so we don't need to know about the other references.
|
||||||
|
-- * We need to choose a single reference for each column, otherwise we'd output too many columns in location headers etc.
|
||||||
|
takeFirstPK pkCols = catMaybes $ head . snd <$> pkCols
|
||||||
|
|
||||||
allTables :: PgVersion -> Bool -> SQL.Statement [Schema] TablesMap
|
allTables :: PgVersion -> Bool -> SQL.Statement [Schema] TablesMap
|
||||||
allTables pgVer =
|
allTables pgVer =
|
||||||
SQL.Statement sql (arrayParam HE.text) decodeTables
|
SQL.Statement sql (arrayParam HE.text) decodeTables
|
||||||
where
|
where
|
||||||
sql = tablesSqlQuery True pgVer
|
sql = tablesSqlQuery pgVer
|
||||||
|
|
||||||
-- | Gets tables with their PK cols
|
-- | Gets tables with their PK cols
|
||||||
tablesSqlQuery :: Bool -> PgVersion -> SqlQuery
|
tablesSqlQuery :: PgVersion -> SqlQuery
|
||||||
tablesSqlQuery getAll pgVer =
|
tablesSqlQuery pgVer =
|
||||||
-- the tbl_constraints/key_col_usage CTEs are based on the standard "information_schema.table_constraints"/"information_schema.key_column_usage" views,
|
-- the tbl_constraints/key_col_usage CTEs are based on the standard "information_schema.table_constraints"/"information_schema.key_column_usage" views,
|
||||||
-- we cannot use those directly as they include the following privilege filter:
|
-- we cannot use those directly as they include the following privilege filter:
|
||||||
-- (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text));
|
-- (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text));
|
||||||
@@ -456,13 +512,11 @@ tablesSqlQuery getAll pgVer =
|
|||||||
CASE
|
CASE
|
||||||
WHEN t.typtype = 'd' THEN
|
WHEN t.typtype = 'd' THEN
|
||||||
CASE
|
CASE
|
||||||
WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text
|
|
||||||
WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer)
|
WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer)
|
||||||
ELSE format_type(a.atttypid, a.atttypmod)
|
ELSE format_type(a.atttypid, a.atttypmod)
|
||||||
END
|
END
|
||||||
ELSE
|
ELSE
|
||||||
CASE
|
CASE
|
||||||
WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text
|
|
||||||
WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer)
|
WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer)
|
||||||
ELSE format_type(a.atttypid, a.atttypmod)
|
ELSE format_type(a.atttypid, a.atttypmod)
|
||||||
END
|
END
|
||||||
@@ -630,16 +684,8 @@ tablesSqlQuery getAll pgVer =
|
|||||||
WHERE c.relkind IN ('v','r','m','f','p')
|
WHERE c.relkind IN ('v','r','m','f','p')
|
||||||
AND n.nspname NOT IN ('pg_catalog', 'information_schema') |] <>
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema') |] <>
|
||||||
relIsPartition <>
|
relIsPartition <>
|
||||||
fltTables <>
|
|
||||||
"ORDER BY table_schema, table_name"
|
"ORDER BY table_schema, table_name"
|
||||||
where
|
where
|
||||||
fltTables = if getAll then mempty else [q|
|
|
||||||
AND n.nspname = ANY($1)
|
|
||||||
AND (
|
|
||||||
pg_has_role(c.relowner, 'USAGE')
|
|
||||||
or has_table_privilege(c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER')
|
|
||||||
or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES')
|
|
||||||
)|]
|
|
||||||
relIsPartition = if pgVer >= pgVersion100 then " AND not c.relispartition " else mempty
|
relIsPartition = if pgVer >= pgVersion100 then " AND not c.relispartition " else mempty
|
||||||
|
|
||||||
|
|
||||||
@@ -679,9 +725,9 @@ allM2OandO2ORels pgVer =
|
|||||||
FROM pg_constraint traint
|
FROM pg_constraint traint
|
||||||
JOIN LATERAL (
|
JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
array_agg(row(cols.attname, refs.attname) order by cols.attnum) AS cols_and_fcols,
|
array_agg(row(cols.attname, refs.attname) order by ord) AS cols_and_fcols,
|
||||||
jsonb_agg(cols.attname order by cols.attnum) AS cols
|
jsonb_agg(cols.attname order by ord) AS cols
|
||||||
FROM ( SELECT unnest(traint.conkey) AS col, unnest(traint.confkey) AS ref) _
|
FROM unnest(traint.conkey, traint.confkey) WITH ORDINALITY AS _(col, ref, ord)
|
||||||
JOIN pg_attribute cols ON cols.attrelid = traint.conrelid AND cols.attnum = col
|
JOIN pg_attribute cols ON cols.attrelid = traint.conrelid AND cols.attnum = col
|
||||||
JOIN pg_attribute refs ON refs.attrelid = traint.confrelid AND refs.attnum = ref
|
JOIN pg_attribute refs ON refs.attrelid = traint.confrelid AND refs.attnum = ref
|
||||||
) AS column_info ON TRUE
|
) AS column_info ON TRUE
|
||||||
@@ -710,13 +756,13 @@ allComputedRels =
|
|||||||
),
|
),
|
||||||
computed_rels as (
|
computed_rels as (
|
||||||
select
|
select
|
||||||
p.pronamespace::regnamespace::text as schema,
|
(parse_ident(p.pronamespace::regnamespace::text))[1] as schema,
|
||||||
p.proname::text as name,
|
p.proname::text as name,
|
||||||
arg_schema.nspname::text as rel_table_schema,
|
arg_schema.nspname::text as rel_table_schema,
|
||||||
arg_name.typname::text as rel_table_name,
|
arg_name.typname::text as rel_table_name,
|
||||||
ret_schema.nspname::text as rel_ftable_schema,
|
ret_schema.nspname::text as rel_ftable_schema,
|
||||||
ret_name.typname::text as rel_ftable_name,
|
ret_name.typname::text as rel_ftable_name,
|
||||||
p.prorows = 1 as single_row
|
not p.proretset or p.prorows = 1 as single_row
|
||||||
from pg_proc p
|
from pg_proc p
|
||||||
join pg_type arg_name on arg_name.oid = p.proargtypes[0]
|
join pg_type arg_name on arg_name.oid = p.proargtypes[0]
|
||||||
join pg_namespace arg_schema on arg_schema.oid = arg_name.typnamespace
|
join pg_namespace arg_schema on arg_schema.oid = arg_name.typnamespace
|
||||||
@@ -756,18 +802,24 @@ allViewsKeyDependencies =
|
|||||||
select
|
select
|
||||||
contype::text as contype,
|
contype::text as contype,
|
||||||
conname,
|
conname,
|
||||||
|
array_length(conkey, 1) as ncol,
|
||||||
conrelid as resorigtbl,
|
conrelid as resorigtbl,
|
||||||
unnest(conkey) as resorigcol
|
col as resorigcol,
|
||||||
|
ord
|
||||||
from pg_constraint
|
from pg_constraint
|
||||||
|
left join lateral unnest(conkey) with ordinality as _(col, ord) on true
|
||||||
where contype IN ('p', 'f')
|
where contype IN ('p', 'f')
|
||||||
union
|
union
|
||||||
-- fk referenced col
|
-- fk referenced col
|
||||||
select
|
select
|
||||||
concat(contype, '_ref') as contype,
|
concat(contype, '_ref') as contype,
|
||||||
conname,
|
conname,
|
||||||
|
array_length(confkey, 1) as ncol,
|
||||||
confrelid,
|
confrelid,
|
||||||
unnest(confkey)
|
col,
|
||||||
|
ord
|
||||||
from pg_constraint
|
from pg_constraint
|
||||||
|
left join lateral unnest(confkey) with ordinality as _(col, ord) on true
|
||||||
where contype='f'
|
where contype='f'
|
||||||
),
|
),
|
||||||
views as (
|
views as (
|
||||||
@@ -875,8 +927,13 @@ allViewsKeyDependencies =
|
|||||||
(entry->>'resorigcol')::int as resorigcol
|
(entry->>'resorigcol')::int as resorigcol
|
||||||
from target_entries
|
from target_entries
|
||||||
),
|
),
|
||||||
recursion as(
|
-- CYCLE detection according to PG docs: https://www.postgresql.org/docs/current/queries-with.html#QUERIES-WITH-CYCLE
|
||||||
select r.*
|
-- Can be replaced with CYCLE clause once PG v13 is EOL.
|
||||||
|
recursion(view_id, view_schema, view_name, view_column, resorigtbl, resorigcol, is_cycle, path) as(
|
||||||
|
select
|
||||||
|
r.*,
|
||||||
|
false,
|
||||||
|
ARRAY[resorigtbl]
|
||||||
from results r
|
from results r
|
||||||
where view_schema = ANY ($1)
|
where view_schema = ANY ($1)
|
||||||
union all
|
union all
|
||||||
@@ -886,25 +943,46 @@ allViewsKeyDependencies =
|
|||||||
view.view_name,
|
view.view_name,
|
||||||
view.view_column,
|
view.view_column,
|
||||||
tab.resorigtbl,
|
tab.resorigtbl,
|
||||||
tab.resorigcol
|
tab.resorigcol,
|
||||||
|
tab.resorigtbl = ANY(path),
|
||||||
|
path || tab.resorigtbl
|
||||||
from recursion view
|
from recursion view
|
||||||
join results tab on view.resorigtbl=tab.view_id and view.resorigcol=tab.view_column
|
join results tab on view.resorigtbl=tab.view_id and view.resorigcol=tab.view_column
|
||||||
|
where not is_cycle
|
||||||
|
),
|
||||||
|
repeated_references as(
|
||||||
|
select
|
||||||
|
view_id,
|
||||||
|
view_schema,
|
||||||
|
view_name,
|
||||||
|
resorigtbl,
|
||||||
|
resorigcol,
|
||||||
|
array_agg(attname) as view_columns
|
||||||
|
from recursion
|
||||||
|
join pg_attribute vcol on vcol.attrelid = view_id and vcol.attnum = view_column
|
||||||
|
group by
|
||||||
|
view_id,
|
||||||
|
view_schema,
|
||||||
|
view_name,
|
||||||
|
resorigtbl,
|
||||||
|
resorigcol
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
sch.nspname as table_schema,
|
sch.nspname as table_schema,
|
||||||
tbl.relname as table_name,
|
tbl.relname as table_name,
|
||||||
rec.view_schema,
|
rep.view_schema,
|
||||||
rec.view_name,
|
rep.view_name,
|
||||||
pks_fks.conname as constraint_name,
|
pks_fks.conname as constraint_name,
|
||||||
pks_fks.contype as constraint_type,
|
pks_fks.contype as constraint_type,
|
||||||
array_agg(row(col.attname, vcol.attname) order by col.attnum) as column_dependencies
|
array_agg(row(col.attname, view_columns) order by pks_fks.ord) as column_dependencies
|
||||||
from recursion rec
|
from repeated_references rep
|
||||||
join pg_class tbl on tbl.oid = rec.resorigtbl
|
|
||||||
join pg_attribute col on col.attrelid = tbl.oid and col.attnum = rec.resorigcol
|
|
||||||
join pg_attribute vcol on vcol.attrelid = rec.view_id and vcol.attnum = rec.view_column
|
|
||||||
join pg_namespace sch on sch.oid = tbl.relnamespace
|
|
||||||
join pks_fks using (resorigtbl, resorigcol)
|
join pks_fks using (resorigtbl, resorigcol)
|
||||||
group by sch.nspname, tbl.relname, rec.view_schema, rec.view_name, pks_fks.conname, pks_fks.contype
|
join pg_class tbl on tbl.oid = rep.resorigtbl
|
||||||
|
join pg_attribute col on col.attrelid = tbl.oid and col.attnum = rep.resorigcol
|
||||||
|
join pg_namespace sch on sch.oid = tbl.relnamespace
|
||||||
|
group by sch.nspname, tbl.relname, rep.view_schema, rep.view_name, pks_fks.conname, pks_fks.contype, pks_fks.ncol
|
||||||
|
-- make sure we only return key for which all columns are referenced in the view - no partial PKs or FKs
|
||||||
|
having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1)
|
||||||
|]
|
|]
|
||||||
|
|
||||||
param :: HE.Value a -> HE.Params a
|
param :: HE.Value a -> HE.Params a
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
{-# LANGUAGE DeriveAnyClass #-}
|
{-# LANGUAGE DeriveAnyClass #-}
|
||||||
{-# LANGUAGE DeriveGeneric #-}
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
|
|
||||||
module PostgREST.DbStructure.Identifiers
|
module PostgREST.SchemaCache.Identifiers
|
||||||
( QualifiedIdentifier(..)
|
( QualifiedIdentifier(..)
|
||||||
, Schema
|
, Schema
|
||||||
, TableName
|
, TableName
|
||||||
, FieldName
|
, FieldName
|
||||||
|
, AccessSet
|
||||||
, dumpQi
|
, dumpQi
|
||||||
, toQi
|
, toQi
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
|
import qualified Data.Set as S
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
@@ -40,3 +42,5 @@ toQi txt = case T.drop 1 <$> T.breakOn "." txt of
|
|||||||
type Schema = Text
|
type Schema = Text
|
||||||
type TableName = Text
|
type TableName = Text
|
||||||
type FieldName = Text
|
type FieldName = Text
|
||||||
|
|
||||||
|
type AccessSet = S.Set QualifiedIdentifier
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{-# LANGUAGE DeriveAnyClass #-}
|
{-# LANGUAGE DeriveAnyClass #-}
|
||||||
{-# LANGUAGE DeriveGeneric #-}
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
|
|
||||||
module PostgREST.DbStructure.Proc
|
module PostgREST.SchemaCache.Proc
|
||||||
( PgType(..)
|
( PgType(..)
|
||||||
, ProcDescription(..)
|
, ProcDescription(..)
|
||||||
, ProcParam(..)
|
, ProcParam(..)
|
||||||
@@ -17,7 +17,7 @@ module PostgREST.DbStructure.Proc
|
|||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.HashMap.Strict as HM
|
import qualified Data.HashMap.Strict as HM
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..),
|
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
|
||||||
Schema, TableName)
|
Schema, TableName)
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
{-# LANGUAGE DeriveAnyClass #-}
|
{-# LANGUAGE DeriveAnyClass #-}
|
||||||
{-# LANGUAGE DeriveGeneric #-}
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
|
|
||||||
module PostgREST.DbStructure.Relationship
|
module PostgREST.SchemaCache.Relationship
|
||||||
( Cardinality(..)
|
( Cardinality(..)
|
||||||
, Relationship(..)
|
, Relationship(..)
|
||||||
, Junction(..)
|
, Junction(..)
|
||||||
@@ -11,7 +11,7 @@ module PostgREST.DbStructure.Relationship
|
|||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.HashMap.Strict as HM
|
import qualified Data.HashMap.Strict as HM
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
QualifiedIdentifier, Schema)
|
QualifiedIdentifier, Schema)
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
@@ -55,8 +55,8 @@ data Junction = Junction
|
|||||||
{ junTable :: QualifiedIdentifier
|
{ junTable :: QualifiedIdentifier
|
||||||
, junConstraint1 :: FKConstraint
|
, junConstraint1 :: FKConstraint
|
||||||
, junConstraint2 :: FKConstraint
|
, junConstraint2 :: FKConstraint
|
||||||
, junColumns1 :: [(FieldName, FieldName)]
|
, junColsSource :: [(FieldName, FieldName)]
|
||||||
, junColumns2 :: [(FieldName, FieldName)]
|
, junColsTarget :: [(FieldName, FieldName)]
|
||||||
}
|
}
|
||||||
deriving (Eq, Ord, Generic, JSON.ToJSON)
|
deriving (Eq, Ord, Generic, JSON.ToJSON)
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{-# LANGUAGE DeriveAnyClass #-}
|
{-# LANGUAGE DeriveAnyClass #-}
|
||||||
{-# LANGUAGE DeriveGeneric #-}
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
|
|
||||||
module PostgREST.DbStructure.Table
|
module PostgREST.SchemaCache.Table
|
||||||
( Column(..)
|
( Column(..)
|
||||||
, Table(..)
|
, Table(..)
|
||||||
, TablesMap
|
, TablesMap
|
||||||
@@ -10,7 +10,7 @@ module PostgREST.DbStructure.Table
|
|||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.HashMap.Strict as HM
|
import qualified Data.HashMap.Strict as HM
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers (FieldName,
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
QualifiedIdentifier (..),
|
QualifiedIdentifier (..),
|
||||||
Schema, TableName)
|
Schema, TableName)
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ installSignalHandlers appState = do
|
|||||||
install Signals.sigINT interrupt
|
install Signals.sigINT interrupt
|
||||||
install Signals.sigTERM interrupt
|
install Signals.sigTERM interrupt
|
||||||
|
|
||||||
-- The SIGUSR1 signal updates the internal 'DbStructure' by running
|
-- The SIGUSR1 signal updates the internal 'SchemaCache' by running
|
||||||
-- 'connectionWorker' exactly as before.
|
-- 'connectionWorker' exactly as before.
|
||||||
install Signals.sigUSR1 $ Workers.connectionWorker appState
|
install Signals.sigUSR1 $ Workers.connectionWorker appState
|
||||||
|
|
||||||
|
|||||||
+105
-26
@@ -1,30 +1,40 @@
|
|||||||
{-# LANGUAGE LambdaCase #-}
|
{-# LANGUAGE LambdaCase #-}
|
||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
|
||||||
module PostgREST.Workers
|
module PostgREST.Workers
|
||||||
( connectionWorker
|
( connectionWorker
|
||||||
, reReadConfig
|
, reReadConfig
|
||||||
, listener
|
, runListener
|
||||||
|
, runAdmin
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
|
import qualified Data.Text as T
|
||||||
import qualified Data.Text.Encoding as T
|
import qualified Data.Text.Encoding as T
|
||||||
import qualified Hasql.Notifications as SQL
|
import qualified Hasql.Notifications as SQL
|
||||||
|
import qualified Hasql.Session as SQL
|
||||||
import qualified Hasql.Transaction.Sessions as SQL
|
import qualified Hasql.Transaction.Sessions as SQL
|
||||||
|
import qualified Network.HTTP.Types.Status as HTTP
|
||||||
|
import qualified Network.Wai as Wai
|
||||||
|
import qualified Network.Wai.Handler.Warp as Warp
|
||||||
|
|
||||||
import Control.Retry (RetryStatus, capDelay, exponentialBackoff,
|
import Control.Retry (RetryStatus, capDelay, exponentialBackoff,
|
||||||
retrying, rsPreviousDelay)
|
retrying, rsPreviousDelay)
|
||||||
import Hasql.Connection (acquire)
|
import Hasql.Connection (acquire)
|
||||||
|
|
||||||
|
import Network.Socket
|
||||||
|
import Network.Socket.ByteString
|
||||||
|
|
||||||
import PostgREST.AppState (AppState)
|
import PostgREST.AppState (AppState)
|
||||||
import PostgREST.Config (AppConfig (..), readAppConfig)
|
import PostgREST.Config (AppConfig (..), readAppConfig)
|
||||||
import PostgREST.Config.Database (queryDbSettings, queryPgVersion)
|
import PostgREST.Config.Database (queryDbSettings, queryPgVersion)
|
||||||
import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion)
|
import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion)
|
||||||
import PostgREST.DbStructure (queryDbStructure)
|
|
||||||
import PostgREST.Error (PgError (PgError), checkIsFatal,
|
import PostgREST.Error (PgError (PgError), checkIsFatal,
|
||||||
errorPayload)
|
errorPayload)
|
||||||
|
import PostgREST.SchemaCache (querySchemaCache)
|
||||||
|
|
||||||
import qualified PostgREST.AppState as AppState
|
import qualified PostgREST.AppState as AppState
|
||||||
|
|
||||||
@@ -45,7 +55,7 @@ data SCacheStatus
|
|||||||
| SCFatalFail
|
| SCFatalFail
|
||||||
|
|
||||||
-- | The purpose of this worker is to obtain a healthy connection to pg and an
|
-- | The purpose of this worker is to obtain a healthy connection to pg and an
|
||||||
-- up-to-date schema cache(DbStructure). This method is meant to be called
|
-- up-to-date schema cache(SchemaCache). This method is meant to be called
|
||||||
-- multiple times by the same thread, but does nothing if the previous
|
-- multiple times by the same thread, but does nothing if the previous
|
||||||
-- invocation has not terminated. In all cases this method does not halt the
|
-- invocation has not terminated. In all cases this method does not halt the
|
||||||
-- calling thread, the work is performed in a separate thread.
|
-- calling thread, the work is performed in a separate thread.
|
||||||
@@ -54,7 +64,7 @@ data SCacheStatus
|
|||||||
-- 1. Tries to connect to pg server and will keep trying until success.
|
-- 1. Tries to connect to pg server and will keep trying until success.
|
||||||
-- 2. Checks if the pg version is supported and if it's not it kills the main
|
-- 2. Checks if the pg version is supported and if it's not it kills the main
|
||||||
-- program.
|
-- program.
|
||||||
-- 3. Obtains the dbStructure. If this fails, it goes back to 1.
|
-- 3. Obtains the sCache. If this fails, it goes back to 1.
|
||||||
connectionWorker :: AppState -> IO ()
|
connectionWorker :: AppState -> IO ()
|
||||||
connectionWorker appState = do
|
connectionWorker appState = do
|
||||||
runExclusively (AppState.getWorkerSem appState) work
|
runExclusively (AppState.getWorkerSem appState) work
|
||||||
@@ -68,13 +78,13 @@ connectionWorker appState = do
|
|||||||
work = do
|
work = do
|
||||||
AppConfig{..} <- AppState.getConfig appState
|
AppConfig{..} <- AppState.getConfig appState
|
||||||
AppState.logWithZTime appState "Attempting to connect to the database..."
|
AppState.logWithZTime appState "Attempting to connect to the database..."
|
||||||
connected <- connectionStatus appState
|
connected <- establishConnection appState
|
||||||
case connected of
|
case connected of
|
||||||
FatalConnectionError reason ->
|
FatalConnectionError reason ->
|
||||||
-- Fatal error when connecting
|
-- Fatal error when connecting
|
||||||
AppState.logWithZTime appState reason >> killThread (AppState.getMainThreadId appState)
|
AppState.logWithZTime appState reason >> killThread (AppState.getMainThreadId appState)
|
||||||
NotConnected ->
|
NotConnected ->
|
||||||
-- Unreachable because connectionStatus will keep trying to connect
|
-- Unreachable because establishConnection will keep trying to connect
|
||||||
return ()
|
return ()
|
||||||
Connected actualPgVersion -> do
|
Connected actualPgVersion -> do
|
||||||
-- Procede with initialization
|
-- Procede with initialization
|
||||||
@@ -97,19 +107,20 @@ connectionWorker appState = do
|
|||||||
-- die if our schema cache query has an error
|
-- die if our schema cache query has an error
|
||||||
killThread $ AppState.getMainThreadId appState
|
killThread $ AppState.getMainThreadId appState
|
||||||
|
|
||||||
-- | Check if a connection from the pool allows access to the PostgreSQL
|
-- | Repeatedly flush the pool, and check if a connection from the
|
||||||
-- database. If not, the pool connections are released and a new connection is
|
-- pool allows access to the PostgreSQL database.
|
||||||
-- tried. Releasing the pool is key for rapid recovery. Otherwise, the pool
|
--
|
||||||
|
-- Releasing the pool is key for rapid recovery. Otherwise, the pool
|
||||||
-- timeout would have to be reached for new healthy connections to be acquired.
|
-- timeout would have to be reached for new healthy connections to be acquired.
|
||||||
-- Which might not happen if the server is busy with requests. No idle
|
-- Which might not happen if the server is busy with requests. No idle
|
||||||
-- connection, no pool timeout.
|
-- connection, no pool timeout.
|
||||||
--
|
--
|
||||||
-- The connection tries are capped, but if the connection times out no error is
|
-- The connection tries are capped, but if the connection times out no error is
|
||||||
-- thrown, just 'False' is returned.
|
-- thrown, just 'False' is returned.
|
||||||
connectionStatus :: AppState -> IO ConnectionStatus
|
establishConnection :: AppState -> IO ConnectionStatus
|
||||||
connectionStatus appState =
|
establishConnection appState =
|
||||||
retrying retrySettings shouldRetry $
|
retrying retrySettings shouldRetry $
|
||||||
const $ AppState.releasePool appState >> getConnectionStatus
|
const $ AppState.flushPool appState >> getConnectionStatus
|
||||||
where
|
where
|
||||||
retrySettings = capDelay delayMicroseconds $ exponentialBackoff backoffMicroseconds
|
retrySettings = capDelay delayMicroseconds $ exponentialBackoff backoffMicroseconds
|
||||||
delayMicroseconds = 32000000 -- 32 seconds
|
delayMicroseconds = 32000000 -- 32 seconds
|
||||||
@@ -147,14 +158,14 @@ connectionStatus appState =
|
|||||||
when itShould $ AppState.putRetryNextIn appState delay
|
when itShould $ AppState.putRetryNextIn appState delay
|
||||||
return itShould
|
return itShould
|
||||||
|
|
||||||
-- | Load the DbStructure by using a connection from the pool.
|
-- | Load the SchemaCache by using a connection from the pool.
|
||||||
loadSchemaCache :: AppState -> IO SCacheStatus
|
loadSchemaCache :: AppState -> IO SCacheStatus
|
||||||
loadSchemaCache appState = do
|
loadSchemaCache appState = do
|
||||||
AppConfig{..} <- AppState.getConfig appState
|
AppConfig{..} <- AppState.getConfig appState
|
||||||
result <-
|
result <-
|
||||||
let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
|
let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
|
||||||
AppState.usePool appState . transaction SQL.ReadCommitted SQL.Read $
|
AppState.usePool appState . transaction SQL.ReadCommitted SQL.Read $
|
||||||
queryDbStructure (toList configDbSchemas) configDbExtraSearchPath configDbPreparedStatements
|
querySchemaCache (toList configDbSchemas) configDbExtraSearchPath configDbPreparedStatements
|
||||||
case result of
|
case result of
|
||||||
Left e -> do
|
Left e -> do
|
||||||
let
|
let
|
||||||
@@ -167,18 +178,22 @@ loadSchemaCache appState = do
|
|||||||
AppState.logWithZTime appState hint
|
AppState.logWithZTime appState hint
|
||||||
return SCFatalFail
|
return SCFatalFail
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
AppState.putDbStructure appState Nothing
|
AppState.putSchemaCache appState Nothing
|
||||||
AppState.logWithZTime appState "An error ocurred when loading the schema cache"
|
AppState.logWithZTime appState "An error ocurred when loading the schema cache"
|
||||||
putErr
|
putErr
|
||||||
return SCOnRetry
|
return SCOnRetry
|
||||||
|
|
||||||
Right dbStructure -> do
|
Right sCache -> do
|
||||||
AppState.putDbStructure appState (Just dbStructure)
|
AppState.putSchemaCache appState (Just sCache)
|
||||||
when (isJust configDbRootSpec) .
|
when (isJust configDbRootSpec) .
|
||||||
AppState.putJsonDbS appState . LBS.toStrict $ JSON.encode dbStructure
|
AppState.putJsonDbS appState . LBS.toStrict $ JSON.encode sCache
|
||||||
AppState.logWithZTime appState "Schema cache loaded"
|
AppState.logWithZTime appState "Schema cache loaded"
|
||||||
return SCLoaded
|
return SCLoaded
|
||||||
|
|
||||||
|
runListener :: AppConfig -> AppState -> IO ()
|
||||||
|
runListener AppConfig{configDbChannelEnabled} appState =
|
||||||
|
when configDbChannelEnabled $ listener appState
|
||||||
|
|
||||||
-- | Starts a dedicated pg connection to LISTEN for notifications. When a
|
-- | Starts a dedicated pg connection to LISTEN for notifications. When a
|
||||||
-- NOTIFY <db-channel> - with an empty payload - is done, it refills the schema
|
-- NOTIFY <db-channel> - with an empty payload - is done, it refills the schema
|
||||||
-- cache. It uses the connectionWorker in case the LISTEN connection dies.
|
-- cache. It uses the connectionWorker in case the LISTEN connection dies.
|
||||||
@@ -215,16 +230,15 @@ listener appState = do
|
|||||||
listener appState
|
listener appState
|
||||||
|
|
||||||
handleNotification _ msg
|
handleNotification _ msg
|
||||||
| BS.null msg = scLoader -- reload the schema cache
|
| BS.null msg = cacheReloader
|
||||||
| msg == "reload schema" = scLoader -- reload the schema cache
|
| msg == "reload schema" = cacheReloader
|
||||||
| msg == "reload config" = reReadConfig False appState -- reload the config
|
| msg == "reload config" = reReadConfig False appState
|
||||||
| otherwise = pure () -- Do nothing if anything else than an empty message is sent
|
| otherwise = pure () -- Do nothing if anything else than an empty message is sent
|
||||||
|
|
||||||
scLoader =
|
cacheReloader =
|
||||||
-- It's not necessary to check the loadSchemaCache success
|
-- reloads the schema cache + restarts pool connections
|
||||||
-- here. If the connection drops, the thread will die and
|
-- it's necessary to restart the pg connections because they cache the pg catalog(see #2620)
|
||||||
-- proceed to recover.
|
connectionWorker appState
|
||||||
void $ loadSchemaCache appState
|
|
||||||
|
|
||||||
-- | Re-reads the config plus config options from the db
|
-- | Re-reads the config plus config options from the db
|
||||||
reReadConfig :: Bool -> AppState -> IO ()
|
reReadConfig :: Bool -> AppState -> IO ()
|
||||||
@@ -263,3 +277,68 @@ reReadConfig startingUp appState = do
|
|||||||
pass
|
pass
|
||||||
else
|
else
|
||||||
AppState.logWithZTime appState "Config reloaded"
|
AppState.logWithZTime appState "Config reloaded"
|
||||||
|
|
||||||
|
runAdmin :: AppConfig -> AppState -> Warp.Settings -> IO ()
|
||||||
|
runAdmin conf@AppConfig{configAdminServerPort} appState settings =
|
||||||
|
whenJust configAdminServerPort $ \adminPort -> do
|
||||||
|
AppState.logWithZTime appState $ "Admin server listening on port " <> show adminPort
|
||||||
|
void . forkIO $ Warp.runSettings (settings & Warp.setPort adminPort) adminApp
|
||||||
|
where
|
||||||
|
whenJust :: Applicative m => Maybe a -> (a -> m ()) -> m ()
|
||||||
|
whenJust mg f = maybe (pure ()) f mg
|
||||||
|
adminApp = admin appState conf
|
||||||
|
|
||||||
|
-- | PostgREST admin application
|
||||||
|
admin :: AppState.AppState -> AppConfig -> Wai.Application
|
||||||
|
admin appState appConfig req respond = do
|
||||||
|
isMainAppReachable <- any isRight <$> reachMainApp appConfig
|
||||||
|
isSchemaCacheLoaded <- isJust <$> AppState.getSchemaCache appState
|
||||||
|
isConnectionUp <-
|
||||||
|
if configDbChannelEnabled appConfig
|
||||||
|
then AppState.getIsListenerOn appState
|
||||||
|
else isRight <$> AppState.usePool appState (SQL.sql "SELECT 1")
|
||||||
|
|
||||||
|
case Wai.pathInfo req of
|
||||||
|
["ready"] ->
|
||||||
|
respond $ Wai.responseLBS (if isMainAppReachable && isConnectionUp && isSchemaCacheLoaded then HTTP.status200 else HTTP.status503) [] mempty
|
||||||
|
["live"] ->
|
||||||
|
respond $ Wai.responseLBS (if isMainAppReachable then HTTP.status200 else HTTP.status503) [] mempty
|
||||||
|
_ ->
|
||||||
|
respond $ Wai.responseLBS HTTP.status404 [] mempty
|
||||||
|
|
||||||
|
-- Try to connect to the main app socket
|
||||||
|
-- Note that it doesn't even send a valid HTTP request, we just want to check that the main app is accepting connections
|
||||||
|
-- The code for resolving the "*4", "!4", "*6", "!6", "*" special values is taken from
|
||||||
|
-- https://hackage.haskell.org/package/streaming-commons-0.2.2.4/docs/src/Data.Streaming.Network.html#bindPortGenEx
|
||||||
|
reachMainApp :: AppConfig -> IO [Either IOException ()]
|
||||||
|
reachMainApp AppConfig{..} =
|
||||||
|
case configServerUnixSocket of
|
||||||
|
Just path -> do
|
||||||
|
sock <- socket AF_UNIX Stream 0
|
||||||
|
(:[]) <$> try (do
|
||||||
|
connect sock $ SockAddrUnix path
|
||||||
|
withSocketsDo $ bracket (pure sock) close sendEmpty)
|
||||||
|
Nothing -> do
|
||||||
|
let
|
||||||
|
host | configServerHost `elem` ["*4", "!4", "*6", "!6", "*"] = Nothing
|
||||||
|
| otherwise = Just configServerHost
|
||||||
|
filterAddrs xs =
|
||||||
|
case configServerHost of
|
||||||
|
"*4" -> ipv4Addrs xs ++ ipv6Addrs xs
|
||||||
|
"!4" -> ipv4Addrs xs
|
||||||
|
"*6" -> ipv6Addrs xs ++ ipv4Addrs xs
|
||||||
|
"!6" -> ipv6Addrs xs
|
||||||
|
_ -> xs
|
||||||
|
ipv4Addrs = filter ((/=) AF_INET6 . addrFamily)
|
||||||
|
ipv6Addrs = filter ((==) AF_INET6 . addrFamily)
|
||||||
|
|
||||||
|
addrs <- getAddrInfo (Just $ defaultHints { addrSocketType = Stream }) (T.unpack <$> host) (Just . show $ configServerPort)
|
||||||
|
tryAddr `traverse` filterAddrs addrs
|
||||||
|
where
|
||||||
|
sendEmpty sock = void $ send sock mempty
|
||||||
|
tryAddr :: AddrInfo -> IO (Either IOException ())
|
||||||
|
tryAddr addr = do
|
||||||
|
sock <- socket (addrFamily addr) (addrSocketType addr) (addrProtocol addr)
|
||||||
|
try $ do
|
||||||
|
connect sock $ addrAddress addr
|
||||||
|
withSocketsDo $ bracket (pure sock) close sendEmpty
|
||||||
|
|||||||
+20
-9
@@ -10,12 +10,23 @@ nix:
|
|||||||
pure: false
|
pure: false
|
||||||
|
|
||||||
extra-deps:
|
extra-deps:
|
||||||
- HTTP-4000.3.16@sha256:6042643c15a0b43e522a6693f1e322f05000d519543a84149cb80aeffee34f71,5947
|
- HTTP-4000.3.16
|
||||||
- configurator-pg-0.2.6@sha256:cd9b06a458428e493a4d6def725af7ab1ab0fef678fbd871f9586fc7f9aa70be,2849
|
- configurator-pg-0.2.6
|
||||||
- hasql-dynamic-statements-0.3.1.1@sha256:2cfe6e75990e690f595a87cbe553f2e90fcd738610f6c66749c81cc4396b2cc4,2675
|
- hashable-1.4.1.0
|
||||||
- hasql-implicits-0.1.0.4@sha256:0848d3cbc9d94e1e539948fa0be4d0326b26335034161bf8076785293444ca6f,1361
|
- hashtables-1.3
|
||||||
- hasql-pool-0.5.2.2@sha256:b56d4dea112d97a2ef4b2749508c0ca646828cb2d77b827e8dc433d249bb2062,2438
|
- hasql-1.6.1.1
|
||||||
- lens-aeson-1.1.3@sha256:52c8eaecd2d1c2a969c0762277c4a8ee72c339a686727d5785932e72ef9c3050,1764
|
- hasql-dynamic-statements-0.3.1.2
|
||||||
- optparse-applicative-0.16.1.0@sha256:418c22ed6a19124d457d96bc66bd22c93ac22fad0c7100fe4972bbb4ac989731,4982
|
- hasql-implicits-0.1.0.5
|
||||||
- protolude-0.3.2@sha256:2a38b3dad40d238ab644e234b692c8911423f9d3ed0e36b62287c4a698d92cd1,2240
|
- hasql-notifications-0.2.0.3
|
||||||
- ptr-0.16.8.2@sha256:708ebb95117f2872d2c5a554eb6804cf1126e86abe793b2673f913f14e5eb1ac,3959
|
- hasql-pool-0.8.0.6
|
||||||
|
- hasql-transaction-1.0.1.2
|
||||||
|
- isomorphism-class-0.1.0.6
|
||||||
|
- lens-aeson-1.1.3
|
||||||
|
- optparse-applicative-0.16.1.0
|
||||||
|
- postgresql-binary-0.12.5
|
||||||
|
- protolude-0.3.2
|
||||||
|
- ptr-0.16.8.2
|
||||||
|
- text-builder-0.6.7
|
||||||
|
- text-builder-dev-0.3.3
|
||||||
|
- git: https://github.com/PostgREST/postgresql-libpq.git
|
||||||
|
commit: 33ff97db570b5b432255f5f24a68db51453f6eb8
|
||||||
|
|||||||
+90
-16
@@ -10,63 +10,137 @@ packages:
|
|||||||
size: 1428
|
size: 1428
|
||||||
sha256: b73a7f6d21cf20bbf819e19039409c9010efb5000d2b72cdd8fd67a9027c14e8
|
sha256: b73a7f6d21cf20bbf819e19039409c9010efb5000d2b72cdd8fd67a9027c14e8
|
||||||
original:
|
original:
|
||||||
hackage: HTTP-4000.3.16@sha256:6042643c15a0b43e522a6693f1e322f05000d519543a84149cb80aeffee34f71,5947
|
hackage: HTTP-4000.3.16
|
||||||
- completed:
|
- completed:
|
||||||
hackage: configurator-pg-0.2.6@sha256:cd9b06a458428e493a4d6def725af7ab1ab0fef678fbd871f9586fc7f9aa70be,2849
|
hackage: configurator-pg-0.2.6@sha256:cd9b06a458428e493a4d6def725af7ab1ab0fef678fbd871f9586fc7f9aa70be,2849
|
||||||
pantry-tree:
|
pantry-tree:
|
||||||
size: 2463
|
size: 2463
|
||||||
sha256: 97efe7a22afc93033bda5adcffdabc0f1c30dc32b2c3ba02114ce7cd74c942fd
|
sha256: 97efe7a22afc93033bda5adcffdabc0f1c30dc32b2c3ba02114ce7cd74c942fd
|
||||||
original:
|
original:
|
||||||
hackage: configurator-pg-0.2.6@sha256:cd9b06a458428e493a4d6def725af7ab1ab0fef678fbd871f9586fc7f9aa70be,2849
|
hackage: configurator-pg-0.2.6
|
||||||
- completed:
|
- completed:
|
||||||
hackage: hasql-dynamic-statements-0.3.1.1@sha256:2cfe6e75990e690f595a87cbe553f2e90fcd738610f6c66749c81cc4396b2cc4,2675
|
hackage: hashable-1.4.1.0@sha256:50b2f002c68fe67730ee7a3cd8607486197dd99b084255005ad51ecd6970a41b,5019
|
||||||
|
pantry-tree:
|
||||||
|
size: 1248
|
||||||
|
sha256: 9af2f7a42674f7effcabbebc043f97057240783f1709338a77f58216f4a5f18c
|
||||||
|
original:
|
||||||
|
hackage: hashable-1.4.1.0
|
||||||
|
- completed:
|
||||||
|
hackage: hashtables-1.3@sha256:ab21804fdafbbd8ad918b2911dabb729ae0ea891780fe66bf7804cbcd07edadf,10379
|
||||||
|
pantry-tree:
|
||||||
|
size: 2895
|
||||||
|
sha256: e71f113ad989dbc994e0fb52bcc219d62930de9afa8b3441bf7909e864481b33
|
||||||
|
original:
|
||||||
|
hackage: hashtables-1.3
|
||||||
|
- completed:
|
||||||
|
hackage: hasql-1.6.1.1@sha256:948a2137308cc5354e4997bc3666753867124cd25db792424cb9614b1c1b44cf,6626
|
||||||
|
pantry-tree:
|
||||||
|
size: 2622
|
||||||
|
sha256: 28d21bf061522fc513f040e9c383b90532222b7258216cc094e07736add8be10
|
||||||
|
original:
|
||||||
|
hackage: hasql-1.6.1.1
|
||||||
|
- completed:
|
||||||
|
hackage: hasql-dynamic-statements-0.3.1.2@sha256:417aa533c84f074e2fa16bb2c4d4231326aa512097dd1025d915388e56acd1eb,2675
|
||||||
pantry-tree:
|
pantry-tree:
|
||||||
size: 595
|
size: 595
|
||||||
sha256: b84ae10a5c776f88f546df73bc957a35e61056400b7e805dad0b254612907e97
|
sha256: 91696d3f3e0ef3254772ae5a8e4e89be68285febb49b302ed83d85ac4037a417
|
||||||
original:
|
original:
|
||||||
hackage: hasql-dynamic-statements-0.3.1.1@sha256:2cfe6e75990e690f595a87cbe553f2e90fcd738610f6c66749c81cc4396b2cc4,2675
|
hackage: hasql-dynamic-statements-0.3.1.2
|
||||||
- completed:
|
- completed:
|
||||||
hackage: hasql-implicits-0.1.0.4@sha256:0848d3cbc9d94e1e539948fa0be4d0326b26335034161bf8076785293444ca6f,1361
|
hackage: hasql-implicits-0.1.0.5@sha256:d16aacad6dc21428d72447d3ae8bcc03839a2f0aa1ec29c797ed9aca4609f9af,1361
|
||||||
pantry-tree:
|
pantry-tree:
|
||||||
size: 264
|
size: 264
|
||||||
sha256: d49af8f8749ab7039fa668af4b78f997f7fa2928b4aded6798f573a3d08e76a0
|
sha256: 0451b99a0a1d02db673d0c40acdf60d4e769e15852eed9e8dc05bffaf43efb70
|
||||||
original:
|
original:
|
||||||
hackage: hasql-implicits-0.1.0.4@sha256:0848d3cbc9d94e1e539948fa0be4d0326b26335034161bf8076785293444ca6f,1361
|
hackage: hasql-implicits-0.1.0.5
|
||||||
- completed:
|
- completed:
|
||||||
hackage: hasql-pool-0.5.2.2@sha256:b56d4dea112d97a2ef4b2749508c0ca646828cb2d77b827e8dc433d249bb2062,2438
|
hackage: hasql-notifications-0.2.0.3@sha256:aca3f7ee847a8f0b7ef6f989dc48f4a094a06c1a34e92aa3c8bb230085966ea6,2027
|
||||||
pantry-tree:
|
pantry-tree:
|
||||||
size: 412
|
size: 452
|
||||||
sha256: 2741a33f947d28b4076c798c20c1f646beecd21f5eaf522c8256cbeb34d4d6d0
|
sha256: 999f0f2856a00d21f4498a8a58452bbefc4ea972fe2984fd234a68a5fe61d98b
|
||||||
original:
|
original:
|
||||||
hackage: hasql-pool-0.5.2.2@sha256:b56d4dea112d97a2ef4b2749508c0ca646828cb2d77b827e8dc433d249bb2062,2438
|
hackage: hasql-notifications-0.2.0.3
|
||||||
|
- completed:
|
||||||
|
hackage: hasql-pool-0.8.0.6@sha256:b63bb83409bab5bc20ff24f5d62205e9b117701a0fc24531ddeac20ab8c2a42c,1818
|
||||||
|
pantry-tree:
|
||||||
|
size: 346
|
||||||
|
sha256: c4100946b7eae44375511e35a393abe2e1db0e5637c68cea8f53176b796bfd5b
|
||||||
|
original:
|
||||||
|
hackage: hasql-pool-0.8.0.6
|
||||||
|
- completed:
|
||||||
|
hackage: hasql-transaction-1.0.1.2@sha256:297b158cd1f0727f9b0e175bd7d3741c1bcb725a8094956d0ee79b41aafdb30a,2890
|
||||||
|
pantry-tree:
|
||||||
|
size: 983
|
||||||
|
sha256: 3679e6d5c835cc17a8fa0c252b8221e282880044b7219aa1de2531bbd5c40691
|
||||||
|
original:
|
||||||
|
hackage: hasql-transaction-1.0.1.2
|
||||||
|
- completed:
|
||||||
|
hackage: isomorphism-class-0.1.0.6@sha256:d93da31287359c761953b876354de28381f409c5c50e3241c572a443e50c553d,1703
|
||||||
|
pantry-tree:
|
||||||
|
size: 465
|
||||||
|
sha256: c97f922d1ae8f1a0db4c28fac9383d2716934879e95ff0b2b88ebb861d6fba14
|
||||||
|
original:
|
||||||
|
hackage: isomorphism-class-0.1.0.6
|
||||||
- completed:
|
- completed:
|
||||||
hackage: lens-aeson-1.1.3@sha256:52c8eaecd2d1c2a969c0762277c4a8ee72c339a686727d5785932e72ef9c3050,1764
|
hackage: lens-aeson-1.1.3@sha256:52c8eaecd2d1c2a969c0762277c4a8ee72c339a686727d5785932e72ef9c3050,1764
|
||||||
pantry-tree:
|
pantry-tree:
|
||||||
size: 541
|
size: 541
|
||||||
sha256: b31392b78f2a03111c805f4400007778eb93b49f998ab41dfbebaaf9b5526bad
|
sha256: b31392b78f2a03111c805f4400007778eb93b49f998ab41dfbebaaf9b5526bad
|
||||||
original:
|
original:
|
||||||
hackage: lens-aeson-1.1.3@sha256:52c8eaecd2d1c2a969c0762277c4a8ee72c339a686727d5785932e72ef9c3050,1764
|
hackage: lens-aeson-1.1.3
|
||||||
- completed:
|
- completed:
|
||||||
hackage: optparse-applicative-0.16.1.0@sha256:418c22ed6a19124d457d96bc66bd22c93ac22fad0c7100fe4972bbb4ac989731,4982
|
hackage: optparse-applicative-0.16.1.0@sha256:418c22ed6a19124d457d96bc66bd22c93ac22fad0c7100fe4972bbb4ac989731,4982
|
||||||
pantry-tree:
|
pantry-tree:
|
||||||
size: 2979
|
size: 2979
|
||||||
sha256: dd092d843091c08691485d68a1908517079b1bc6f3d73928f37635a19dc27fc1
|
sha256: dd092d843091c08691485d68a1908517079b1bc6f3d73928f37635a19dc27fc1
|
||||||
original:
|
original:
|
||||||
hackage: optparse-applicative-0.16.1.0@sha256:418c22ed6a19124d457d96bc66bd22c93ac22fad0c7100fe4972bbb4ac989731,4982
|
hackage: optparse-applicative-0.16.1.0
|
||||||
|
- completed:
|
||||||
|
hackage: postgresql-binary-0.12.5@sha256:de9da3cba9be541d6c75ae8da2858c33d83dc1b2e0c639b0b9781816b78a91f4,5594
|
||||||
|
pantry-tree:
|
||||||
|
size: 1619
|
||||||
|
sha256: b392337f91031a5b3407393e2f04dfe4e7a28019e88eae6a9370538b90e28c51
|
||||||
|
original:
|
||||||
|
hackage: postgresql-binary-0.12.5
|
||||||
- completed:
|
- completed:
|
||||||
hackage: protolude-0.3.2@sha256:2a38b3dad40d238ab644e234b692c8911423f9d3ed0e36b62287c4a698d92cd1,2240
|
hackage: protolude-0.3.2@sha256:2a38b3dad40d238ab644e234b692c8911423f9d3ed0e36b62287c4a698d92cd1,2240
|
||||||
pantry-tree:
|
pantry-tree:
|
||||||
size: 1594
|
size: 1594
|
||||||
sha256: a36d2912ac552d950ba4476de7d950b56b82dd28e48b9f4d0efee938f10bc525
|
sha256: a36d2912ac552d950ba4476de7d950b56b82dd28e48b9f4d0efee938f10bc525
|
||||||
original:
|
original:
|
||||||
hackage: protolude-0.3.2@sha256:2a38b3dad40d238ab644e234b692c8911423f9d3ed0e36b62287c4a698d92cd1,2240
|
hackage: protolude-0.3.2
|
||||||
- completed:
|
- completed:
|
||||||
hackage: ptr-0.16.8.2@sha256:708ebb95117f2872d2c5a554eb6804cf1126e86abe793b2673f913f14e5eb1ac,3959
|
hackage: ptr-0.16.8.2@sha256:708ebb95117f2872d2c5a554eb6804cf1126e86abe793b2673f913f14e5eb1ac,3959
|
||||||
pantry-tree:
|
pantry-tree:
|
||||||
size: 1303
|
size: 1303
|
||||||
sha256: 557c438345de19f82bf01d676100da2a191ef06f624e7a4b90b09ac17cbb52a5
|
sha256: 557c438345de19f82bf01d676100da2a191ef06f624e7a4b90b09ac17cbb52a5
|
||||||
original:
|
original:
|
||||||
hackage: ptr-0.16.8.2@sha256:708ebb95117f2872d2c5a554eb6804cf1126e86abe793b2673f913f14e5eb1ac,3959
|
hackage: ptr-0.16.8.2
|
||||||
|
- completed:
|
||||||
|
hackage: text-builder-0.6.7@sha256:efbb3e06107e9c8d1cfe85c963938ca9f375a74379af03da3173be4ef5c37bcf,2364
|
||||||
|
pantry-tree:
|
||||||
|
size: 425
|
||||||
|
sha256: cd0ae197e6f9f3860a8ab71f5b87c4a8452ed1fce2fdfd35e36d68ded6e6648e
|
||||||
|
original:
|
||||||
|
hackage: text-builder-0.6.7
|
||||||
|
- completed:
|
||||||
|
hackage: text-builder-dev-0.3.3@sha256:79ec422defcc2e5b34f94129c72b98d34b2efc1ed8bbd945ccb8f4f535a892c3,2784
|
||||||
|
pantry-tree:
|
||||||
|
size: 724
|
||||||
|
sha256: 8883631a132438e7892fcb13e89d6bbcdc0ac76c56fbea8df8d7aa482ce81f73
|
||||||
|
original:
|
||||||
|
hackage: text-builder-dev-0.3.3
|
||||||
|
- completed:
|
||||||
|
name: postgresql-libpq
|
||||||
|
version: 0.9.4.3
|
||||||
|
git: https://github.com/PostgREST/postgresql-libpq.git
|
||||||
|
pantry-tree:
|
||||||
|
size: 1081
|
||||||
|
sha256: 0df271e48af32eb8292a45301af45e114110d54099ee73dbc609d39770e8175e
|
||||||
|
commit: 33ff97db570b5b432255f5f24a68db51453f6eb8
|
||||||
|
original:
|
||||||
|
git: https://github.com/PostgREST/postgresql-libpq.git
|
||||||
|
commit: 33ff97db570b5b432255f5f24a68db51453f6eb8
|
||||||
snapshots:
|
snapshots:
|
||||||
- completed:
|
- completed:
|
||||||
size: 618951
|
size: 618951
|
||||||
|
|||||||
+3
-2
@@ -13,6 +13,7 @@ main =
|
|||||||
, "-XStandaloneDeriving"
|
, "-XStandaloneDeriving"
|
||||||
, "-isrc"
|
, "-isrc"
|
||||||
, "src/PostgREST/Query/SqlFragment.hs"
|
, "src/PostgREST/Query/SqlFragment.hs"
|
||||||
, "src/PostgREST/Request/Preferences.hs"
|
, "src/PostgREST/ApiRequest/Preferences.hs"
|
||||||
, "src/PostgREST/Request/QueryParams.hs"
|
, "src/PostgREST/ApiRequest/QueryParams.hs"
|
||||||
|
, "src/PostgREST/Error.hs"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent
|
||||||
|
CONFIGSDIR = BASEDIR / "configs"
|
||||||
|
FIXTURES = yaml.load((BASEDIR / "fixtures.yaml").read_text(), Loader=yaml.Loader)
|
||||||
|
POSTGREST_BIN = shutil.which("postgrest")
|
||||||
|
SECRET = "reallyreallyreallyreallyverysafe"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dburi():
|
||||||
|
"Postgres database connection URI."
|
||||||
|
dbname = os.environ["PGDATABASE"]
|
||||||
|
host = os.environ["PGHOST"]
|
||||||
|
user = os.environ["PGUSER"]
|
||||||
|
return f"postgresql://?dbname={dbname}&host={host}&user={user}".encode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def baseenv():
|
||||||
|
"Base environment to connect to PostgreSQL"
|
||||||
|
return {
|
||||||
|
"PGDATABASE": os.environ["PGDATABASE"],
|
||||||
|
"PGHOST": os.environ["PGHOST"],
|
||||||
|
"PGUSER": os.environ["PGUSER"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def defaultenv(baseenv):
|
||||||
|
"Default environment for PostgREST."
|
||||||
|
return {
|
||||||
|
**baseenv,
|
||||||
|
"PGRST_DB_CONFIG": "true",
|
||||||
|
"PGRST_LOG_LEVEL": "info",
|
||||||
|
"PGRST_DB_POOL": "1",
|
||||||
|
"PGRST_NOT_EXISTING": "should not break any tests",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hpctixfile():
|
||||||
|
"Returns an individual filename for each test, if the HPCTIXFILE environment variable is set."
|
||||||
|
if "HPCTIXFILE" not in os.environ:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
tixfile = pathlib.Path(os.environ["HPCTIXFILE"])
|
||||||
|
test = hash(os.environ["PYTEST_CURRENT_TEST"])
|
||||||
|
return tixfile.with_suffix(f".{test}.tix")
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
# Not the default, but only works with PG* variables, which are not set
|
# Not the default, but only works with PG* variables, which are not set
|
||||||
db-config = false
|
db-config = false
|
||||||
|
# not existing config options should not break tests
|
||||||
|
not-existing = "should succeed"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
|
|||||||
db-max-rows = 1000
|
db-max-rows = 1000
|
||||||
db-plan-enabled = false
|
db-plan-enabled = false
|
||||||
db-pool = 10
|
db-pool = 10
|
||||||
db-pool-timeout = 3600
|
db-pool-acquisition-timeout = ""
|
||||||
db-pre-request = "check_alias"
|
db-pre-request = "check_alias"
|
||||||
db-prepared-statements = true
|
db-prepared-statements = true
|
||||||
db-root-spec = "open_alias"
|
db-root-spec = "open_alias"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
|
|||||||
db-max-rows = ""
|
db-max-rows = ""
|
||||||
db-plan-enabled = false
|
db-plan-enabled = false
|
||||||
db-pool = 10
|
db-pool = 10
|
||||||
db-pool-timeout = 3600
|
db-pool-acquisition-timeout = ""
|
||||||
db-pre-request = ""
|
db-pre-request = ""
|
||||||
db-prepared-statements = false
|
db-prepared-statements = false
|
||||||
db-root-spec = ""
|
db-root-spec = ""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
|
|||||||
db-max-rows = ""
|
db-max-rows = ""
|
||||||
db-plan-enabled = false
|
db-plan-enabled = false
|
||||||
db-pool = 10
|
db-pool = 10
|
||||||
db-pool-timeout = 3600
|
db-pool-acquisition-timeout = ""
|
||||||
db-pre-request = ""
|
db-pre-request = ""
|
||||||
db-prepared-statements = false
|
db-prepared-statements = false
|
||||||
db-root-spec = ""
|
db-root-spec = ""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
|
|||||||
db-max-rows = ""
|
db-max-rows = ""
|
||||||
db-plan-enabled = false
|
db-plan-enabled = false
|
||||||
db-pool = 10
|
db-pool = 10
|
||||||
db-pool-timeout = 3600
|
db-pool-acquisition-timeout = ""
|
||||||
db-pre-request = ""
|
db-pre-request = ""
|
||||||
db-prepared-statements = true
|
db-prepared-statements = true
|
||||||
db-root-spec = ""
|
db-root-spec = ""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db-extra-search-path = "public,extensions,other"
|
|||||||
db-max-rows = 100
|
db-max-rows = 100
|
||||||
db-plan-enabled = true
|
db-plan-enabled = true
|
||||||
db-pool = 1
|
db-pool = 1
|
||||||
db-pool-timeout = 100
|
db-pool-acquisition-timeout = 10
|
||||||
db-pre-request = "test.other_custom_headers"
|
db-pre-request = "test.other_custom_headers"
|
||||||
db-prepared-statements = false
|
db-prepared-statements = false
|
||||||
db-root-spec = "other_root"
|
db-root-spec = "other_root"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db-extra-search-path = "public,extensions,private"
|
|||||||
db-max-rows = 1000
|
db-max-rows = 1000
|
||||||
db-plan-enabled = true
|
db-plan-enabled = true
|
||||||
db-pool = 1
|
db-pool = 1
|
||||||
db-pool-timeout = 100
|
db-pool-acquisition-timeout = 10
|
||||||
db-pre-request = "test.custom_headers"
|
db-pre-request = "test.custom_headers"
|
||||||
db-prepared-statements = false
|
db-prepared-statements = false
|
||||||
db-root-spec = "root"
|
db-root-spec = "root"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db-extra-search-path = "public,test"
|
|||||||
db-max-rows = 1000
|
db-max-rows = 1000
|
||||||
db-plan-enabled = true
|
db-plan-enabled = true
|
||||||
db-pool = 1
|
db-pool = 1
|
||||||
db-pool-timeout = 100
|
db-pool-acquisition-timeout = 10
|
||||||
db-pre-request = "please_run_fast"
|
db-pre-request = "please_run_fast"
|
||||||
db-prepared-statements = false
|
db-prepared-statements = false
|
||||||
db-root-spec = "openapi_v3"
|
db-root-spec = "openapi_v3"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db-extra-search-path = "public"
|
|||||||
db-max-rows = ""
|
db-max-rows = ""
|
||||||
db-plan-enabled = false
|
db-plan-enabled = false
|
||||||
db-pool = 10
|
db-pool = 10
|
||||||
db-pool-timeout = 3600
|
db-pool-acquisition-timeout = ""
|
||||||
db-pre-request = ""
|
db-pre-request = ""
|
||||||
db-prepared-statements = true
|
db-prepared-statements = true
|
||||||
db-root-spec = ""
|
db-root-spec = ""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ PGRST_DB_EXTRA_SEARCH_PATH: public, test
|
|||||||
PGRST_DB_MAX_ROWS: 1000
|
PGRST_DB_MAX_ROWS: 1000
|
||||||
PGRST_DB_PLAN_ENABLED: true
|
PGRST_DB_PLAN_ENABLED: true
|
||||||
PGRST_DB_POOL: 1
|
PGRST_DB_POOL: 1
|
||||||
PGRST_DB_POOL_TIMEOUT: 100
|
PGRST_DB_POOL_ACQUISITION_TIMEOUT: 10
|
||||||
PGRST_DB_PREPARED_STATEMENTS: false
|
PGRST_DB_PREPARED_STATEMENTS: false
|
||||||
PGRST_DB_PRE_REQUEST: please_run_fast
|
PGRST_DB_PRE_REQUEST: please_run_fast
|
||||||
PGRST_DB_ROOT_SPEC: openapi_v3
|
PGRST_DB_ROOT_SPEC: openapi_v3
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db-extra-search-path = "public, test"
|
|||||||
db-max-rows = 1000
|
db-max-rows = 1000
|
||||||
db-plan-enabled = true
|
db-plan-enabled = true
|
||||||
db-pool = 1
|
db-pool = 1
|
||||||
db-pool-timeout = 100
|
db-pool-acquisition-timeout = 10
|
||||||
db-pre-request = "please_run_fast"
|
db-pre-request = "please_run_fast"
|
||||||
db-prepared-statements = false
|
db-prepared-statements = false
|
||||||
db-root-spec = "openapi_v3"
|
db-root-spec = "openapi_v3"
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ ALTER ROLE db_config_authenticator SET pgrst.db_prepared_statements = 'false';
|
|||||||
ALTER ROLE db_config_authenticator SET pgrst.db_pre_request = 'test.custom_headers';
|
ALTER ROLE db_config_authenticator SET pgrst.db_pre_request = 'test.custom_headers';
|
||||||
ALTER ROLE db_config_authenticator SET pgrst.db_max_rows = '1000';
|
ALTER ROLE db_config_authenticator SET pgrst.db_max_rows = '1000';
|
||||||
ALTER ROLE db_config_authenticator SET pgrst.db_extra_search_path = 'public, extensions';
|
ALTER ROLE db_config_authenticator SET pgrst.db_extra_search_path = 'public, extensions';
|
||||||
|
ALTER ROLE db_config_authenticator SET pgrst.not_existing = 'should be ignored';
|
||||||
|
|
||||||
-- override with database specific setting
|
-- override with database specific setting
|
||||||
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.jwt_secret = 'OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE';
|
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.jwt_secret = 'OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE';
|
||||||
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.db_extra_search_path = 'public, extensions, private';
|
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.db_extra_search_path = 'public, extensions, private';
|
||||||
|
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.not_existing = 'should be ignored';
|
||||||
|
|
||||||
-- other database settings that should be ignored
|
-- other database settings that should be ignored
|
||||||
CREATE DATABASE other;
|
CREATE DATABASE other;
|
||||||
|
|||||||
@@ -86,3 +86,15 @@ $$ language sql;
|
|||||||
create or replace function hello() returns text as $$
|
create or replace function hello() returns text as $$
|
||||||
select 'hello';
|
select 'hello';
|
||||||
$$ language sql;
|
$$ language sql;
|
||||||
|
|
||||||
|
create table cats(id uuid primary key, name text);
|
||||||
|
grant all on cats to postgrest_test_anonymous;
|
||||||
|
|
||||||
|
create function drop_change_cats() returns void
|
||||||
|
language sql security definer
|
||||||
|
as $$
|
||||||
|
drop table cats;
|
||||||
|
create table cats(id bigint primary key, name text);
|
||||||
|
grant all on table cats to postgrest_test_anonymous;
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
|
$$;
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"Fixtures to run PostgREST as a server."
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import dataclasses
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import requests_unixsocket
|
||||||
|
|
||||||
|
from config import *
|
||||||
|
|
||||||
|
|
||||||
|
class PostgrestTimedOut(Exception):
|
||||||
|
"Connecting to PostgREST endpoint timed out."
|
||||||
|
|
||||||
|
|
||||||
|
class PostgrestSession(requests_unixsocket.Session):
|
||||||
|
"HTTP client session directed at a PostgREST endpoint."
|
||||||
|
|
||||||
|
def __init__(self, baseurl, *args, **kwargs):
|
||||||
|
super(PostgrestSession, self).__init__(*args, **kwargs)
|
||||||
|
self.baseurl = baseurl
|
||||||
|
|
||||||
|
def request(self, method, url, *args, **kwargs):
|
||||||
|
# Not using urllib.parse.urljoin to compose the url, as it doesn't play
|
||||||
|
# well with our 'http+unix://' unix domain socket urls.
|
||||||
|
fullurl = self.baseurl + url
|
||||||
|
return super(PostgrestSession, self).request(method, fullurl, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class PostgrestProcess:
|
||||||
|
"Running PostgREST process and its corresponding main and admin endpoints."
|
||||||
|
admin: object
|
||||||
|
process: object
|
||||||
|
session: object
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def run(
|
||||||
|
configpath=None,
|
||||||
|
stdin=None,
|
||||||
|
env=None,
|
||||||
|
port=None,
|
||||||
|
host=None,
|
||||||
|
wait_for_readiness=True,
|
||||||
|
no_pool_connection_available=False,
|
||||||
|
):
|
||||||
|
"Run PostgREST and yield an endpoint that is ready for connections."
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
if port:
|
||||||
|
env["PGRST_SERVER_PORT"] = str(port)
|
||||||
|
env["PGRST_SERVER_HOST"] = host or "localhost"
|
||||||
|
baseurl = f"http://localhost:{port}"
|
||||||
|
else:
|
||||||
|
socketfile = pathlib.Path(tmpdir) / "postgrest.sock"
|
||||||
|
env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile)
|
||||||
|
baseurl = "http+unix://" + urllib.parse.quote_plus(str(socketfile))
|
||||||
|
|
||||||
|
adminport = freeport()
|
||||||
|
env["PGRST_ADMIN_SERVER_PORT"] = str(adminport)
|
||||||
|
adminurl = f"http://localhost:{adminport}"
|
||||||
|
|
||||||
|
command = [POSTGREST_BIN]
|
||||||
|
env["HPCTIXFILE"] = hpctixfile()
|
||||||
|
|
||||||
|
if configpath:
|
||||||
|
command.append(configpath)
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
os.set_blocking(process.stdout.fileno(), False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process.stdin.write(stdin or b"")
|
||||||
|
process.stdin.close()
|
||||||
|
|
||||||
|
if wait_for_readiness:
|
||||||
|
wait_until_ready(adminurl + "/ready")
|
||||||
|
|
||||||
|
process.stdout.read()
|
||||||
|
|
||||||
|
if no_pool_connection_available:
|
||||||
|
sleep_pool_connection(baseurl, 10)
|
||||||
|
|
||||||
|
yield PostgrestProcess(
|
||||||
|
process=process,
|
||||||
|
session=PostgrestSession(baseurl),
|
||||||
|
admin=PostgrestSession(adminurl),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
remaining_output = process.stdout.read()
|
||||||
|
if remaining_output:
|
||||||
|
print(remaining_output.decode())
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=1)
|
||||||
|
except:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def metapostgrest():
|
||||||
|
"A shared postgrest instance to use for interacting with the database independently of the instance under test"
|
||||||
|
role = "meta_authenticator"
|
||||||
|
env = {
|
||||||
|
"PGDATABASE": os.environ["PGDATABASE"],
|
||||||
|
"PGHOST": os.environ["PGHOST"],
|
||||||
|
"PGUSER": role,
|
||||||
|
"PGRST_DB_ANON_ROLE": role,
|
||||||
|
"PGRST_DB_CONFIG": "true",
|
||||||
|
"PGRST_LOG_LEVEL": "info",
|
||||||
|
"PGRST_DB_POOL": "1",
|
||||||
|
}
|
||||||
|
with run(env=env) as postgrest:
|
||||||
|
yield postgrest
|
||||||
|
|
||||||
|
|
||||||
|
def freeport():
|
||||||
|
"Find a free port on localhost."
|
||||||
|
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||||
|
s.bind(("", 0))
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def wait_until_exit(postgrest):
|
||||||
|
"Wait for PostgREST to exit, or times out"
|
||||||
|
try:
|
||||||
|
return postgrest.process.wait(timeout=1)
|
||||||
|
except (subprocess.TimeoutExpired):
|
||||||
|
raise PostgrestTimedOut()
|
||||||
|
|
||||||
|
|
||||||
|
def wait_until_ready(url):
|
||||||
|
"Wait for the given HTTP endpoint to return a status of 200."
|
||||||
|
session = requests_unixsocket.Session()
|
||||||
|
|
||||||
|
response = None
|
||||||
|
for _ in range(10):
|
||||||
|
try:
|
||||||
|
response = session.get(url, timeout=1)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return
|
||||||
|
except (requests.ConnectionError, requests.ReadTimeout):
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
raise PostgrestTimedOut(f"{response.status_code}: {response.text}")
|
||||||
|
else:
|
||||||
|
raise PostgrestTimedOut()
|
||||||
|
|
||||||
|
|
||||||
|
def sleep_pool_connection(url, seconds):
|
||||||
|
"Sleep a pool connection by calling an RPC that uses pg_sleep"
|
||||||
|
session = requests_unixsocket.Session()
|
||||||
|
|
||||||
|
# The try/except is a hack for not waiting for the response,
|
||||||
|
# taken from https://stackoverflow.com/a/45601591/4692662
|
||||||
|
try:
|
||||||
|
session.get(url + f"/rpc/sleep?seconds={seconds}", timeout=0.1)
|
||||||
|
except requests.exceptions.ReadTimeout:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"Unit tests for Input/Ouput of PostgREST seen as a black box."
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import dataclasses
|
||||||
|
from datetime import datetime
|
||||||
|
from itertools import repeat
|
||||||
|
from operator import attrgetter
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import requests_unixsocket
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from config import *
|
||||||
|
|
||||||
|
|
||||||
|
def itemgetter(*items):
|
||||||
|
"operator.itemgetter with None as fallback when key does not exist"
|
||||||
|
if len(items) == 1:
|
||||||
|
item = items[0]
|
||||||
|
|
||||||
|
def g(obj):
|
||||||
|
return obj.get(item)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def g(obj):
|
||||||
|
return tuple(obj.get(item) for item in items)
|
||||||
|
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
class PostgrestError(Exception):
|
||||||
|
"Postgrest exited with a non-zero return code."
|
||||||
|
|
||||||
|
|
||||||
|
def cli(args, env=None, stdin=None):
|
||||||
|
"Run PostgREST and return stdout."
|
||||||
|
env = env or {}
|
||||||
|
|
||||||
|
command = [POSTGREST_BIN] + args
|
||||||
|
env["HPCTIXFILE"] = hpctixfile()
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
process.stdin.write(stdin or b"")
|
||||||
|
try:
|
||||||
|
result = process.communicate(timeout=5)[0]
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise PostgrestError()
|
||||||
|
return result.decode()
|
||||||
|
finally:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def dumpconfig(configpath=None, env=None, stdin=None):
|
||||||
|
"Dump the config as parsed by PostgREST."
|
||||||
|
args = ["--dump-config"]
|
||||||
|
|
||||||
|
if configpath:
|
||||||
|
args.append(configpath)
|
||||||
|
|
||||||
|
return cli(args, env=env, stdin=stdin)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"args,env,use_defaultenv,expect",
|
||||||
|
map(itemgetter("args", "env", "use_defaultenv", "expect"), FIXTURES["cli"]),
|
||||||
|
ids=map(itemgetter("name"), FIXTURES["cli"]),
|
||||||
|
)
|
||||||
|
def test_cli(args, env, use_defaultenv, expect, defaultenv):
|
||||||
|
"""
|
||||||
|
When PostgREST is run with <args> arguments and <env>/<defaultenv> environment variabales
|
||||||
|
it should return. Exit code should be according to <expect_error>.
|
||||||
|
"""
|
||||||
|
# use --dump-config by default to make sure that the postgrest process will terminate for sure
|
||||||
|
args = args or ["--dump-config"]
|
||||||
|
|
||||||
|
env = env or {}
|
||||||
|
if use_defaultenv:
|
||||||
|
env = {**defaultenv, **env}
|
||||||
|
|
||||||
|
if expect == "error":
|
||||||
|
with pytest.raises(PostgrestError):
|
||||||
|
print(cli(args, env=env))
|
||||||
|
else:
|
||||||
|
dump = cli(args, env=env).split("\n")
|
||||||
|
if expect:
|
||||||
|
assert expect in dump
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"expectedconfig",
|
||||||
|
[
|
||||||
|
expectedconfig
|
||||||
|
for expectedconfig in (CONFIGSDIR / "expected").iterdir()
|
||||||
|
if (CONFIGSDIR / expectedconfig.name).exists()
|
||||||
|
],
|
||||||
|
ids=attrgetter("name"),
|
||||||
|
)
|
||||||
|
def test_expected_config(expectedconfig):
|
||||||
|
"""
|
||||||
|
Configs as dumped by PostgREST should match an expected output.
|
||||||
|
|
||||||
|
Used to test default values, config aliases and environment variables. The
|
||||||
|
expected output for each file in 'configs', if available, is found in the
|
||||||
|
'configs/expected' directory.
|
||||||
|
|
||||||
|
"""
|
||||||
|
expected = expectedconfig.read_text()
|
||||||
|
config = CONFIGSDIR / expectedconfig.name
|
||||||
|
|
||||||
|
assert dumpconfig(config) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_config_from_environment():
|
||||||
|
"Config should be read directly from environment without config file."
|
||||||
|
|
||||||
|
envfile = (CONFIGSDIR / "no-defaults-env.yaml").read_text()
|
||||||
|
env = {k: str(v) for k, v in yaml.load(envfile, Loader=yaml.Loader).items()}
|
||||||
|
|
||||||
|
expected = (CONFIGSDIR / "expected" / "no-defaults.config").read_text()
|
||||||
|
assert dumpconfig(env=env) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role, expectedconfig",
|
||||||
|
[
|
||||||
|
("db_config_authenticator", "no-defaults-with-db.config"),
|
||||||
|
("other_authenticator", "no-defaults-with-db-other-authenticator.config"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_expected_config_from_db_settings(baseenv, role, expectedconfig):
|
||||||
|
"Config should be overriden from database settings"
|
||||||
|
|
||||||
|
config = CONFIGSDIR / "no-defaults.config"
|
||||||
|
|
||||||
|
env = {
|
||||||
|
**baseenv,
|
||||||
|
"PGUSER": role,
|
||||||
|
"PGRST_DB_URI": "postgresql://",
|
||||||
|
"PGRST_DB_CONFIG": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
expected = (CONFIGSDIR / "expected" / expectedconfig).read_text()
|
||||||
|
assert dumpconfig(configpath=config, env=env) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"config",
|
||||||
|
[conf for conf in CONFIGSDIR.iterdir() if conf.suffix == ".config"],
|
||||||
|
ids=attrgetter("name"),
|
||||||
|
)
|
||||||
|
def test_stable_config(tmp_path, config, defaultenv):
|
||||||
|
"""
|
||||||
|
A dumped, re-read and re-dumped config should match the dumped config.
|
||||||
|
|
||||||
|
Note: only dump vs. re-dump must be equal, as the original config file might
|
||||||
|
be different because of default values, whitespace, and quoting.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Set environment variables that some of the configs expect. Using a
|
||||||
|
# complex ROLE_CLAIM_KEY to make sure quoting works.
|
||||||
|
env = {
|
||||||
|
**defaultenv,
|
||||||
|
"ROLE_CLAIM_KEY": '."https://www.example.com/roles"[0].value',
|
||||||
|
"POSTGREST_TEST_SOCKET": "/tmp/postgrest.sock",
|
||||||
|
"POSTGREST_TEST_PORT": "80",
|
||||||
|
"JWT_SECRET_FILE": "a_file",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Some configs expect input from stdin, at least on base64.
|
||||||
|
stdin = b"Y29ubmVjdGlvbl9zdHJpbmc="
|
||||||
|
|
||||||
|
dumped = dumpconfig(config, env=env, stdin=stdin)
|
||||||
|
|
||||||
|
tmpconfigpath = tmp_path / "config"
|
||||||
|
tmpconfigpath.write_text(dumped)
|
||||||
|
redumped = dumpconfig(tmpconfigpath, env=env)
|
||||||
|
|
||||||
|
assert dumped == redumped
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalidroleclaimkey", FIXTURES["invalidroleclaimkeys"])
|
||||||
|
def test_invalid_role_claim_key(invalidroleclaimkey, defaultenv):
|
||||||
|
"Given an invalid role-claim-key, Postgrest should exit with a non-zero exit code."
|
||||||
|
env = {
|
||||||
|
**defaultenv,
|
||||||
|
"PGRST_JWT_ROLE_CLAIM_KEY": invalidroleclaimkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(PostgrestError):
|
||||||
|
dump = dumpconfig(env=env)
|
||||||
|
for line in dump.split("\n"):
|
||||||
|
if line.startswith("jwt-role-claim-key"):
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalidopenapimodes", FIXTURES["invalidopenapimodes"])
|
||||||
|
def test_invalid_openapi_mode(invalidopenapimodes, defaultenv):
|
||||||
|
"Given an invalid openapi-mode, Postgrest should exit with a non-zero exit code."
|
||||||
|
env = {
|
||||||
|
**defaultenv,
|
||||||
|
"PGRST_OPENAPI_MODE": invalidopenapimodes,
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(PostgrestError):
|
||||||
|
dump = dumpconfig(CONFIGSDIR / "defaults.config", env=env)
|
||||||
|
for line in dump.split("\n"):
|
||||||
|
if line.startswith("openapi-mode"):
|
||||||
|
print(line)
|
||||||
+90
-457
@@ -1,435 +1,18 @@
|
|||||||
"Unit tests for Input/Ouput of PostgREST seen as a black box."
|
"Unit tests for Input/Ouput of PostgREST seen as a black box."
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import dataclasses
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from itertools import repeat
|
|
||||||
from operator import attrgetter
|
from operator import attrgetter
|
||||||
import os
|
import os
|
||||||
import pathlib
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
|
||||||
import requests_unixsocket
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
from config import *
|
||||||
BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent
|
from util import *
|
||||||
CONFIGSDIR = BASEDIR / "configs"
|
from postgrest import *
|
||||||
FIXTURES = yaml.load((BASEDIR / "fixtures.yaml").read_text(), Loader=yaml.Loader)
|
|
||||||
POSTGREST_BIN = shutil.which("postgrest")
|
|
||||||
SECRET = "reallyreallyreallyreallyverysafe"
|
|
||||||
|
|
||||||
|
|
||||||
def itemgetter(*items):
|
|
||||||
"operator.itemgetter with None as fallback when key does not exist"
|
|
||||||
if len(items) == 1:
|
|
||||||
item = items[0]
|
|
||||||
|
|
||||||
def g(obj):
|
|
||||||
return obj.get(item)
|
|
||||||
|
|
||||||
else:
|
|
||||||
|
|
||||||
def g(obj):
|
|
||||||
return tuple(obj.get(item) for item in items)
|
|
||||||
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
class Thread(threading.Thread):
|
|
||||||
"Variant of threading.Thread that re-raises any exceptions when joining the thread"
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self._exception = None
|
|
||||||
super(Thread, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
try:
|
|
||||||
super(Thread, self).run()
|
|
||||||
except Exception as e:
|
|
||||||
self._exception = e
|
|
||||||
|
|
||||||
def join(self):
|
|
||||||
super(Thread, self).join()
|
|
||||||
if self._exception is not None:
|
|
||||||
raise self._exception
|
|
||||||
|
|
||||||
|
|
||||||
class PostgrestTimedOut(Exception):
|
|
||||||
"Connecting to PostgREST endpoint timed out."
|
|
||||||
|
|
||||||
|
|
||||||
class PostgrestError(Exception):
|
|
||||||
"Postgrest exited with a non-zero return code."
|
|
||||||
|
|
||||||
|
|
||||||
class PostgrestSession(requests_unixsocket.Session):
|
|
||||||
"HTTP client session directed at a PostgREST endpoint."
|
|
||||||
|
|
||||||
def __init__(self, baseurl, *args, **kwargs):
|
|
||||||
super(PostgrestSession, self).__init__(*args, **kwargs)
|
|
||||||
self.baseurl = baseurl
|
|
||||||
|
|
||||||
def request(self, method, url, *args, **kwargs):
|
|
||||||
# Not using urllib.parse.urljoin to compose the url, as it doesn't play
|
|
||||||
# well with our 'http+unix://' unix domain socket urls.
|
|
||||||
fullurl = self.baseurl + url
|
|
||||||
return super(PostgrestSession, self).request(method, fullurl, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class PostgrestProcess:
|
|
||||||
"Running PostgREST process and its corresponding main and admin endpoints."
|
|
||||||
admin: object
|
|
||||||
process: object
|
|
||||||
session: object
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def dburi():
|
|
||||||
"Postgres database connection URI."
|
|
||||||
dbname = os.environ["PGDATABASE"]
|
|
||||||
host = os.environ["PGHOST"]
|
|
||||||
user = os.environ["PGUSER"]
|
|
||||||
return f"postgresql://?dbname={dbname}&host={host}&user={user}".encode()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def baseenv():
|
|
||||||
"Base environment to connect to PostgreSQL"
|
|
||||||
return {
|
|
||||||
"PGDATABASE": os.environ["PGDATABASE"],
|
|
||||||
"PGHOST": os.environ["PGHOST"],
|
|
||||||
"PGUSER": os.environ["PGUSER"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def defaultenv(baseenv):
|
|
||||||
"Default environment for PostgREST."
|
|
||||||
return {
|
|
||||||
**baseenv,
|
|
||||||
"PGRST_DB_CONFIG": "true",
|
|
||||||
"PGRST_LOG_LEVEL": "info",
|
|
||||||
"PGRST_DB_POOL": "1",
|
|
||||||
"PGRST_DB_POOL_TIMEOUT": "1",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def metapostgrest():
|
|
||||||
"A shared postgrest instance to use for interacting with the database independently of the instance under test"
|
|
||||||
role = "meta_authenticator"
|
|
||||||
env = {
|
|
||||||
"PGDATABASE": os.environ["PGDATABASE"],
|
|
||||||
"PGHOST": os.environ["PGHOST"],
|
|
||||||
"PGUSER": role,
|
|
||||||
"PGRST_DB_ANON_ROLE": role,
|
|
||||||
"PGRST_DB_CONFIG": "true",
|
|
||||||
"PGRST_LOG_LEVEL": "info",
|
|
||||||
"PGRST_DB_POOL": "1",
|
|
||||||
"PGRST_DB_POOL_TIMEOUT": "1",
|
|
||||||
}
|
|
||||||
with run(env=env) as postgrest:
|
|
||||||
yield postgrest
|
|
||||||
|
|
||||||
|
|
||||||
def hpctixfile():
|
|
||||||
"Returns an individual filename for each test, if the HPCTIXFILE environment variable is set."
|
|
||||||
if "HPCTIXFILE" not in os.environ:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
tixfile = pathlib.Path(os.environ["HPCTIXFILE"])
|
|
||||||
test = hash(os.environ["PYTEST_CURRENT_TEST"])
|
|
||||||
return tixfile.with_suffix(f".{test}.tix")
|
|
||||||
|
|
||||||
|
|
||||||
def cli(args, env=None, stdin=None):
|
|
||||||
"Run PostgREST and return stdout."
|
|
||||||
env = env or {}
|
|
||||||
|
|
||||||
command = [POSTGREST_BIN] + args
|
|
||||||
env["HPCTIXFILE"] = hpctixfile()
|
|
||||||
|
|
||||||
process = subprocess.Popen(
|
|
||||||
command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
|
|
||||||
)
|
|
||||||
|
|
||||||
process.stdin.write(stdin or b"")
|
|
||||||
try:
|
|
||||||
result = process.communicate(timeout=5)[0]
|
|
||||||
if process.returncode != 0:
|
|
||||||
raise PostgrestError()
|
|
||||||
return result.decode()
|
|
||||||
finally:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
|
|
||||||
def dumpconfig(configpath=None, env=None, stdin=None):
|
|
||||||
"Dump the config as parsed by PostgREST."
|
|
||||||
args = ["--dump-config"]
|
|
||||||
|
|
||||||
if configpath:
|
|
||||||
args.append(configpath)
|
|
||||||
|
|
||||||
return cli(args, env=env, stdin=stdin)
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def run(
|
|
||||||
configpath=None,
|
|
||||||
stdin=None,
|
|
||||||
env=None,
|
|
||||||
port=None,
|
|
||||||
host=None,
|
|
||||||
no_pool_connection_available=False,
|
|
||||||
):
|
|
||||||
"Run PostgREST and yield an endpoint that is ready for connections."
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
if port:
|
|
||||||
env["PGRST_SERVER_PORT"] = str(port)
|
|
||||||
env["PGRST_SERVER_HOST"] = host or "localhost"
|
|
||||||
baseurl = f"http://localhost:{port}"
|
|
||||||
else:
|
|
||||||
socketfile = pathlib.Path(tmpdir) / "postgrest.sock"
|
|
||||||
env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile)
|
|
||||||
baseurl = "http+unix://" + urllib.parse.quote_plus(str(socketfile))
|
|
||||||
|
|
||||||
adminport = freeport()
|
|
||||||
env["PGRST_ADMIN_SERVER_PORT"] = str(adminport)
|
|
||||||
adminurl = f"http://localhost:{adminport}"
|
|
||||||
|
|
||||||
command = [POSTGREST_BIN]
|
|
||||||
env["HPCTIXFILE"] = hpctixfile()
|
|
||||||
|
|
||||||
if configpath:
|
|
||||||
command.append(configpath)
|
|
||||||
|
|
||||||
process = subprocess.Popen(
|
|
||||||
command,
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
|
|
||||||
os.set_blocking(process.stdout.fileno(), False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
process.stdin.write(stdin or b"")
|
|
||||||
process.stdin.close()
|
|
||||||
|
|
||||||
wait_until_ready(adminurl + "/ready")
|
|
||||||
|
|
||||||
process.stdout.read()
|
|
||||||
|
|
||||||
yield PostgrestProcess(
|
|
||||||
process=process,
|
|
||||||
session=PostgrestSession(baseurl),
|
|
||||||
admin=PostgrestSession(adminurl),
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if no_pool_connection_available:
|
|
||||||
sleep_pool_connection(baseurl, 10)
|
|
||||||
|
|
||||||
remaining_output = process.stdout.read()
|
|
||||||
if remaining_output:
|
|
||||||
print(remaining_output.decode())
|
|
||||||
process.terminate()
|
|
||||||
try:
|
|
||||||
process.wait(timeout=1)
|
|
||||||
except:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
|
|
||||||
def freeport():
|
|
||||||
"Find a free port on localhost."
|
|
||||||
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
|
||||||
s.bind(("", 0))
|
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
return s.getsockname()[1]
|
|
||||||
|
|
||||||
|
|
||||||
def wait_until_ready(url):
|
|
||||||
"Wait for the given HTTP endpoint to return a status of 200."
|
|
||||||
session = requests_unixsocket.Session()
|
|
||||||
|
|
||||||
response = None
|
|
||||||
for _ in range(10):
|
|
||||||
try:
|
|
||||||
response = session.get(url, timeout=1)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return
|
|
||||||
except (requests.ConnectionError, requests.ReadTimeout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
if response:
|
|
||||||
raise PostgrestTimedOut(f"{response.status_code}: {response.text}")
|
|
||||||
else:
|
|
||||||
raise PostgrestTimedOut()
|
|
||||||
|
|
||||||
|
|
||||||
def sleep_pool_connection(url, seconds):
|
|
||||||
"Sleep a pool connection by calling an RPC that uses pg_sleep"
|
|
||||||
session = requests_unixsocket.Session()
|
|
||||||
|
|
||||||
# The try/except is a hack for not waiting for the response,
|
|
||||||
# taken from https://stackoverflow.com/a/45601591/4692662
|
|
||||||
try:
|
|
||||||
session.get(url + f"/rpc/sleep?seconds={seconds}", timeout=0.1)
|
|
||||||
except requests.exceptions.ReadTimeout:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def authheader(token):
|
|
||||||
"Bearer token HTTP authorization header."
|
|
||||||
return {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
def jwtauthheader(claim, secret):
|
|
||||||
"Authorization header with signed JWT."
|
|
||||||
return authheader(jwt.encode(claim, secret))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"args,env,use_defaultenv,expect",
|
|
||||||
map(itemgetter("args", "env", "use_defaultenv", "expect"), FIXTURES["cli"]),
|
|
||||||
ids=map(itemgetter("name"), FIXTURES["cli"]),
|
|
||||||
)
|
|
||||||
def test_cli(args, env, use_defaultenv, expect, defaultenv):
|
|
||||||
"""
|
|
||||||
When PostgREST is run with <args> arguments and <env>/<defaultenv> environment variabales
|
|
||||||
it should return. Exit code should be according to <expect_error>.
|
|
||||||
"""
|
|
||||||
# use --dump-config by default to make sure that the postgrest process will terminate for sure
|
|
||||||
args = args or ["--dump-config"]
|
|
||||||
|
|
||||||
env = env or {}
|
|
||||||
if use_defaultenv:
|
|
||||||
env = {**defaultenv, **env}
|
|
||||||
|
|
||||||
if expect == "error":
|
|
||||||
with pytest.raises(PostgrestError):
|
|
||||||
print(cli(args, env=env))
|
|
||||||
else:
|
|
||||||
dump = cli(args, env=env).split("\n")
|
|
||||||
if expect:
|
|
||||||
assert expect in dump
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"expectedconfig",
|
|
||||||
[
|
|
||||||
expectedconfig
|
|
||||||
for expectedconfig in (CONFIGSDIR / "expected").iterdir()
|
|
||||||
if (CONFIGSDIR / expectedconfig.name).exists()
|
|
||||||
],
|
|
||||||
ids=attrgetter("name"),
|
|
||||||
)
|
|
||||||
def test_expected_config(expectedconfig):
|
|
||||||
"""
|
|
||||||
Configs as dumped by PostgREST should match an expected output.
|
|
||||||
|
|
||||||
Used to test default values, config aliases and environment variables. The
|
|
||||||
expected output for each file in 'configs', if available, is found in the
|
|
||||||
'configs/expected' directory.
|
|
||||||
|
|
||||||
"""
|
|
||||||
expected = expectedconfig.read_text()
|
|
||||||
config = CONFIGSDIR / expectedconfig.name
|
|
||||||
|
|
||||||
assert dumpconfig(config) == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_expected_config_from_environment():
|
|
||||||
"Config should be read directly from environment without config file."
|
|
||||||
|
|
||||||
envfile = (CONFIGSDIR / "no-defaults-env.yaml").read_text()
|
|
||||||
env = {k: str(v) for k, v in yaml.load(envfile, Loader=yaml.Loader).items()}
|
|
||||||
|
|
||||||
expected = (CONFIGSDIR / "expected" / "no-defaults.config").read_text()
|
|
||||||
assert dumpconfig(env=env) == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"role, expectedconfig",
|
|
||||||
[
|
|
||||||
("db_config_authenticator", "no-defaults-with-db.config"),
|
|
||||||
("other_authenticator", "no-defaults-with-db-other-authenticator.config"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_expected_config_from_db_settings(baseenv, role, expectedconfig):
|
|
||||||
"Config should be overriden from database settings"
|
|
||||||
|
|
||||||
config = CONFIGSDIR / "no-defaults.config"
|
|
||||||
|
|
||||||
env = {
|
|
||||||
**baseenv,
|
|
||||||
"PGUSER": role,
|
|
||||||
"PGRST_DB_URI": "postgresql://",
|
|
||||||
"PGRST_DB_CONFIG": "true",
|
|
||||||
}
|
|
||||||
|
|
||||||
expected = (CONFIGSDIR / "expected" / expectedconfig).read_text()
|
|
||||||
assert dumpconfig(configpath=config, env=env) == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"config",
|
|
||||||
[conf for conf in CONFIGSDIR.iterdir() if conf.suffix == ".config"],
|
|
||||||
ids=attrgetter("name"),
|
|
||||||
)
|
|
||||||
def test_stable_config(tmp_path, config, defaultenv):
|
|
||||||
"""
|
|
||||||
A dumped, re-read and re-dumped config should match the dumped config.
|
|
||||||
|
|
||||||
Note: only dump vs. re-dump must be equal, as the original config file might
|
|
||||||
be different because of default values, whitespace, and quoting.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Set environment variables that some of the configs expect. Using a
|
|
||||||
# complex ROLE_CLAIM_KEY to make sure quoting works.
|
|
||||||
env = {
|
|
||||||
**defaultenv,
|
|
||||||
"ROLE_CLAIM_KEY": '."https://www.example.com/roles"[0].value',
|
|
||||||
"POSTGREST_TEST_SOCKET": "/tmp/postgrest.sock",
|
|
||||||
"POSTGREST_TEST_PORT": "80",
|
|
||||||
"JWT_SECRET_FILE": "a_file",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Some configs expect input from stdin, at least on base64.
|
|
||||||
stdin = b"Y29ubmVjdGlvbl9zdHJpbmc="
|
|
||||||
|
|
||||||
dumped = dumpconfig(config, env=env, stdin=stdin)
|
|
||||||
|
|
||||||
tmpconfigpath = tmp_path / "config"
|
|
||||||
tmpconfigpath.write_text(dumped)
|
|
||||||
redumped = dumpconfig(tmpconfigpath, env=env)
|
|
||||||
|
|
||||||
assert dumped == redumped
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_connection(defaultenv):
|
|
||||||
"Connections via a port on localhost should work."
|
|
||||||
with run(env=defaultenv, port=freeport()):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -483,6 +66,15 @@ def test_read_secret_from_stdin_dbconfig(defaultenv):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_fail_with_invalid_password(defaultenv):
|
||||||
|
"Connecting with an invalid password should fail without retries."
|
||||||
|
uri = f'postgresql://?dbname={defaultenv["PGDATABASE"]}&host={defaultenv["PGHOST"]}&user=some_protected_user&password=invalid_pass'
|
||||||
|
env = {**defaultenv, "PGRST_DB_URI": uri}
|
||||||
|
with run(env=env, wait_for_readiness=False) as postgrest:
|
||||||
|
exitCode = wait_until_exit(postgrest)
|
||||||
|
assert exitCode == 1
|
||||||
|
|
||||||
|
|
||||||
def test_connect_with_dburi(dburi, defaultenv):
|
def test_connect_with_dburi(dburi, defaultenv):
|
||||||
"Connecting with db-uri instead of LIPQ* environment variables should work."
|
"Connecting with db-uri instead of LIPQ* environment variables should work."
|
||||||
defaultenv_without_libpq = {
|
defaultenv_without_libpq = {
|
||||||
@@ -538,36 +130,6 @@ def test_role_claim_key(roleclaim, defaultenv):
|
|||||||
assert response.status_code == roleclaim["expected_status"]
|
assert response.status_code == roleclaim["expected_status"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("invalidroleclaimkey", FIXTURES["invalidroleclaimkeys"])
|
|
||||||
def test_invalid_role_claim_key(invalidroleclaimkey, defaultenv):
|
|
||||||
"Given an invalid role-claim-key, Postgrest should exit with a non-zero exit code."
|
|
||||||
env = {
|
|
||||||
**defaultenv,
|
|
||||||
"PGRST_JWT_ROLE_CLAIM_KEY": invalidroleclaimkey,
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(PostgrestError):
|
|
||||||
dump = dumpconfig(env=env)
|
|
||||||
for line in dump.split("\n"):
|
|
||||||
if line.startswith("jwt-role-claim-key"):
|
|
||||||
print(line)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("invalidopenapimodes", FIXTURES["invalidopenapimodes"])
|
|
||||||
def test_invalid_openapi_mode(invalidopenapimodes, defaultenv):
|
|
||||||
"Given an invalid openapi-mode, Postgrest should exit with a non-zero exit code."
|
|
||||||
env = {
|
|
||||||
**defaultenv,
|
|
||||||
"PGRST_OPENAPI_MODE": invalidopenapimodes,
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(PostgrestError):
|
|
||||||
dump = dumpconfig(CONFIGSDIR / "defaults.config", env=env)
|
|
||||||
for line in dump.split("\n"):
|
|
||||||
if line.startswith("openapi-mode"):
|
|
||||||
print(line)
|
|
||||||
|
|
||||||
|
|
||||||
def test_iat_claim(defaultenv):
|
def test_iat_claim(defaultenv):
|
||||||
"""
|
"""
|
||||||
A claim with an 'iat' (issued at) attribute should be successful.
|
A claim with an 'iat' (issued at) attribute should be successful.
|
||||||
@@ -590,9 +152,9 @@ def test_iat_claim(defaultenv):
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
def test_app_settings(defaultenv):
|
def test_app_settings_flush_pool(defaultenv):
|
||||||
"""
|
"""
|
||||||
App settings should not reset when the db pool times out.
|
App settings should not reset when the db pool is flushed.
|
||||||
|
|
||||||
See: https://github.com/PostgREST/postgrest/issues/1141
|
See: https://github.com/PostgREST/postgrest/issues/1141
|
||||||
|
|
||||||
@@ -601,15 +163,40 @@ def test_app_settings(defaultenv):
|
|||||||
env = {**defaultenv, "PGRST_APP_SETTINGS_EXTERNAL_API_SECRET": "0123456789abcdef"}
|
env = {**defaultenv, "PGRST_APP_SETTINGS_EXTERNAL_API_SECRET": "0123456789abcdef"}
|
||||||
|
|
||||||
with run(env=env) as postgrest:
|
with run(env=env) as postgrest:
|
||||||
# Wait for the db pool to time out, set to 1s in config
|
uri = "/rpc/get_guc_value?name=app.settings.external_api_secret"
|
||||||
time.sleep(2)
|
response = postgrest.session.get(uri)
|
||||||
|
assert response.text == '"0123456789abcdef"'
|
||||||
|
|
||||||
|
# SIGUSR1 causes the postgres connection pool to be flushed
|
||||||
|
postgrest.process.send_signal(signal.SIGUSR1)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
uri = "/rpc/get_guc_value?name=app.settings.external_api_secret"
|
uri = "/rpc/get_guc_value?name=app.settings.external_api_secret"
|
||||||
response = postgrest.session.get(uri)
|
response = postgrest.session.get(uri)
|
||||||
|
|
||||||
assert response.text == '"0123456789abcdef"'
|
assert response.text == '"0123456789abcdef"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_flush_pool_no_interrupt(defaultenv):
|
||||||
|
"Flushing the pool via SIGUSR1 doesn't interrupt ongoing requests"
|
||||||
|
|
||||||
|
with run(env=defaultenv) as postgrest:
|
||||||
|
|
||||||
|
def sleep():
|
||||||
|
response = postgrest.session.get("/rpc/sleep?seconds=0.5")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
t = Thread(target=sleep)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
# make sure the request has started
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# SIGUSR1 causes the postgres connection pool to be flushed
|
||||||
|
postgrest.process.send_signal(signal.SIGUSR1)
|
||||||
|
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
|
||||||
def test_app_settings_reload(tmp_path, defaultenv):
|
def test_app_settings_reload(tmp_path, defaultenv):
|
||||||
"App settings should be reloaded from file when PostgREST is sent SIGUSR2."
|
"App settings should be reloaded from file when PostgREST is sent SIGUSR2."
|
||||||
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
|
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
|
||||||
@@ -747,7 +334,7 @@ def test_db_schema_notify_reload(defaultenv):
|
|||||||
"/rpc/change_db_schema_and_full_reload", data={"schemas": "v1"}
|
"/rpc/change_db_schema_and_full_reload", data={"schemas": "v1"}
|
||||||
)
|
)
|
||||||
|
|
||||||
time.sleep(0.1)
|
time.sleep(0.2)
|
||||||
|
|
||||||
response = postgrest.session.get("/rpc/get_guc_value?name=search_path")
|
response = postgrest.session.get("/rpc/get_guc_value?name=search_path")
|
||||||
assert response.text == '"\\"v1\\", \\"public\\""'
|
assert response.text == '"\\"v1\\", \\"public\\""'
|
||||||
@@ -970,7 +557,22 @@ def test_pool_size(defaultenv, metapostgrest):
|
|||||||
assert delta > 1 and delta < 1.5
|
assert delta > 1 and delta < 1.5
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="issue #2401")
|
def test_pool_acquisition_timeout(defaultenv, metapostgrest):
|
||||||
|
"Verify that PGRST_DB_POOL_ACQUISITON_TIMEOUT times out when the pool is empty"
|
||||||
|
|
||||||
|
env = {
|
||||||
|
**defaultenv,
|
||||||
|
"PGRST_DB_POOL": "1",
|
||||||
|
"PGRST_DB_POOL_ACQUISITION_TIMEOUT": "1", # 1 second
|
||||||
|
}
|
||||||
|
|
||||||
|
with run(env=env, no_pool_connection_available=True) as postgrest:
|
||||||
|
response = postgrest.session.get("/projects")
|
||||||
|
assert response.status_code == 504
|
||||||
|
data = response.json()
|
||||||
|
assert data["message"] == "Timed out acquiring connection from connection pool."
|
||||||
|
|
||||||
|
|
||||||
def test_change_statement_timeout_held_connection(defaultenv, metapostgrest):
|
def test_change_statement_timeout_held_connection(defaultenv, metapostgrest):
|
||||||
"Statement timeout changes take effect immediately, even with a request outliving the reconfiguration"
|
"Statement timeout changes take effect immediately, even with a request outliving the reconfiguration"
|
||||||
|
|
||||||
@@ -1215,6 +817,37 @@ def test_no_pool_connection_required_on_bad_jwt_claim(defaultenv):
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_pool_connection_required_on_bad_embedding(defaultenv):
|
||||||
|
"no pool connection should be consumed for failing to embed"
|
||||||
|
|
||||||
|
with run(env=defaultenv, no_pool_connection_available=True) as postgrest:
|
||||||
|
|
||||||
|
# OPTIONS on a table shouldn't require opening a connection
|
||||||
|
response = postgrest.session.get("/projects?select=*,unexistent(*)")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_reloading_catalog_cache(defaultenv):
|
||||||
|
"notify should reload the connection catalog cache"
|
||||||
|
|
||||||
|
with run(env=defaultenv) as postgrest:
|
||||||
|
|
||||||
|
# first the id col is an uuid
|
||||||
|
response = postgrest.session.get(
|
||||||
|
"/cats?id=eq.dea27321-f988-4a57-93e4-8eeb38f3cf1e"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# change it to a bigint
|
||||||
|
response = postgrest.session.post("/rpc/drop_change_cats")
|
||||||
|
assert response.status_code == 204
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# next request should succeed with a bigint value
|
||||||
|
response = postgrest.session.get("/cats?id=eq.1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
# TODO: This test fails now because of https://github.com/PostgREST/postgrest/pull/2122
|
# TODO: This test fails now because of https://github.com/PostgREST/postgrest/pull/2122
|
||||||
# The stack size of 1K(-with-rtsopts=-K1K) is not enough and this fails with "stack overflow"
|
# The stack size of 1K(-with-rtsopts=-K1K) is not enough and this fails with "stack overflow"
|
||||||
# A stack size of 200K seems to be enough for succeess
|
# A stack size of 200K seems to be enough for succeess
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"Sanity checks for the PostgREST black box testing infrastructure."
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from operator import attrgetter
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from config import *
|
||||||
|
from util import *
|
||||||
|
from postgrest import *
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_connection(defaultenv):
|
||||||
|
"Connections via a port on localhost should work."
|
||||||
|
with run(env=defaultenv, port=freeport()):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_get(defaultenv):
|
||||||
|
"run() should give a working PostgREST."
|
||||||
|
with run(env=defaultenv) as postgrest:
|
||||||
|
response = postgrest.session.get("/projects")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_pool_connection_available(defaultenv):
|
||||||
|
"no_pool_connection_available option is functional"
|
||||||
|
with run(env=defaultenv, no_pool_connection_available=True) as postgrest:
|
||||||
|
with pytest.raises(Exception) as e:
|
||||||
|
postgrest.session.get("/projects", timeout=1)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import contextlib
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
|
||||||
|
class Thread(threading.Thread):
|
||||||
|
"Variant of threading.Thread that re-raises any exceptions when joining the thread"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._exception = None
|
||||||
|
super(Thread, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
super(Thread, self).run()
|
||||||
|
except Exception as e:
|
||||||
|
self._exception = e
|
||||||
|
|
||||||
|
def join(self):
|
||||||
|
super(Thread, self).join()
|
||||||
|
if self._exception is not None:
|
||||||
|
raise self._exception
|
||||||
|
|
||||||
|
|
||||||
|
def freeport():
|
||||||
|
"Find a free port on localhost."
|
||||||
|
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||||
|
s.bind(("", 0))
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def authheader(token):
|
||||||
|
"Bearer token HTTP authorization header."
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def jwtauthheader(claim, secret):
|
||||||
|
"Authorization header with signed JWT."
|
||||||
|
return authheader(jwt.encode(claim, secret))
|
||||||
@@ -91,7 +91,7 @@ spec actualPgVersion = describe "OpenAPI" $ do
|
|||||||
[
|
[
|
||||||
{ "$ref": "#/parameters/body.child_entities" },
|
{ "$ref": "#/parameters/body.child_entities" },
|
||||||
{ "$ref": "#/parameters/select" },
|
{ "$ref": "#/parameters/select" },
|
||||||
{ "$ref": "#/parameters/preferReturn" }
|
{ "$ref": "#/parameters/preferPost" }
|
||||||
]
|
]
|
||||||
|]
|
|]
|
||||||
|
|
||||||
@@ -157,6 +157,38 @@ spec actualPgVersion = describe "OpenAPI" $ do
|
|||||||
}
|
}
|
||||||
|]
|
|]
|
||||||
|
|
||||||
|
it "includes definitions to views" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let def = r ^? key "definitions" . key "child_entities_view"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
def `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "child_entities_view comment",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"description": "child_entities_view id comment\n\nNote:\nThis is a Primary Key.<pk/>",
|
||||||
|
"format": "integer",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "child_entities_view name comment. Can be longer than sixty-three characters long",
|
||||||
|
"format": "text",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"parent_id": {
|
||||||
|
"description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' column='id'/>",
|
||||||
|
"format": "integer",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
it "doesn't include privileged table for anonymous" $ do
|
it "doesn't include privileged table for anonymous" $ do
|
||||||
r <- simpleBody <$> get "/"
|
r <- simpleBody <$> get "/"
|
||||||
let tablePath = r ^? key "paths" . key "/authors_only"
|
let tablePath = r ^? key "paths" . key "/authors_only"
|
||||||
@@ -278,6 +310,23 @@ spec actualPgVersion = describe "OpenAPI" $ do
|
|||||||
}
|
}
|
||||||
|]
|
|]
|
||||||
|
|
||||||
|
describe "VIEW created for a TABLE with a O2M relationship" $ do
|
||||||
|
|
||||||
|
it "fk points to destination TABLE instead of the VIEW" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let referralLink = r ^? key "definitions" . key "projects" . key "properties" . key "client_id"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
referralLink `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "integer",
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Note:\nThis is a Foreign Key to `clients.id`.<fk table='clients' column='id'/>"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
describe "PostgreSQL to Swagger Type Mapping" $ do
|
describe "PostgreSQL to Swagger Type Mapping" $ do
|
||||||
|
|
||||||
it "character varying to string" $ do
|
it "character varying to string" $ do
|
||||||
@@ -458,6 +507,117 @@ spec actualPgVersion = describe "OpenAPI" $ do
|
|||||||
}
|
}
|
||||||
|]
|
|]
|
||||||
|
|
||||||
|
it "array types to array" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let text_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text_arr"
|
||||||
|
let int_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_int_arr"
|
||||||
|
let bool_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bool_arr"
|
||||||
|
let char_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_char_arr"
|
||||||
|
let varchar_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_varchar_arr"
|
||||||
|
let bigint_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint_arr"
|
||||||
|
let numeric_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric_arr"
|
||||||
|
let json_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_json_arr"
|
||||||
|
let jsonb_arr_types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_jsonb_arr"
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
|
||||||
|
text_arr_types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "text[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
int_arr_types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "integer[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
bool_arr_types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "boolean[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
char_arr_types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "character[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
varchar_arr_types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "character varying[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
bigint_arr_types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "bigint[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
numeric_arr_types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "numeric[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
json_arr_types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "json[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
jsonb_arr_types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "jsonb[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
|
||||||
describe "Detects default values" $ do
|
describe "Detects default values" $ do
|
||||||
|
|
||||||
it "text" $ do
|
it "text" $ do
|
||||||
@@ -523,7 +683,7 @@ spec actualPgVersion = describe "OpenAPI" $ do
|
|||||||
|
|
||||||
types `shouldBe` Just [aesonQQ|
|
types `shouldBe` Just [aesonQQ|
|
||||||
{
|
{
|
||||||
"format": "enum_menagerie_type",
|
"format": "test.enum_menagerie_type",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"foo",
|
"foo",
|
||||||
@@ -537,7 +697,7 @@ spec actualPgVersion = describe "OpenAPI" $ do
|
|||||||
it "includes function summary/description and body schema for arguments" $ do
|
it "includes function summary/description and body schema for arguments" $ do
|
||||||
r <- simpleBody <$> get "/"
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
let method s = key "paths" . key "/rpc/varied_arguments" . key s
|
let method s = key "paths" . key "/rpc/varied_arguments_openapi" . key s
|
||||||
args = r ^? method "post" . key "parameters" . nth 0 . key "schema"
|
args = r ^? method "post" . key "parameters" . nth 0 . key "schema"
|
||||||
summary = r ^? method "post" . key "summary"
|
summary = r ^? method "post" . key "summary"
|
||||||
description = r ^? method "post" . key "description"
|
description = r ^? method "post" . key "description"
|
||||||
@@ -558,7 +718,15 @@ spec actualPgVersion = describe "OpenAPI" $ do
|
|||||||
"date",
|
"date",
|
||||||
"money",
|
"money",
|
||||||
"enum",
|
"enum",
|
||||||
"arr"
|
"text_arr",
|
||||||
|
"int_arr",
|
||||||
|
"bool_arr",
|
||||||
|
"char_arr",
|
||||||
|
"varchar_arr",
|
||||||
|
"bigint_arr",
|
||||||
|
"numeric_arr",
|
||||||
|
"json_arr",
|
||||||
|
"jsonb_arr"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"double": {
|
"double": {
|
||||||
@@ -585,9 +753,64 @@ spec actualPgVersion = describe "OpenAPI" $ do
|
|||||||
"format": "enum_menagerie_type",
|
"format": "enum_menagerie_type",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"arr": {
|
"text_arr": {
|
||||||
"format": "text[]",
|
"format": "text[]",
|
||||||
"type": "string"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"int_arr": {
|
||||||
|
"format": "integer[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bool_arr": {
|
||||||
|
"format": "boolean[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"char_arr": {
|
||||||
|
"format": "character[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"varchar_arr": {
|
||||||
|
"format": "character varying[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bigint_arr": {
|
||||||
|
"format": "bigint[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"numeric_arr": {
|
||||||
|
"format": "numeric[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json_arr": {
|
||||||
|
"format": "json[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
},
|
||||||
|
"jsonb_arr": {
|
||||||
|
"format": "jsonb[]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
},
|
},
|
||||||
"integer": {
|
"integer": {
|
||||||
"format": "integer",
|
"format": "integer",
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ spec actualPgVersion =
|
|||||||
it "cannot have zero conditions" $
|
it "cannot have zero conditions" $
|
||||||
get "/entities?or=()" `shouldRespondWith`
|
get "/entities?or=()" `shouldRespondWith`
|
||||||
[json|{
|
[json|{
|
||||||
"details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)",
|
"details": "unexpected \")\" expecting field name (* or [a..z0..9_$]), negation operator (not) or logic operator (and, or)",
|
||||||
"message": "\"failed to parse logic tree (())\" (line 1, column 4)",
|
"message": "\"failed to parse logic tree (())\" (line 1, column 4)",
|
||||||
"code": "PGRST100",
|
"code": "PGRST100",
|
||||||
"hint": null
|
"hint": null
|
||||||
|
|||||||
@@ -12,14 +12,24 @@ import SpecHelper
|
|||||||
|
|
||||||
spec :: SpecWith ((), Application)
|
spec :: SpecWith ((), Application)
|
||||||
spec = describe "computed relationships" $ do
|
spec = describe "computed relationships" $ do
|
||||||
it "can define a many-to-one relationship and embed" $
|
it "can define a many-to-one relationship with SETOF and ROWS 1 and embed" $
|
||||||
get "/videogames?select=name,designers:computed_designers(name)"
|
get "/videogames?select=name,designer:computed_designers(name)"
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"name":"Civilization I","designers":{"name":"Sid Meier"}},
|
{"name":"Civilization I","designer":{"name":"Sid Meier"}},
|
||||||
{"name":"Civilization II","designers":{"name":"Sid Meier"}},
|
{"name":"Civilization II","designer":{"name":"Sid Meier"}},
|
||||||
{"name":"Final Fantasy I","designers":{"name":"Hironobu Sakaguchi"}},
|
{"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}},
|
||||||
{"name":"Final Fantasy II","designers":{"name":"Hironobu Sakaguchi"}}
|
{"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}}
|
||||||
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can define a many-to-one relationship without SETOF and embed" $
|
||||||
|
get "/videogames?select=name,designer:computed_designers_noset(name)"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"name":"Civilization I","designer":{"name":"Sid Meier"}},
|
||||||
|
{"name":"Civilization II","designer":{"name":"Sid Meier"}},
|
||||||
|
{"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}},
|
||||||
|
{"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}}
|
||||||
]|] { matchHeaders = [matchContentTypeJson] }
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can define a one-to-many relationship and embed" $
|
it "can define a one-to-many relationship and embed" $
|
||||||
@@ -48,6 +58,16 @@ spec = describe "computed relationships" $ do
|
|||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
||||||
}
|
}
|
||||||
|
request methodGet "/videogames?select=name,designer:computed_designers_noset!inner(name)&designer.name=like.*Hironobu*"
|
||||||
|
[("Prefer", "count=exact")] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}},
|
||||||
|
{"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
||||||
|
}
|
||||||
|
|
||||||
it "works with rpc" $ do
|
it "works with rpc" $ do
|
||||||
get "/rpc/getallvideogames?select=name,designer:computed_designers(name)"
|
get "/rpc/getallvideogames?select=name,designer:computed_designers(name)"
|
||||||
@@ -107,3 +127,27 @@ spec = describe "computed relationships" $ do
|
|||||||
get "/second_1?select=*,first_1(*)"
|
get "/second_1?select=*,first_1(*)"
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json|[]|] { matchHeaders = [matchContentTypeJson] }
|
[json|[]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
-- https://github.com/PostgREST/postgrest/issues/2455
|
||||||
|
it "creates queries with the right aliasing" $ do
|
||||||
|
get "/fee?select=*,jsbaz(*,janedoe(*))"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/fee?select=*,jsbaz(*,johnsmith(*, fee(*)))"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "creates queries with the right aliasing when following a normal embed" $ do
|
||||||
|
get "/projects?select=name,clients(name,computed_projects(name))&limit=1"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[{"name":"Windows 7","clients":{"name":"Microsoft","computed_projects":{"name":"Windows 7"}}}]
|
||||||
|
|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/clients?select=name,projects(name,computed_clients(name))&limit=1"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"name":"Microsoft","projects":[
|
||||||
|
{"name":"Windows 7","computed_clients":{"name":"Microsoft"}},
|
||||||
|
{"name":"Windows 10","computed_clients":{"name":"Microsoft"}}
|
||||||
|
]}
|
||||||
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|||||||
@@ -120,6 +120,41 @@ spec =
|
|||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "errs with multiple references to the same composite key columns in a view" $
|
||||||
|
get "/i2459_composite_v2?select=*,i2459_composite_v1(*)" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
{
|
||||||
|
"code": "PGRST201",
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"cardinality": "many-to-one",
|
||||||
|
"embedding": "i2459_composite_v2 with i2459_composite_v1",
|
||||||
|
"relationship": "i2459_composite_t2_t1_a_t1_b_fkey using i2459_composite_v2(t1_a1, t1_b1) and i2459_composite_v1(a, b)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "many-to-one",
|
||||||
|
"embedding": "i2459_composite_v2 with i2459_composite_v1",
|
||||||
|
"relationship": "i2459_composite_t2_t1_a_t1_b_fkey using i2459_composite_v2(t1_a1, t1_b2) and i2459_composite_v1(a, b)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "many-to-one",
|
||||||
|
"embedding": "i2459_composite_v2 with i2459_composite_v1",
|
||||||
|
"relationship": "i2459_composite_t2_t1_a_t1_b_fkey using i2459_composite_v2(t1_a2, t1_b1) and i2459_composite_v1(a, b)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "many-to-one",
|
||||||
|
"embedding": "i2459_composite_v2 with i2459_composite_v1",
|
||||||
|
"relationship": "i2459_composite_t2_t1_a_t1_b_fkey using i2459_composite_v2(t1_a2, t1_b2) and i2459_composite_v1(a, b)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hint": "Try changing 'i2459_composite_v1' to one of the following: 'i2459_composite_v1!i2459_composite_t2_t1_a_t1_b_fkey', 'i2459_composite_v1!i2459_composite_t2_t1_a_t1_b_fkey', 'i2459_composite_v1!i2459_composite_t2_t1_a_t1_b_fkey', 'i2459_composite_v1!i2459_composite_t2_t1_a_t1_b_fkey'. Find the desired relationship in the 'details' key.",
|
||||||
|
"message": "Could not embed because more than one relationship was found for 'i2459_composite_v2' and 'i2459_composite_v1'"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
{ matchStatus = 300
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
context "disambiguating requests with embed hints" $ do
|
context "disambiguating requests with embed hints" $ do
|
||||||
|
|
||||||
context "using FK to specify the relationship" $ do
|
context "using FK to specify the relationship" $ do
|
||||||
@@ -167,10 +202,10 @@ spec =
|
|||||||
it "fails if the fk is not known" $
|
it "fails if the fk is not known" $
|
||||||
get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`
|
get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`
|
||||||
[json|{
|
[json|{
|
||||||
"hint":"Verify that 'message' and 'person' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
"hint":null,
|
||||||
"message":"Could not find a relationship between 'message' and 'person' in the schema cache",
|
"message":"Could not find a relationship between 'message' and 'person' in the schema cache",
|
||||||
"code": "PGRST200",
|
"code": "PGRST200",
|
||||||
"details": null}|]
|
"details":"Searched for a foreign key relationship between 'message' and 'person' using the hint 'space' in the schema 'test', but no matches were found."}|]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
, matchHeaders = [matchContentTypeJson] }
|
, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
@@ -240,6 +275,20 @@ spec =
|
|||||||
[json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]
|
[json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can specify all view column names that reference the same base column" $ do
|
||||||
|
get "/i2459_simple_v1?select=*,i2459_simple_v2!t1_id1(*)" `shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/i2459_simple_v1?select=*,i2459_simple_v2!t1_id2(*)" `shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/i2459_simple_v2?select=*,i2459_simple_v1!t1_id1(*)" `shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/i2459_simple_v2?select=*,i2459_simple_v1!t1_id2(*)" `shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "using the junction to disambiguate the request" $
|
context "using the junction to disambiguate the request" $
|
||||||
it "can specify the junction of an m2m relationship" $ do
|
it "can specify the junction of an m2m relationship" $ do
|
||||||
get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
|
get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
|
||||||
@@ -341,6 +390,14 @@ spec =
|
|||||||
}
|
}
|
||||||
]|] { matchHeaders = [matchContentTypeJson] }
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can specify all view column names that reference the same base column" $ do
|
||||||
|
get "/i2459_self_v1?select=*,parent(*),grandparent(*)" `shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/i2459_self_v2?select=*,parent(*),grandparent(*)" `shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "two self reference foreign keys" $ do
|
context "two self reference foreign keys" $ do
|
||||||
it "embeds parents" $
|
it "embeds parents" $
|
||||||
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
|
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
|
||||||
@@ -435,10 +492,10 @@ spec =
|
|||||||
it "doesn't work if the junction is only internal" $
|
it "doesn't work if the junction is only internal" $
|
||||||
get "/end_1?select=end_2(*)" `shouldRespondWith`
|
get "/end_1?select=end_2(*)" `shouldRespondWith`
|
||||||
[json|{
|
[json|{
|
||||||
"hint":"Verify that 'end_1' and 'end_2' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
"hint": null,
|
||||||
"message":"Could not find a relationship between 'end_1' and 'end_2' in the schema cache",
|
"message":"Could not find a relationship between 'end_1' and 'end_2' in the schema cache",
|
||||||
"code":"PGRST200",
|
"code":"PGRST200",
|
||||||
"details": null}|]
|
"details": "Searched for a foreign key relationship between 'end_1' and 'end_2' in the schema 'test', but no matches were found."}|]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
, matchHeaders = [matchContentTypeJson] }
|
, matchHeaders = [matchContentTypeJson] }
|
||||||
it "shouldn't try to embed if the private junction has an exposed homonym" $
|
it "shouldn't try to embed if the private junction has an exposed homonym" $
|
||||||
@@ -446,10 +503,10 @@ spec =
|
|||||||
-- Ref: https://github.com/PostgREST/postgrest/issues/1587#issuecomment-734995669
|
-- Ref: https://github.com/PostgREST/postgrest/issues/1587#issuecomment-734995669
|
||||||
get "/schauspieler?select=filme(*)" `shouldRespondWith`
|
get "/schauspieler?select=filme(*)" `shouldRespondWith`
|
||||||
[json|{
|
[json|{
|
||||||
"hint":"Verify that 'schauspieler' and 'filme' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
"hint":null,
|
||||||
"message":"Could not find a relationship between 'schauspieler' and 'filme' in the schema cache",
|
"message":"Could not find a relationship between 'schauspieler' and 'filme' in the schema cache",
|
||||||
"code":"PGRST200",
|
"code":"PGRST200",
|
||||||
"details": null}|]
|
"details":"Searched for a foreign key relationship between 'schauspieler' and 'filme' in the schema 'test', but no matches were found."}|]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
, matchHeaders = [matchContentTypeJson] }
|
, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
@@ -507,3 +564,5 @@ spec =
|
|||||||
]|]
|
]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "should not expose hidden FKs" $
|
||||||
|
get "/va?select=vb(*)" `shouldRespondWith` 200
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ spec actualPgVersion = do
|
|||||||
{"id": 204, "body": "yyy"},
|
{"id": 204, "body": "yyy"},
|
||||||
{"id": 205, "body": "zzz"}]|]
|
{"id": 205, "body": "zzz"}]|]
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)","code":"PGRST100","hint":null} |]
|
[json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_$])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)","code":"PGRST100","hint":null} |]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
@@ -591,7 +591,7 @@ spec actualPgVersion = do
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe "Inserting into VIEWs" $ do
|
describe "Inserting into VIEWs" $ do
|
||||||
context "requesting no representation" $
|
context "requesting no representation" $ do
|
||||||
it "succeeds with 201" $
|
it "succeeds with 201" $
|
||||||
post "/compound_pk_view"
|
post "/compound_pk_view"
|
||||||
[json|{"k1":1,"k2":"test","extra":2}|]
|
[json|{"k1":1,"k2":"test","extra":2}|]
|
||||||
@@ -602,6 +602,17 @@ spec actualPgVersion = do
|
|||||||
, matchHeaderAbsent hLocation ]
|
, matchHeaderAbsent hLocation ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "returns a location header with pks from both tables" $
|
||||||
|
request methodPost "/with_multiple_pks" [("Prefer", "return=headers-only")]
|
||||||
|
[json|{"pk1":1,"pk2":2}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
""
|
||||||
|
{ matchStatus = 201
|
||||||
|
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||||
|
, "Location" <:> "/with_multiple_pks?pk1=eq.1&pk2=eq.2"
|
||||||
|
, "Content-Range" <:> "*/*" ]
|
||||||
|
}
|
||||||
|
|
||||||
context "requesting header only representation" $ do
|
context "requesting header only representation" $ do
|
||||||
it "returns a location header with a composite PK col" $
|
it "returns a location header with a composite PK col" $
|
||||||
request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")]
|
request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")]
|
||||||
@@ -614,13 +625,13 @@ spec actualPgVersion = do
|
|||||||
, "Content-Range" <:> "*/*" ]
|
, "Content-Range" <:> "*/*" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "returns location header with a single PK col" $
|
it "should not throw and return location header when a PK is null" $
|
||||||
request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")]
|
request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")]
|
||||||
[json|{"id":1}|]
|
[json|{"id":1}|]
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
""
|
""
|
||||||
{ matchStatus = 201
|
{ matchStatus = 201
|
||||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||||
, "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1"
|
, "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"
|
||||||
, "Content-Range" <:> "*/*" ]
|
, "Content-Range" <:> "*/*" ]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,10 +74,33 @@ spec =
|
|||||||
}
|
}
|
||||||
|
|
||||||
it "succeeds in reading a table from a schema with uppercase and special characters in its name" $
|
it "succeeds in reading a table from a schema with uppercase and special characters in its name" $
|
||||||
request methodGet "/names" [("Accept-Profile", "SPECIAL \"@/\\#~_-")] "" `shouldRespondWith`
|
request methodGet "/names?select=id,name" [("Accept-Profile", "SPECIAL \"@/\\#~_-")] "" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"id":1,"name":"John"},
|
{"id": 1, "name":"John"},
|
||||||
{"id":2,"name":"Mary"}
|
{"id": 2, "name":"Mary"},
|
||||||
|
{"id": 3, "name":"José"}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "SPECIAL \"@/\\#~_-"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in embedding with FK when the schema name has special characters" $
|
||||||
|
request methodGet "/names?select=name,languages(name)&id=in.(1,3)" [("Accept-Profile", "SPECIAL \"@/\\#~_-")] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"name": "John", languages: {"name": "English"}},
|
||||||
|
{"name": "José", languages: {"name": "Spanish"}}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "SPECIAL \"@/\\#~_-"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in embedding with computed relationships when the schema name has special characters" $
|
||||||
|
request methodGet "/names?select=name,computed_languages(name)&id=in.(1,3)" [("Accept-Profile", "SPECIAL \"@/\\#~_-")] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"name": "John", computed_languages: {"name": "English"}},
|
||||||
|
{"name": "José", computed_languages: {"name": "Spanish"}}
|
||||||
]|]
|
]|]
|
||||||
{
|
{
|
||||||
matchStatus = 200
|
matchStatus = 200
|
||||||
|
|||||||
@@ -114,3 +114,10 @@ spec =
|
|||||||
{ "first_name": "Daniel B.", "last_name": "Lyon" },
|
{ "first_name": "Daniel B.", "last_name": "Lyon" },
|
||||||
{ "first_name": "Edwin S.", "last_name": "Smith" } ]|]
|
{ "first_name": "Edwin S.", "last_name": "Smith" } ]|]
|
||||||
{ matchStatus = 200 }
|
{ matchStatus = 200 }
|
||||||
|
|
||||||
|
context "max-rows is set and limits are requested" $ do
|
||||||
|
it "should work with limit 0" $
|
||||||
|
get "/items?limit=0"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [] |]
|
||||||
|
{ matchHeaders = ["Content-Range" <:> "*/*"] }
|
||||||
|
|||||||
@@ -590,8 +590,8 @@ spec actualPgVersion = do
|
|||||||
it "cannot request partitions as children from a partitioned table" $
|
it "cannot request partitions as children from a partitioned table" $
|
||||||
get "/car_models?id=in.(1,2,4)&select=id,name,car_model_sales_202101(id)&order=id.asc" `shouldRespondWith`
|
get "/car_models?id=in.(1,2,4)&select=id,name,car_model_sales_202101(id)&order=id.asc" `shouldRespondWith`
|
||||||
[json|
|
[json|
|
||||||
{"hint":"Verify that 'car_models' and 'car_model_sales_202101' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
{"hint":"Perhaps you meant 'car_model_sales' instead of 'car_model_sales_202101'.",
|
||||||
"details":null,
|
"details":"Searched for a foreign key relationship between 'car_models' and 'car_model_sales_202101' in the schema 'test', but no matches were found.",
|
||||||
"code":"PGRST200",
|
"code":"PGRST200",
|
||||||
"message":"Could not find a relationship between 'car_models' and 'car_model_sales_202101' in the schema cache"} |]
|
"message":"Could not find a relationship between 'car_models' and 'car_model_sales_202101' in the schema cache"} |]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
@@ -601,8 +601,8 @@ spec actualPgVersion = do
|
|||||||
it "cannot request a partitioned table as parent from a partition" $
|
it "cannot request a partitioned table as parent from a partition" $
|
||||||
get "/car_model_sales_202101?select=id,name,car_models(id,name)&order=id.asc" `shouldRespondWith`
|
get "/car_model_sales_202101?select=id,name,car_models(id,name)&order=id.asc" `shouldRespondWith`
|
||||||
[json|
|
[json|
|
||||||
{"hint":"Verify that 'car_model_sales_202101' and 'car_models' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
{"hint":"Perhaps you meant 'car_model_sales' instead of 'car_model_sales_202101'.",
|
||||||
"details":null,
|
"details":"Searched for a foreign key relationship between 'car_model_sales_202101' and 'car_models' in the schema 'test', but no matches were found.",
|
||||||
"code":"PGRST200",
|
"code":"PGRST200",
|
||||||
"message":"Could not find a relationship between 'car_model_sales_202101' and 'car_models' in the schema cache"} |]
|
"message":"Could not find a relationship between 'car_model_sales_202101' and 'car_models' in the schema cache"} |]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
@@ -612,8 +612,8 @@ spec actualPgVersion = do
|
|||||||
it "cannot request a partition as parent from a partitioned table" $
|
it "cannot request a partition as parent from a partitioned table" $
|
||||||
get "/car_model_sales?id=in.(1,3,4)&select=id,name,car_models_default(id,name)&order=id.asc" `shouldRespondWith`
|
get "/car_model_sales?id=in.(1,3,4)&select=id,name,car_models_default(id,name)&order=id.asc" `shouldRespondWith`
|
||||||
[json|
|
[json|
|
||||||
{"hint":"Verify that 'car_model_sales' and 'car_models_default' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
{"hint":"Perhaps you meant 'car_models' instead of 'car_models_default'.",
|
||||||
"details":null,
|
"details":"Searched for a foreign key relationship between 'car_model_sales' and 'car_models_default' in the schema 'test', but no matches were found.",
|
||||||
"code":"PGRST200",
|
"code":"PGRST200",
|
||||||
"message":"Could not find a relationship between 'car_model_sales' and 'car_models_default' in the schema cache"} |]
|
"message":"Could not find a relationship between 'car_model_sales' and 'car_models_default' in the schema cache"} |]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
@@ -623,8 +623,8 @@ spec actualPgVersion = do
|
|||||||
it "cannot request partitioned tables as children from a partition" $
|
it "cannot request partitioned tables as children from a partition" $
|
||||||
get "/car_models_default?select=id,name,car_model_sales(id,name)&order=id.asc" `shouldRespondWith`
|
get "/car_models_default?select=id,name,car_model_sales(id,name)&order=id.asc" `shouldRespondWith`
|
||||||
[json|
|
[json|
|
||||||
{"hint":"Verify that 'car_models_default' and 'car_model_sales' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
{"hint":"Perhaps you meant 'car_model_sales' instead of 'car_models_default'.",
|
||||||
"details":null,
|
"details":"Searched for a foreign key relationship between 'car_models_default' and 'car_model_sales' in the schema 'test', but no matches were found.",
|
||||||
"code":"PGRST200",
|
"code":"PGRST200",
|
||||||
"message":"Could not find a relationship between 'car_models_default' and 'car_model_sales' in the schema cache"} |]
|
"message":"Could not find a relationship between 'car_models_default' and 'car_model_sales' in the schema cache"} |]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
@@ -739,6 +739,9 @@ spec actualPgVersion = do
|
|||||||
{"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
|
{"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works when embedding two views that refer to tables with different column ordering" $
|
||||||
|
get "/v1?select=v2(*)" `shouldRespondWith` 200
|
||||||
|
|
||||||
it "can embed a view that has group by" $
|
it "can embed a view that has group by" $
|
||||||
get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`
|
get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`
|
||||||
[json|
|
[json|
|
||||||
|
|||||||
@@ -39,6 +39,35 @@ spec = do
|
|||||||
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
|
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
|
||||||
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||||
|
|
||||||
|
context "of invalid range" $ do
|
||||||
|
it "refuses a range with nonzero start when there are no items" $
|
||||||
|
request methodPost "/rpc/getitemrange?offset=1"
|
||||||
|
[("Prefer", "count=exact")] emptyRange
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"An offset of 1 was requested, but there are only 0 rows.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
|
{ matchStatus = 416
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "refuses a range requesting start past last item" $
|
||||||
|
request methodPost "/rpc/getitemrange?offset=100"
|
||||||
|
[("Prefer", "count=exact")] defaultRange
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"An offset of 100 was requested, but there are only 15 rows.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
|
{ matchStatus = 416
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||||
|
}
|
||||||
|
|
||||||
context "with range headers" $ do
|
context "with range headers" $ do
|
||||||
context "of acceptable range" $ do
|
context "of acceptable range" $ do
|
||||||
it "succeeds with partial content" $ do
|
it "succeeds with partial content" $ do
|
||||||
@@ -82,12 +111,25 @@ spec = do
|
|||||||
it "fails with 416 for offside range" $
|
it "fails with 416 for offside range" $
|
||||||
request methodPost "/rpc/getitemrange"
|
request methodPost "/rpc/getitemrange"
|
||||||
(rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
|
(rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
|
||||||
`shouldRespondWith` 416
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"The lower boundary must be lower than or equal to the upper boundary in the Range header.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
|
{ matchStatus = 416 }
|
||||||
|
|
||||||
it "refuses a range with nonzero start when there are no items" $
|
it "refuses a range with nonzero start when there are no items" $
|
||||||
request methodPost "/rpc/getitemrange"
|
request methodPost "/rpc/getitemrange"
|
||||||
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
|
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
|
||||||
`shouldRespondWith` "[]"
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"An offset of 1 was requested, but there are only 0 rows.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
{ matchStatus = 416
|
{ matchStatus = 416
|
||||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||||
}
|
}
|
||||||
@@ -95,7 +137,13 @@ spec = do
|
|||||||
it "refuses a range requesting start past last item" $
|
it "refuses a range requesting start past last item" $
|
||||||
request methodPost "/rpc/getitemrange"
|
request methodPost "/rpc/getitemrange"
|
||||||
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
|
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
|
||||||
`shouldRespondWith` "[]"
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"An offset of 100 was requested, but there are only 15 rows.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
{ matchStatus = 416
|
{ matchStatus = 416
|
||||||
, matchHeaders = ["Content-Range" <:> "*/15"]
|
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||||
}
|
}
|
||||||
@@ -193,11 +241,46 @@ spec = do
|
|||||||
|
|
||||||
it "fails if limit is negative" $
|
it "fails if limit is negative" $
|
||||||
get "/items?select=id&limit=-1"
|
get "/items?select=id&limit=-1"
|
||||||
`shouldRespondWith` [json|{"message":"HTTP Range error","code":"PGRST103","details":null,"hint":null}|]
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"Limit should be greater than or equal to zero.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
{ matchStatus = 416
|
{ matchStatus = 416
|
||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context "of invalid range" $ do
|
||||||
|
it "refuses a range with nonzero start when there are no items" $
|
||||||
|
request methodGet "/menagerie?offset=1"
|
||||||
|
[("Prefer", "count=exact")] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"An offset of 1 was requested, but there are only 0 rows.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
|
{ matchStatus = 416
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "refuses a range requesting start past last item" $
|
||||||
|
request methodGet "/items?offset=100"
|
||||||
|
[("Prefer", "count=exact")] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"An offset of 100 was requested, but there are only 15 rows.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
|
{ matchStatus = 416
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||||
|
}
|
||||||
|
|
||||||
context "when count=planned" $ do
|
context "when count=planned" $ do
|
||||||
it "obtains a filtered range" $ do
|
it "obtains a filtered range" $ do
|
||||||
request methodGet "/items?select=id&id=gt.8"
|
request methodGet "/items?select=id&id=gt.8"
|
||||||
@@ -343,12 +426,25 @@ spec = do
|
|||||||
it "fails with 416 for offside range" $
|
it "fails with 416 for offside range" $
|
||||||
request methodGet "/items"
|
request methodGet "/items"
|
||||||
(rangeHdrs $ ByteRangeFromTo 1 0) ""
|
(rangeHdrs $ ByteRangeFromTo 1 0) ""
|
||||||
`shouldRespondWith` 416
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"The lower boundary must be lower than or equal to the upper boundary in the Range header.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
|
{ matchStatus = 416 }
|
||||||
|
|
||||||
it "refuses a range with nonzero start when there are no items" $
|
it "refuses a range with nonzero start when there are no items" $
|
||||||
request methodGet "/menagerie"
|
request methodGet "/menagerie"
|
||||||
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
|
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
|
||||||
`shouldRespondWith` "[]"
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"An offset of 1 was requested, but there are only 0 rows.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
{ matchStatus = 416
|
{ matchStatus = 416
|
||||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||||
}
|
}
|
||||||
@@ -356,7 +452,13 @@ spec = do
|
|||||||
it "refuses a range requesting start past last item" $
|
it "refuses a range requesting start past last item" $
|
||||||
request methodGet "/items"
|
request methodGet "/items"
|
||||||
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
|
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
|
||||||
`shouldRespondWith` "[]"
|
`shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"message":"Requested range not satisfiable",
|
||||||
|
"code":"PGRST103",
|
||||||
|
"details":"An offset of 100 was requested, but there are only 15 rows.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
{ matchStatus = 416
|
{ matchStatus = 416
|
||||||
, matchHeaders = ["Content-Range" <:> "*/15"]
|
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,17 +120,39 @@ spec actualPgVersion =
|
|||||||
it "should fail with 404 on unknown proc name" $
|
it "should fail with 404 on unknown proc name" $
|
||||||
get "/rpc/fake" `shouldRespondWith` 404
|
get "/rpc/fake" `shouldRespondWith` 404
|
||||||
|
|
||||||
|
it "should fail with 404 and hint the closest proc on unknown proc name" $
|
||||||
|
get "/rpc/sayhell" `shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"hint":"Perhaps you meant to call the function test.sayhello",
|
||||||
|
"message":"Could not find the function test.sayhell without parameters in the schema cache",
|
||||||
|
"code":"PGRST202",
|
||||||
|
"details":"Searched for the function test.sayhell without parameters, but no matches were found in the schema cache."} |]
|
||||||
|
{ matchStatus = 404
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
it "should fail with 404 on unknown proc args" $ do
|
it "should fail with 404 on unknown proc args" $ do
|
||||||
get "/rpc/sayhello" `shouldRespondWith` 404
|
get "/rpc/sayhello" `shouldRespondWith` 404
|
||||||
get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
|
get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
|
||||||
|
|
||||||
|
it "should fail with 404 and hint the closest args on unknown proc args" $
|
||||||
|
get "/rpc/sayhello?nam=Peter" `shouldRespondWith`
|
||||||
|
[json| {
|
||||||
|
"hint":"Perhaps you meant to call the function test.sayhello(name)",
|
||||||
|
"message":"Could not find the function test.sayhello(nam) in the schema cache",
|
||||||
|
"code":"PGRST202",
|
||||||
|
"details":"Searched for the function test.sayhello with parameter nam, but no matches were found in the schema cache."} |]
|
||||||
|
{ matchStatus = 404
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
it "should not ignore unknown args and fail with 404" $
|
it "should not ignore unknown args and fail with 404" $
|
||||||
get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`
|
get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`
|
||||||
[json| {
|
[json| {
|
||||||
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
|
"hint":"Perhaps you meant to call the function test.add_them(a, b)",
|
||||||
"message":"Could not find the test.add_them(a, b, smthelse) function in the schema cache",
|
"message":"Could not find the function test.add_them(a, b, smthelse) in the schema cache",
|
||||||
"code":"PGRST202",
|
"code":"PGRST202",
|
||||||
"details":null} |]
|
"details":"Searched for the function test.add_them with parameters a, b, smthelse, but no matches were found in the schema cache."} |]
|
||||||
{ matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
@@ -141,10 +163,10 @@ spec actualPgVersion =
|
|||||||
[json|{}|]
|
[json|{}|]
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json| {
|
[json| {
|
||||||
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
|
"hint":null,
|
||||||
"message":"Could not find the test.sayhello function with a single json or jsonb parameter in the schema cache",
|
"message":"Could not find the function test.sayhello in the schema cache",
|
||||||
"code":"PGRST202",
|
"code":"PGRST202",
|
||||||
"details":null} |]
|
"details":"Searched for the function test.sayhello with a single json/jsonb parameter, but no matches were found in the schema cache."} |]
|
||||||
{ matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
@@ -152,19 +174,19 @@ spec actualPgVersion =
|
|||||||
it "should fail with 404 for overloaded functions with unknown args" $ do
|
it "should fail with 404 for overloaded functions with unknown args" $ do
|
||||||
get "/rpc/overloaded?wrong_arg=value" `shouldRespondWith`
|
get "/rpc/overloaded?wrong_arg=value" `shouldRespondWith`
|
||||||
[json| {
|
[json| {
|
||||||
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
|
"hint":null,
|
||||||
"message":"Could not find the test.overloaded(wrong_arg) function in the schema cache",
|
"message":"Could not find the function test.overloaded(wrong_arg) in the schema cache",
|
||||||
"code":"PGRST202",
|
"code":"PGRST202",
|
||||||
"details":null} |]
|
"details":"Searched for the function test.overloaded with parameter wrong_arg, but no matches were found in the schema cache."} |]
|
||||||
{ matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
get "/rpc/overloaded?a=1&b=2&wrong_arg=value" `shouldRespondWith`
|
get "/rpc/overloaded?a=1&b=2&wrong_arg=value" `shouldRespondWith`
|
||||||
[json| {
|
[json| {
|
||||||
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
|
"hint":"Perhaps you meant to call the function test.overloaded(a, b, c)",
|
||||||
"message":"Could not find the test.overloaded(a, b, wrong_arg) function in the schema cache",
|
"message":"Could not find the function test.overloaded(a, b, wrong_arg) in the schema cache",
|
||||||
"code":"PGRST202",
|
"code":"PGRST202",
|
||||||
"details":null} |]
|
"details":"Searched for the function test.overloaded with parameters a, b, wrong_arg, but no matches were found in the schema cache."} |]
|
||||||
{ matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
@@ -246,13 +268,17 @@ spec actualPgVersion =
|
|||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json|{"id": 2, "articleStars": [{"userId": 3}]}|]
|
[json|{"id": 2, "articleStars": [{"userId": 3}]}|]
|
||||||
|
|
||||||
it "can embed an M2M relationship table" $
|
it "can embed an M2M relationship table" $ do
|
||||||
get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"
|
get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"
|
||||||
`shouldRespondWith` [json|[
|
`shouldRespondWith` [json|[
|
||||||
{"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},
|
{"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},
|
||||||
{"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}
|
{"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}
|
||||||
]|]
|
]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
-- https://github.com/PostgREST/postgrest/issues/2565
|
||||||
|
get "/rpc/get_yards?select=groups(*)"
|
||||||
|
`shouldRespondWith` [json|[]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can embed an M2M relationship table that has a parent relationship table" $
|
it "can embed an M2M relationship table that has a parent relationship table" $
|
||||||
get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"
|
get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"
|
||||||
@@ -1247,10 +1273,10 @@ spec actualPgVersion =
|
|||||||
[json|{"x": 1, "y": 2}|]
|
[json|{"x": 1, "y": 2}|]
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json|{
|
[json|{
|
||||||
"hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
|
"hint": "Perhaps you meant to call the function test.unnamed_text_param",
|
||||||
"message": "Could not find the test.unnamed_int_param(x, y) function or the test.unnamed_int_param function with a single unnamed json or jsonb parameter in the schema cache",
|
"message": "Could not find the function test.unnamed_int_param(x, y) in the schema cache",
|
||||||
"code":"PGRST202",
|
"code":"PGRST202",
|
||||||
"details":null
|
"details":"Searched for the function test.unnamed_int_param with parameters x, y or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache."
|
||||||
}|]
|
}|]
|
||||||
{ matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = [ matchContentTypeJson ]
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
@@ -1262,10 +1288,10 @@ spec actualPgVersion =
|
|||||||
[str|a simple text|]
|
[str|a simple text|]
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json|{
|
[json|{
|
||||||
"hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
|
"hint": null,
|
||||||
"message": "Could not find the test.unnamed_int_param function with a single unnamed text parameter in the schema cache",
|
"message": "Could not find the function test.unnamed_int_param in the schema cache",
|
||||||
"code":"PGRST202",
|
"code":"PGRST202",
|
||||||
"details":null
|
"details":"Searched for the function test.unnamed_int_param with a single unnamed text parameter, but no matches were found in the schema cache."
|
||||||
}|]
|
}|]
|
||||||
{ matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = [ matchContentTypeJson ]
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
@@ -1277,10 +1303,10 @@ spec actualPgVersion =
|
|||||||
[str|a simple text|]
|
[str|a simple text|]
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json|{
|
[json|{
|
||||||
"hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
|
"hint": null,
|
||||||
"message": "Could not find the test.unnamed_int_param function with a single unnamed xml parameter in the schema cache",
|
"message": "Could not find the function test.unnamed_int_param in the schema cache",
|
||||||
"code":"PGRST202",
|
"code":"PGRST202",
|
||||||
"details":null
|
"details":"Searched for the function test.unnamed_int_param with a single unnamed xml parameter, but no matches were found in the schema cache."
|
||||||
}|]
|
}|]
|
||||||
{ matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = [ matchContentTypeJson ]
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
@@ -1293,10 +1319,10 @@ spec actualPgVersion =
|
|||||||
file
|
file
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json|{
|
[json|{
|
||||||
"hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
|
"hint": null,
|
||||||
"message": "Could not find the test.unnamed_int_param function with a single unnamed bytea parameter in the schema cache",
|
"message": "Could not find the function test.unnamed_int_param in the schema cache",
|
||||||
"code":"PGRST202",
|
"code":"PGRST202",
|
||||||
"details":null
|
"details":"Searched for the function test.unnamed_int_param with a single unnamed bytea parameter, but no matches were found in the schema cache."
|
||||||
}|]
|
}|]
|
||||||
{ matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = [ matchContentTypeJson ]
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
@@ -1353,10 +1379,10 @@ spec actualPgVersion =
|
|||||||
"a,b\n1,2\n4,6\n100,200"
|
"a,b\n1,2\n4,6\n100,200"
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json| {
|
[json| {
|
||||||
"hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
|
"hint":"Perhaps you meant to call the function test.overloaded_unnamed_param(x, y)",
|
||||||
"message":"Could not find the test.overloaded_unnamed_param(a, b) function in the schema cache",
|
"message":"Could not find the function test.overloaded_unnamed_param(a, b) in the schema cache",
|
||||||
"code":"PGRST202",
|
"code":"PGRST202",
|
||||||
"details":null
|
"details":"Searched for the function test.overloaded_unnamed_param with parameters a, b, but no matches were found in the schema cache."
|
||||||
}|]
|
}|]
|
||||||
{ matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ spec = do
|
|||||||
`shouldRespondWith` 404
|
`shouldRespondWith` 404
|
||||||
|
|
||||||
context "on an empty table" $
|
context "on an empty table" $
|
||||||
it "indicates no records found to update by returning 404" $
|
it "succeeds with status code 204" $
|
||||||
request methodPatch "/empty_table" []
|
request methodPatch "/empty_table" []
|
||||||
[json| { "extra":20 } |]
|
[json| { "extra":20 } |]
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
""
|
""
|
||||||
{ matchStatus = 404,
|
{ matchStatus = 204,
|
||||||
matchHeaders = [matchHeaderAbsent hContentType]
|
matchHeaders = [matchHeaderAbsent hContentType]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +71,14 @@ spec = do
|
|||||||
[("Prefer", "return=representation")] [json| { "id":999999 } |]
|
[("Prefer", "return=representation")] [json| { "id":999999 } |]
|
||||||
`shouldRespondWith` "[]"
|
`shouldRespondWith` "[]"
|
||||||
{
|
{
|
||||||
matchStatus = 404,
|
matchStatus = 200,
|
||||||
matchHeaders = []
|
matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
it "gives a 404 when no rows updated" $
|
it "returns status code 200 when no rows updated" $
|
||||||
request methodPatch "/items?id=eq.99999999" []
|
request methodPatch "/items?id=eq.99999999" []
|
||||||
[json| { "id": 42 } |]
|
[json| { "id": 42 } |]
|
||||||
`shouldRespondWith` 404
|
`shouldRespondWith` 204
|
||||||
|
|
||||||
it "returns updated object as array when return=rep" $
|
it "returns updated object as array when return=rep" $
|
||||||
request methodPatch "/items?id=eq.2"
|
request methodPatch "/items?id=eq.2"
|
||||||
@@ -137,13 +137,13 @@ spec = do
|
|||||||
matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
|
matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "indicates no records updated by returning 404" $
|
it "returns empty array when no rows updated and return=rep" $
|
||||||
request methodPatch
|
request methodPatch
|
||||||
"/items?always_true=eq.false"
|
"/items?always_true=eq.false"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[json| { id: 100 } |]
|
[json| { id: 100 } |]
|
||||||
`shouldRespondWith` "[]"
|
`shouldRespondWith` "[]"
|
||||||
{ matchStatus = 404,
|
{ matchStatus = 200,
|
||||||
matchHeaders = []
|
matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,9 +304,9 @@ spec = do
|
|||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
[json|[{"id": 1, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
|
[json|[{"id": 1, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
|
||||||
|
|
||||||
it "ignores json keys and gives 404 if no record updated" $
|
it "ignores json keys and gives 200 if no record updated" $
|
||||||
request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
|
request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
|
||||||
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404
|
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 200
|
||||||
|
|
||||||
context "tables with self reference foreign keys" $ do
|
context "tables with self reference foreign keys" $ do
|
||||||
it "embeds children after update" $
|
it "embeds children after update" $
|
||||||
|
|||||||
+14
-14
@@ -12,7 +12,7 @@ import Test.Hspec
|
|||||||
import PostgREST.App (postgrest)
|
import PostgREST.App (postgrest)
|
||||||
import PostgREST.Config (AppConfig (..), LogLevel (..))
|
import PostgREST.Config (AppConfig (..), LogLevel (..))
|
||||||
import PostgREST.Config.Database (queryPgVersion)
|
import PostgREST.Config.Database (queryPgVersion)
|
||||||
import PostgREST.DbStructure (queryDbStructure)
|
import PostgREST.SchemaCache (querySchemaCache)
|
||||||
import Protolude hiding (toList, toS)
|
import Protolude hiding (toList, toS)
|
||||||
import Protolude.Conv (toS)
|
import Protolude.Conv (toS)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
@@ -64,36 +64,36 @@ import qualified Feature.RpcPreRequestGucsSpec
|
|||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
pool <- P.acquire (3, 10, toUtf8 $ configDbUri testCfg)
|
pool <- P.acquire 3 Nothing $ toUtf8 $ configDbUri testCfg
|
||||||
|
|
||||||
actualPgVersion <- either (panic . show) id <$> P.use pool queryPgVersion
|
actualPgVersion <- either (panic . show) id <$> P.use pool queryPgVersion
|
||||||
|
|
||||||
baseDbStructure <-
|
baseSchemaCache <-
|
||||||
loadDbStructure pool
|
loadSchemaCache pool
|
||||||
(configDbSchemas testCfg)
|
(configDbSchemas testCfg)
|
||||||
(configDbExtraSearchPath testCfg)
|
(configDbExtraSearchPath testCfg)
|
||||||
|
|
||||||
let
|
let
|
||||||
-- For tests that run with the same refDbStructure
|
-- For tests that run with the same refSchemaCache
|
||||||
app config = do
|
app config = do
|
||||||
appState <- AppState.initWithPool pool config
|
appState <- AppState.initWithPool pool config
|
||||||
AppState.putPgVersion appState actualPgVersion
|
AppState.putPgVersion appState actualPgVersion
|
||||||
AppState.putDbStructure appState (Just baseDbStructure)
|
AppState.putSchemaCache appState (Just baseSchemaCache)
|
||||||
when (isJust $ configDbRootSpec config) $
|
when (isJust $ configDbRootSpec config) $
|
||||||
AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure
|
AppState.putJsonDbS appState $ toS $ JSON.encode baseSchemaCache
|
||||||
return ((), postgrest LogCrit appState $ pure ())
|
return ((), postgrest LogCrit appState $ pure ())
|
||||||
|
|
||||||
-- For tests that run with a different DbStructure(depends on configSchemas)
|
-- For tests that run with a different SchemaCache(depends on configSchemas)
|
||||||
appDbs config = do
|
appDbs config = do
|
||||||
customDbStructure <-
|
customSchemaCache <-
|
||||||
loadDbStructure pool
|
loadSchemaCache pool
|
||||||
(configDbSchemas config)
|
(configDbSchemas config)
|
||||||
(configDbExtraSearchPath config)
|
(configDbExtraSearchPath config)
|
||||||
appState <- AppState.initWithPool pool config
|
appState <- AppState.initWithPool pool config
|
||||||
AppState.putPgVersion appState actualPgVersion
|
AppState.putPgVersion appState actualPgVersion
|
||||||
AppState.putDbStructure appState (Just customDbStructure)
|
AppState.putSchemaCache appState (Just customSchemaCache)
|
||||||
when (isJust $ configDbRootSpec config) $
|
when (isJust $ configDbRootSpec config) $
|
||||||
AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure
|
AppState.putJsonDbS appState $ toS $ JSON.encode baseSchemaCache
|
||||||
return ((), postgrest LogCrit appState $ pure ())
|
return ((), postgrest LogCrit appState $ pure ())
|
||||||
|
|
||||||
let withApp = app testCfg
|
let withApp = app testCfg
|
||||||
@@ -259,5 +259,5 @@ main = do
|
|||||||
describe "Feature.RollbackForcedSpec" Feature.RollbackSpec.forced
|
describe "Feature.RollbackForcedSpec" Feature.RollbackSpec.forced
|
||||||
|
|
||||||
where
|
where
|
||||||
loadDbStructure pool schemas extraSearchPath =
|
loadSchemaCache pool schemas extraSearchPath =
|
||||||
either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ queryDbStructure (toList schemas) extraSearchPath True)
|
either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ querySchemaCache (toList schemas) extraSearchPath True)
|
||||||
|
|||||||
+10
-10
@@ -14,23 +14,23 @@ import Text.Heredoc
|
|||||||
|
|
||||||
import Protolude hiding (get, toS)
|
import Protolude hiding (get, toS)
|
||||||
|
|
||||||
import PostgREST.Query.QueryBuilder (requestToCallProcQuery)
|
import PostgREST.Plan.CallPlan
|
||||||
import PostgREST.Request.Types
|
import PostgREST.Query.QueryBuilder (callPlanToQuery)
|
||||||
|
|
||||||
import PostgREST.DbStructure.Identifiers
|
import PostgREST.SchemaCache.Identifiers
|
||||||
import PostgREST.DbStructure.Proc
|
import PostgREST.SchemaCache.Proc
|
||||||
|
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
pool <- P.acquire (3, 10, "postgresql://")
|
pool <- P.acquire 3 Nothing "postgresql://"
|
||||||
|
|
||||||
hspec $ describe "QueryCost" $
|
hspec $ describe "QueryCost" $
|
||||||
context "call proc query" $ do
|
context "call proc query" $ do
|
||||||
it "should not exceed cost when calling setof composite proc" $ do
|
it "should not exceed cost when calling setof composite proc" $ do
|
||||||
cost <- exec pool $
|
cost <- exec pool $
|
||||||
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
|
callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
|
||||||
(KeyParams [ProcParam "id" "int" True False])
|
(KeyParams [ProcParam "id" "int" True False])
|
||||||
(Just [str| {"id": 3} |]) False False [])
|
(Just [str| {"id": 3} |]) False False [])
|
||||||
liftIO $
|
liftIO $
|
||||||
@@ -38,13 +38,13 @@ main = do
|
|||||||
|
|
||||||
it "should not exceed cost when calling setof composite proc with empty params" $ do
|
it "should not exceed cost when calling setof composite proc with empty params" $ do
|
||||||
cost <- exec pool $
|
cost <- exec pool $
|
||||||
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") (KeyParams []) Nothing False False [])
|
callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") (KeyParams []) Nothing False False [])
|
||||||
liftIO $
|
liftIO $
|
||||||
cost `shouldSatisfy` (< Just 30)
|
cost `shouldSatisfy` (< Just 30)
|
||||||
|
|
||||||
it "should not exceed cost when calling scalar proc" $ do
|
it "should not exceed cost when calling scalar proc" $ do
|
||||||
cost <- exec pool $
|
cost <- exec pool $
|
||||||
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
|
callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
|
||||||
(KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
|
(KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
|
||||||
(Just [str| {"a": 3, "b": 4} |]) True False [])
|
(Just [str| {"a": 3, "b": 4} |]) True False [])
|
||||||
liftIO $
|
liftIO $
|
||||||
@@ -53,7 +53,7 @@ main = do
|
|||||||
context "params=multiple-objects" $ do
|
context "params=multiple-objects" $ do
|
||||||
it "should not exceed cost when calling setof composite proc" $ do
|
it "should not exceed cost when calling setof composite proc" $ do
|
||||||
cost <- exec pool $
|
cost <- exec pool $
|
||||||
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
|
callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
|
||||||
(KeyParams [ProcParam "id" "int" True False])
|
(KeyParams [ProcParam "id" "int" True False])
|
||||||
(Just [str| [{"id": 1}, {"id": 4}] |]) False True [])
|
(Just [str| [{"id": 1}, {"id": 4}] |]) False True [])
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
@@ -63,7 +63,7 @@ main = do
|
|||||||
|
|
||||||
it "should not exceed cost when calling scalar proc" $ do
|
it "should not exceed cost when calling scalar proc" $ do
|
||||||
cost <- exec pool $
|
cost <- exec pool $
|
||||||
requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
|
callPlanToQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
|
||||||
(KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
|
(KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
|
||||||
(Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False [])
|
(Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False [])
|
||||||
liftIO $
|
liftIO $
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import PostgREST.Config (AppConfig (..),
|
|||||||
LogLevel (..),
|
LogLevel (..),
|
||||||
OpenAPIMode (..),
|
OpenAPIMode (..),
|
||||||
parseSecret)
|
parseSecret)
|
||||||
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
|
|
||||||
import PostgREST.MediaType (MediaType (..))
|
import PostgREST.MediaType (MediaType (..))
|
||||||
|
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
|
||||||
import Protolude hiding (get, toS)
|
import Protolude hiding (get, toS)
|
||||||
import Protolude.Conv (toS)
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in
|
|||||||
, configDbMaxRows = Nothing
|
, configDbMaxRows = Nothing
|
||||||
, configDbPlanEnabled = False
|
, configDbPlanEnabled = False
|
||||||
, configDbPoolSize = 10
|
, configDbPoolSize = 10
|
||||||
, configDbPoolTimeout = 10
|
, configDbPoolAcquisitionTimeout = Nothing
|
||||||
, configDbPreRequest = Just $ QualifiedIdentifier "test" "switch_role"
|
, configDbPreRequest = Just $ QualifiedIdentifier "test" "switch_role"
|
||||||
, configDbPreparedStatements = True
|
, configDbPreparedStatements = True
|
||||||
, configDbRootSpec = Nothing
|
, configDbRootSpec = Nothing
|
||||||
|
|||||||
Vendored
+3
-1
@@ -792,8 +792,10 @@ INSERT INTO shop_bles(id, name, coords, shop_id, range_area) VALUES(1, 'Beacon-1
|
|||||||
INSERT INTO shop_bles(id, name, coords, shop_id, range_area) VALUES(2, 'Beacon-2', 'SRID=4326;POINT(-71.10044 42.373695)', 1,
|
INSERT INTO shop_bles(id, name, coords, shop_id, range_area) VALUES(2, 'Beacon-2', 'SRID=4326;POINT(-71.10044 42.373695)', 1,
|
||||||
extensions.ST_GeomFromGeoJSON('{"type": "Polygon", "coordinates": [ [ [ -71.10034391283989, 42.37385299961788 ], [ -71.10036939382553, 42.373756895982865 ], [ -71.1002916097641, 42.373745997623224 ], [ -71.1002641171217, 42.37384408279195 ], [ -71.10034391283989, 42.37385299961788 ] ] ]}'));
|
extensions.ST_GeomFromGeoJSON('{"type": "Polygon", "coordinates": [ [ [ -71.10034391283989, 42.37385299961788 ], [ -71.10036939382553, 42.373756895982865 ], [ -71.1002916097641, 42.373745997623224 ], [ -71.1002641171217, 42.37384408279195 ], [ -71.10034391283989, 42.37385299961788 ] ] ]}'));
|
||||||
|
|
||||||
|
TRUNCATE TABLE "SPECIAL ""@/\#~_-".languages CASCADE;
|
||||||
|
INSERT INTO "SPECIAL ""@/\#~_-".languages (id, name) VALUES (1, 'English'), (2, 'Spanish');
|
||||||
TRUNCATE TABLE "SPECIAL ""@/\#~_-".names CASCADE;
|
TRUNCATE TABLE "SPECIAL ""@/\#~_-".names CASCADE;
|
||||||
INSERT INTO "SPECIAL ""@/\#~_-".names (id, name) VALUES (1, 'John'), (2, 'Mary');
|
INSERT INTO "SPECIAL ""@/\#~_-".names (id, name, language_id) VALUES (1, 'John', 1), (2, 'Mary', 1), (3, 'José', 2);
|
||||||
|
|
||||||
TRUNCATE TABLE do$llar$s CASCADE;
|
TRUNCATE TABLE do$llar$s CASCADE;
|
||||||
INSERT INTO do$llar$s (a$num$) VALUES (100), (200), (300);
|
INSERT INTO do$llar$s (a$num$) VALUES (100), (200), (300);
|
||||||
|
|||||||
Vendored
+18
-225
@@ -1,216 +1,35 @@
|
|||||||
-- Privileges for anonymous
|
-- Privileges for anonymous
|
||||||
GRANT USAGE ON SCHEMA
|
GRANT USAGE ON SCHEMA
|
||||||
postgrest
|
"EXTRA ""@/\#~_-"
|
||||||
, test
|
, "SPECIAL ""@/\#~_-"
|
||||||
, jwt
|
|
||||||
, public
|
|
||||||
, "تست"
|
, "تست"
|
||||||
, extensions
|
, extensions
|
||||||
|
, jwt
|
||||||
|
, postgrest
|
||||||
|
, public
|
||||||
|
, test
|
||||||
, v1
|
, v1
|
||||||
, v2
|
, v2
|
||||||
, "SPECIAL ""@/\#~_-"
|
|
||||||
, "EXTRA ""@/\#~_-"
|
|
||||||
TO postgrest_test_anonymous;
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
-- Schema test objects
|
-- Schema test objects
|
||||||
SET search_path = test, "تست", pg_catalog;
|
SET search_path = test, "تست", pg_catalog;
|
||||||
|
|
||||||
GRANT ALL ON TABLE
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA
|
||||||
items
|
"SPECIAL ""@/\#~_-"
|
||||||
, items2
|
, "تست"
|
||||||
, items3
|
|
||||||
, "articleStars"
|
|
||||||
, articles
|
|
||||||
, arrays
|
|
||||||
, auto_incrementing_pk
|
|
||||||
, clients
|
|
||||||
, comments
|
|
||||||
, complex_items
|
|
||||||
, compound_pk
|
|
||||||
, compound_pk_view
|
|
||||||
, deferrable_unique_constraint
|
|
||||||
, empty_table
|
|
||||||
, fav_numbers
|
|
||||||
, has_count_column
|
|
||||||
, has_fk
|
|
||||||
, insertable_view_with_join
|
|
||||||
, json_table
|
|
||||||
, materialized_view
|
|
||||||
, menagerie
|
|
||||||
, no_pk
|
|
||||||
, nullable_integer
|
|
||||||
, projects
|
|
||||||
, projects_view
|
|
||||||
, projects_view_alt
|
|
||||||
, test_null_pk_competitors_sponsors
|
|
||||||
, simple_pk
|
|
||||||
, simple_pk2
|
|
||||||
, tasks
|
|
||||||
, filtered_tasks
|
|
||||||
, tsearch
|
|
||||||
, users
|
|
||||||
, users_projects
|
|
||||||
, users_tasks
|
|
||||||
, files
|
|
||||||
, touched_files
|
|
||||||
, "Escap3e;"
|
|
||||||
, "ghostBusters"
|
|
||||||
, "withUnique"
|
|
||||||
, "clashing_column"
|
|
||||||
, "موارد"
|
|
||||||
, addresses
|
|
||||||
, orders
|
|
||||||
, public.public_consumers
|
|
||||||
, public.public_orders
|
|
||||||
, consumers_view
|
|
||||||
, consumers_view_view
|
|
||||||
, consumers_extra_view
|
|
||||||
, orders_view
|
|
||||||
, images
|
|
||||||
, images_base64
|
|
||||||
, w_or_wo_comma_names
|
|
||||||
, items_with_different_col_types
|
|
||||||
, entities
|
|
||||||
, child_entities
|
|
||||||
, grandchild_entities
|
|
||||||
, ranges
|
|
||||||
, being
|
|
||||||
, descendant
|
|
||||||
, being_part
|
|
||||||
, part
|
|
||||||
, leak
|
|
||||||
, perf_articles
|
|
||||||
, employees
|
|
||||||
, tiobe_pls
|
|
||||||
, single_unique
|
|
||||||
, compound_unique
|
|
||||||
, only_pk
|
|
||||||
, family_tree
|
|
||||||
, managers
|
|
||||||
, organizations
|
|
||||||
, authors
|
|
||||||
, publishers
|
|
||||||
, books
|
|
||||||
, forties_books
|
|
||||||
, fifties_books
|
|
||||||
, sixties_books
|
|
||||||
, person
|
|
||||||
, message
|
|
||||||
, person_detail
|
|
||||||
, space
|
|
||||||
, zone
|
|
||||||
, projects_dump
|
|
||||||
, "UnitTest"
|
|
||||||
, json_arr
|
|
||||||
, jsonb_test
|
|
||||||
, authors_books_number
|
|
||||||
, authors_have_book_in_decade
|
|
||||||
, authors_have_book_in_decade2
|
|
||||||
, forties_and_fifties_books
|
|
||||||
, odd_years_publications
|
|
||||||
, foos
|
|
||||||
, bars
|
|
||||||
, materialized_projects
|
|
||||||
, contract
|
|
||||||
, player_view
|
|
||||||
, contract_view
|
|
||||||
, ltree_sample
|
|
||||||
, isn_sample
|
|
||||||
, projects_count_grouped_by
|
|
||||||
, "Server Today"
|
|
||||||
, pgrst_reserved_chars
|
|
||||||
, authors_w_entities
|
|
||||||
, openapi_types
|
|
||||||
, openapi_defaults
|
|
||||||
, getallprojects_view
|
|
||||||
, get_projects_above_view
|
|
||||||
, web_content
|
|
||||||
, pages
|
|
||||||
, referrals
|
|
||||||
, big_projects
|
|
||||||
, sites
|
|
||||||
, jobs
|
|
||||||
, main_jobs
|
|
||||||
, whatev_projects
|
|
||||||
, whatev_sites
|
|
||||||
, whatev_jobs
|
|
||||||
, agents
|
|
||||||
, departments
|
|
||||||
, schedules
|
|
||||||
, activities
|
|
||||||
, unit_workdays
|
|
||||||
, unit_workdays_fst_shift
|
|
||||||
, stuff
|
|
||||||
, loc_test
|
|
||||||
, v1.parents
|
|
||||||
, v2.parents
|
|
||||||
, v2.another_table
|
|
||||||
, v1.children
|
|
||||||
, v2.children
|
|
||||||
, screens
|
|
||||||
, labels
|
|
||||||
, label_screen
|
|
||||||
, actors
|
|
||||||
, films
|
|
||||||
, personnages
|
|
||||||
, end_1
|
|
||||||
, end_2
|
|
||||||
, schauspieler
|
|
||||||
, filme
|
|
||||||
, rollen
|
|
||||||
, products
|
|
||||||
, suppliers
|
|
||||||
, products_suppliers
|
|
||||||
, trade_unions
|
|
||||||
, suppliers_trade_unions
|
|
||||||
, client
|
|
||||||
, clientinfo
|
|
||||||
, contact
|
|
||||||
, chores
|
|
||||||
, limited_update_items
|
|
||||||
, limited_update_items_cpk
|
|
||||||
, limited_update_items_no_pk
|
|
||||||
, limited_update_items_view
|
|
||||||
, limited_update_items_wnonuniq_view
|
|
||||||
, limited_delete_items
|
|
||||||
, limited_delete_items_cpk
|
|
||||||
, limited_delete_items_no_pk
|
|
||||||
, limited_delete_items_view
|
|
||||||
, plate
|
|
||||||
, well
|
|
||||||
, limited_delete_items_wnonuniq_view
|
|
||||||
, limited_delete_items_cpk_view
|
|
||||||
, limited_update_items_cpk_view
|
|
||||||
, xmltest
|
|
||||||
, oid_test
|
|
||||||
, job
|
|
||||||
, series
|
|
||||||
, adaptation_notifications
|
|
||||||
, series_popularity
|
|
||||||
, test
|
, test
|
||||||
, view_test
|
, v1
|
||||||
, shops
|
, v2
|
||||||
, shop_bles
|
|
||||||
, "SPECIAL ""@/\#~_-".names
|
|
||||||
, do$llar$s
|
|
||||||
, safe_update_items
|
|
||||||
, safe_delete_items
|
|
||||||
, unsafe_update_items
|
|
||||||
, unsafe_delete_items
|
|
||||||
, videogames
|
|
||||||
, designers
|
|
||||||
, students
|
|
||||||
, students_info
|
|
||||||
, students_view
|
|
||||||
, students_info_view
|
|
||||||
, country
|
|
||||||
, capital
|
|
||||||
, first
|
|
||||||
, second
|
|
||||||
, first_1
|
|
||||||
, second_1
|
|
||||||
TO postgrest_test_anonymous;
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
|
REVOKE ALL PRIVILEGES ON TABLE
|
||||||
|
app_users
|
||||||
|
, authors_only
|
||||||
|
, insertonly
|
||||||
|
, limited_article_stars
|
||||||
|
FROM postgrest_test_anonymous;
|
||||||
|
|
||||||
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
|
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
|
||||||
|
|
||||||
GRANT USAGE ON SEQUENCE
|
GRANT USAGE ON SEQUENCE
|
||||||
@@ -238,29 +57,3 @@ REVOKE EXECUTE ON FUNCTION privileged_hello(text) FROM PUBLIC; -- All functions
|
|||||||
GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author;
|
GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author;
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA test TO postgrest_test_default_role;
|
GRANT USAGE ON SCHEMA test TO postgrest_test_default_role;
|
||||||
|
|
||||||
|
|
||||||
DO $do$BEGIN
|
|
||||||
IF (SELECT current_setting('server_version_num')::INT >= 100000) THEN
|
|
||||||
GRANT ALL ON TABLE test.car_models TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_models_2021 TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_models_default TO postgrest_test_anonymous;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF (SELECT current_setting('server_version_num')::INT >= 110000) THEN
|
|
||||||
GRANT ALL ON TABLE test.car_brands TO postgrest_test_anonymous;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF (SELECT current_setting('server_version_num')::INT >= 120000) THEN
|
|
||||||
GRANT ALL ON TABLE test.car_model_sales TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_model_sales_202101 TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_model_sales_default TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_racers TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_dealers TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_dealers_springfield TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_dealers_default TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_models_car_dealers TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_models_car_dealers_10to20 TO postgrest_test_anonymous;
|
|
||||||
GRANT ALL ON TABLE test.car_models_car_dealers_default TO postgrest_test_anonymous;
|
|
||||||
END IF;
|
|
||||||
END$do$;
|
|
||||||
|
|||||||
Vendored
+265
-5
@@ -272,6 +272,56 @@ $_$An RPC function
|
|||||||
|
|
||||||
Just a test for RPC function arguments$_$;
|
Just a test for RPC function arguments$_$;
|
||||||
|
|
||||||
|
CREATE FUNCTION varied_arguments_openapi(
|
||||||
|
double double precision,
|
||||||
|
"varchar" character varying,
|
||||||
|
"boolean" boolean,
|
||||||
|
date date,
|
||||||
|
money money,
|
||||||
|
enum enum_menagerie_type,
|
||||||
|
text_arr text[],
|
||||||
|
int_arr int[],
|
||||||
|
bool_arr boolean[],
|
||||||
|
char_arr char[],
|
||||||
|
varchar_arr varchar[],
|
||||||
|
bigint_arr bigint[],
|
||||||
|
numeric_arr numeric[],
|
||||||
|
json_arr json[],
|
||||||
|
jsonb_arr jsonb[],
|
||||||
|
"integer" integer default 42,
|
||||||
|
json json default '{}',
|
||||||
|
jsonb jsonb default '{}'
|
||||||
|
) RETURNS json
|
||||||
|
LANGUAGE sql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $_$
|
||||||
|
SELECT json_build_object(
|
||||||
|
'double', double,
|
||||||
|
'varchar', "varchar",
|
||||||
|
'boolean', "boolean",
|
||||||
|
'date', date,
|
||||||
|
'money', money,
|
||||||
|
'enum', enum,
|
||||||
|
'text_arr', text_arr,
|
||||||
|
'int_arr', int_arr,
|
||||||
|
'bool_arr', bool_arr,
|
||||||
|
'char_arr', char_arr,
|
||||||
|
'varchar_arr', varchar_arr,
|
||||||
|
'bigint_arr', bigint_arr,
|
||||||
|
'numeric_arr', numeric_arr,
|
||||||
|
'json_arr', json_arr,
|
||||||
|
'jsonb_arr', jsonb_arr,
|
||||||
|
'integer', "integer",
|
||||||
|
'json', json,
|
||||||
|
'jsonb', jsonb
|
||||||
|
);
|
||||||
|
$_$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION varied_arguments_openapi(double precision, character varying, boolean, date, money, enum_menagerie_type, text[], int[], boolean[], char[], varchar[], bigint[], numeric[], json[], jsonb[], integer, json, jsonb) IS
|
||||||
|
$_$An RPC function
|
||||||
|
|
||||||
|
Just a test for RPC function arguments$_$;
|
||||||
|
|
||||||
|
|
||||||
CREATE FUNCTION json_argument(arg json) RETURNS text
|
CREATE FUNCTION json_argument(arg json) RETURNS text
|
||||||
LANGUAGE sql
|
LANGUAGE sql
|
||||||
@@ -1153,6 +1203,8 @@ create table child_entities (
|
|||||||
parent_id integer references entities(id)
|
parent_id integer references entities(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
create view child_entities_view as table child_entities;
|
||||||
|
|
||||||
create table grandchild_entities (
|
create table grandchild_entities (
|
||||||
id integer primary key,
|
id integer primary key,
|
||||||
name text,
|
name text,
|
||||||
@@ -1176,6 +1228,10 @@ comment on table child_entities is 'child_entities comment';
|
|||||||
comment on column child_entities.id is 'child_entities id comment';
|
comment on column child_entities.id is 'child_entities id comment';
|
||||||
comment on column child_entities.name is 'child_entities name comment. Can be longer than sixty-three characters long';
|
comment on column child_entities.name is 'child_entities name comment. Can be longer than sixty-three characters long';
|
||||||
|
|
||||||
|
comment on view child_entities_view is 'child_entities_view comment';
|
||||||
|
comment on column child_entities_view.id is 'child_entities_view id comment';
|
||||||
|
comment on column child_entities_view.name is 'child_entities_view name comment. Can be longer than sixty-three characters long';
|
||||||
|
|
||||||
comment on table grandchild_entities is
|
comment on table grandchild_entities is
|
||||||
$$grandchild_entities summary
|
$$grandchild_entities summary
|
||||||
|
|
||||||
@@ -1808,7 +1864,16 @@ CREATE TABLE test.openapi_types(
|
|||||||
"a_real" real,
|
"a_real" real,
|
||||||
"a_double_precision" double precision,
|
"a_double_precision" double precision,
|
||||||
"a_json" json,
|
"a_json" json,
|
||||||
"a_jsonb" jsonb
|
"a_jsonb" jsonb,
|
||||||
|
"a_text_arr" text[],
|
||||||
|
"a_int_arr" int[],
|
||||||
|
"a_bool_arr" boolean[],
|
||||||
|
"a_char_arr" char[],
|
||||||
|
"a_varchar_arr" varchar[],
|
||||||
|
"a_bigint_arr" bigint[],
|
||||||
|
"a_numeric_arr" numeric[],
|
||||||
|
"a_json_arr" json[],
|
||||||
|
"a_jsonb_arr" jsonb[]
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE test.openapi_defaults(
|
CREATE TABLE test.openapi_defaults(
|
||||||
@@ -2478,9 +2543,6 @@ CREATE AGGREGATE test.unsupported_agg (*) (
|
|||||||
STYPE = int
|
STYPE = int
|
||||||
);
|
);
|
||||||
|
|
||||||
create view no_pk_view as
|
|
||||||
select * from no_pk;
|
|
||||||
|
|
||||||
create table limited_update_items(
|
create table limited_update_items(
|
||||||
id int primary key
|
id int primary key
|
||||||
, name text
|
, name text
|
||||||
@@ -2660,11 +2722,21 @@ create function get_shop(id int) returns shops as $$
|
|||||||
select * from shops where id = $1;
|
select * from shops where id = $1;
|
||||||
$$ language sql;
|
$$ language sql;
|
||||||
|
|
||||||
CREATE TABLE "SPECIAL ""@/\#~_-".names(
|
CREATE TABLE "SPECIAL ""@/\#~_-".languages(
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
name TEXT
|
name TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "SPECIAL ""@/\#~_-".names(
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
language_id INT REFERENCES "SPECIAL ""@/\#~_-".languages(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE FUNCTION "SPECIAL ""@/\#~_-".computed_languages("SPECIAL ""@/\#~_-".names) RETURNS SETOF "SPECIAL ""@/\#~_-".languages ROWS 1 AS $$
|
||||||
|
SELECT * FROM "SPECIAL ""@/\#~_-".languages where id = $1.language_id;
|
||||||
|
$$ LANGUAGE sql;
|
||||||
|
|
||||||
CREATE FUNCTION "EXTRA ""@/\#~_-".get_val_special(val text) RETURNS text AS $$
|
CREATE FUNCTION "EXTRA ""@/\#~_-".get_val_special(val text) RETURNS text AS $$
|
||||||
SELECT val;
|
SELECT val;
|
||||||
$$ LANGUAGE sql;
|
$$ LANGUAGE sql;
|
||||||
@@ -2724,6 +2796,10 @@ CREATE FUNCTION test.computed_designers(test.videogames) RETURNS SETOF test.desi
|
|||||||
SELECT * FROM test.designers WHERE id = $1.designer_id;
|
SELECT * FROM test.designers WHERE id = $1.designer_id;
|
||||||
$$ LANGUAGE sql STABLE ROWS 1;
|
$$ LANGUAGE sql STABLE ROWS 1;
|
||||||
|
|
||||||
|
CREATE FUNCTION test.computed_designers_noset(test.videogames) RETURNS test.designers AS $$
|
||||||
|
SELECT * FROM test.designers WHERE id = $1.designer_id;
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
CREATE FUNCTION test.computed_videogames(test.designers) RETURNS SETOF test.videogames AS $$
|
CREATE FUNCTION test.computed_videogames(test.designers) RETURNS SETOF test.videogames AS $$
|
||||||
SELECT * FROM test.videogames WHERE designer_id = $1.id;
|
SELECT * FROM test.videogames WHERE designer_id = $1.id;
|
||||||
$$ LANGUAGE sql STABLE;
|
$$ LANGUAGE sql STABLE;
|
||||||
@@ -2826,3 +2902,187 @@ $$ LANGUAGE sql STABLE ROWS 1;
|
|||||||
CREATE FUNCTION test.first_1(test.second_1) RETURNS SETOF test.first_1 AS $$
|
CREATE FUNCTION test.first_1(test.second_1) RETURNS SETOF test.first_1 AS $$
|
||||||
SELECT * FROM test.first_1 WHERE second_id_1 = $1.id;
|
SELECT * FROM test.first_1 WHERE second_id_1 = $1.id;
|
||||||
$$ LANGUAGE sql STABLE ROWS 1;
|
$$ LANGUAGE sql STABLE ROWS 1;
|
||||||
|
|
||||||
|
|
||||||
|
create table fee (
|
||||||
|
fee_id int primary key
|
||||||
|
);
|
||||||
|
create table baz (
|
||||||
|
baz_id int primary key
|
||||||
|
);
|
||||||
|
|
||||||
|
create table janedoe (
|
||||||
|
janedoe_id int primary key,
|
||||||
|
baz_id int references baz(baz_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table johnsmith (
|
||||||
|
johnsmith_id int primary key,
|
||||||
|
fee_id int references fee(fee_id),
|
||||||
|
baz_id int references baz(baz_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function jsbaz(fee) returns setof baz as $$
|
||||||
|
select b.*
|
||||||
|
from baz b
|
||||||
|
join johnsmith js on js.baz_id = b.baz_id
|
||||||
|
where js.fee_id = $1.fee_id
|
||||||
|
$$ stable language sql;
|
||||||
|
|
||||||
|
|
||||||
|
-- issue https://github.com/PostgREST/postgrest/issues/2518
|
||||||
|
create table a (
|
||||||
|
primary key (c1, c2),
|
||||||
|
c1 int,
|
||||||
|
c2 bool
|
||||||
|
);
|
||||||
|
|
||||||
|
create table b (
|
||||||
|
c2 bool,
|
||||||
|
c1 int,
|
||||||
|
foreign key (c1, c2) references a
|
||||||
|
);
|
||||||
|
|
||||||
|
create view test.v1 as table test.a;
|
||||||
|
|
||||||
|
create view test.v2 as table test.b;
|
||||||
|
|
||||||
|
-- issue https://github.com/PostgREST/postgrest/issues/2458
|
||||||
|
create table with_pk1 (pk1 int primary key);
|
||||||
|
create table with_pk2 (pk2 int primary key);
|
||||||
|
|
||||||
|
create view with_multiple_pks as
|
||||||
|
select * from with_pk1, with_pk2;
|
||||||
|
|
||||||
|
create function with_multiple_pks_insert() returns trigger
|
||||||
|
language plpgsql as $$
|
||||||
|
begin
|
||||||
|
insert into with_pk1 values (new.pk1) on conflict do nothing;
|
||||||
|
insert into with_pk2 values (new.pk2) on conflict do nothing;
|
||||||
|
return new;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger ins instead of insert on with_multiple_pks
|
||||||
|
for each row execute procedure with_multiple_pks_insert();
|
||||||
|
|
||||||
|
-- issue https://github.com/PostgREST/postgrest/issues/2283
|
||||||
|
create view self_recursive_view as table projects;
|
||||||
|
create or replace view self_recursive_view as table self_recursive_view;
|
||||||
|
|
||||||
|
CREATE FUNCTION test.computed_clients(test.projects) RETURNS SETOF test.clients ROWS 1 AS $$
|
||||||
|
SELECT * FROM test.clients WHERE id = $1.client_id;
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
|
CREATE FUNCTION test.computed_projects(test.clients) RETURNS SETOF test.projects ROWS 1 AS $$
|
||||||
|
SELECT * FROM test.projects WHERE client_id = $1.id;
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
|
-- issue https://github.com/PostgREST/postgrest/issues/2459
|
||||||
|
create table public.i2459_simple_t1 (
|
||||||
|
id int primary key
|
||||||
|
);
|
||||||
|
|
||||||
|
create table public.i2459_simple_t2 (
|
||||||
|
t1_id int references public.i2459_simple_t1
|
||||||
|
);
|
||||||
|
|
||||||
|
create view i2459_simple_v1 as table public.i2459_simple_t1;
|
||||||
|
|
||||||
|
create view i2459_simple_v2 as
|
||||||
|
select t1_id as t1_id1, t1_id as t1_id2 from public.i2459_simple_t2;
|
||||||
|
|
||||||
|
|
||||||
|
create table public.i2459_composite_t1 (
|
||||||
|
primary key (a,b),
|
||||||
|
a int,
|
||||||
|
b int
|
||||||
|
);
|
||||||
|
|
||||||
|
create table public.i2459_composite_t2 (
|
||||||
|
t1_a int,
|
||||||
|
t1_b int,
|
||||||
|
constraint i2459_composite_t2_t1_a_t1_b_fkey foreign key (t1_a, t1_b) references public.i2459_composite_t1
|
||||||
|
);
|
||||||
|
|
||||||
|
create view i2459_composite_v1 as table public.i2459_composite_t1;
|
||||||
|
|
||||||
|
create view i2459_composite_v2 as
|
||||||
|
select t1_a as t1_a1,
|
||||||
|
t1_b as t1_b1,
|
||||||
|
t1_a as t1_a2,
|
||||||
|
t1_b as t1_b2
|
||||||
|
from public.i2459_composite_t2;
|
||||||
|
|
||||||
|
|
||||||
|
create table public.i2459_self_t (
|
||||||
|
id int primary key,
|
||||||
|
parent int references public.i2459_self_t,
|
||||||
|
type text
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
create view i2459_self_v1 as
|
||||||
|
select parent.parent as grandparent,
|
||||||
|
child.parent,
|
||||||
|
child.id
|
||||||
|
from public.i2459_self_t as parent
|
||||||
|
join public.i2459_self_t as child
|
||||||
|
on child.parent = parent.id
|
||||||
|
where child.type = 'A';
|
||||||
|
|
||||||
|
create view i2459_self_v2 as
|
||||||
|
select parent.parent as grandparent,
|
||||||
|
child.parent,
|
||||||
|
child.id
|
||||||
|
from public.i2459_self_t as parent
|
||||||
|
join public.i2459_self_t as child
|
||||||
|
on child.parent = parent.id
|
||||||
|
where child.type = 'B';
|
||||||
|
|
||||||
|
-- issue https://github.com/PostgREST/postgrest/issues/2548
|
||||||
|
CREATE TABLE public.ta (
|
||||||
|
a1 INT PRIMARY KEY,
|
||||||
|
a2 INT,
|
||||||
|
UNIQUE (a1, a2)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE public.tb (
|
||||||
|
b1 INT REFERENCES public.ta (a1),
|
||||||
|
b2 INT,
|
||||||
|
FOREIGN KEY (b1, b2) REFERENCES public.ta (a1, a2)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIEW test.va AS SELECT a1 FROM public.ta;
|
||||||
|
CREATE VIEW test.vb AS SELECT b1 FROM public.tb;
|
||||||
|
|
||||||
|
CREATE TABLE test.groups (
|
||||||
|
name text PRIMARY KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE test.yards (
|
||||||
|
id bigint PRIMARY KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE test.group_yard (
|
||||||
|
id bigint NOT NULL,
|
||||||
|
group_id text NOT NULL REFERENCES test.groups(name),
|
||||||
|
yard_id bigint NOT NULL REFERENCES test.yards(id),
|
||||||
|
PRIMARY KEY (id, group_id, yard_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE FUNCTION test.get_yards() RETURNS SETOF test.yards
|
||||||
|
LANGUAGE sql
|
||||||
|
AS $$
|
||||||
|
select * from test.yards;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- view's name is alphabetically before projects
|
||||||
|
create view test.alpha_projects as
|
||||||
|
select c.id, p.name as pro_name, c.name as cli_name
|
||||||
|
from projects p join clients c on p.client_id = c.id;
|
||||||
|
|
||||||
|
-- view's name is alphabetically after projects
|
||||||
|
create view test.zeta_projects as
|
||||||
|
select c.id, p.name as pro_name, c.name as cli_name
|
||||||
|
from projects p join clients c on p.client_id = c.id;
|
||||||
Reference in New Issue
Block a user