Compare commits

...
55 Commits
Author SHA1 Message Date
steve-chavez 5485b8ca9a bump version to 14.4 2026-01-29 14:13:49 -05:00
steve-chavez 895e9c536c chore: remove wrong entry in CHANGELOG 2026-01-29 13:24:20 -05:00
Michal Kleczekandsteve-chavez 16c767134c fix: listener running with exception masked after first failure 2026-01-29 13:22:46 -05:00
Laurence IslaandSteve Chavez 0a8b836435 fix: filtering the returned representation whenn using or/and filters on mutations
(cherry picked from commit 1682677297)
2026-01-29 09:16:08 -05:00
Michal KleczekandSteve Chavez 5796f86100 fix: ensure Listener connections are released
retryingListen function potentially leaks database connections. This patch ensures the connections are released in case of listen/notify errors.

(cherry picked from commit 00c7cb1a22)
2026-01-28 18:26:15 -05:00
Wolfgang Walther 101eac1cce docs: fix links
datrium.com doesn't exist anymore, while euronodes.com seems to only
fail SSL in CI.
2026-01-28 09:57:23 +01:00
renovate[bot]andWolfgang Walther 1ae14afdf2 chore(deps): update haskell-actions/setup action to v2.10.2 2026-01-11 17:36:28 +00:00
Wolfgang Walther 69090bd224 ci: pin backport action to version instead of default branch 2026-01-11 18:34:43 +01:00
renovate[bot]andWolfgang Walther 5d5160fbd7 chore(deps): update haskell-actions/setup action to v2.10.1 2026-01-05 19:01:32 +00:00
steve-chavez 545f45d9de bump version to 14.3 2026-01-03 16:45:56 +08:00
Taimoor Zaeemandsteve-chavez eb55e73645 chore: move changelog entry to unreleased section
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2026-01-03 16:44:10 +08:00
Michał KłeczekandSteve Chavez e252a4900c fix: Performance and high memory usage of relation hint calculation
* Calculation of hint message when requested relation is not present in schema cache requires creation of a FuzzySet (to use fuzzy search to find candidate tables). For schemas with many tables it is costly.
This patch introduces dbTablesFuzzyIndex in SchemaCache to memoize the FuzzySet creation.

* Additionally, because of FuzzySet large memory requirements, this patch introduces a limit of 500 relations per schema, above which FuzzySet is not created and hint calculation disabled.

(cherry picked from commit e592d568c6)
2026-01-03 15:18:37 +08:00
Taimoor ZaeemandSteve Chavez 01bdb05c89 nix: add config file for hlint
Adds a config file for hlint containing arguments and
custom warnings.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2025-12-23 11:51:05 -05:00
122ed4d02e refactor: fix definition of Ord instance for Routine type (#4577)
The `Ord` instance definition for type `Routine` had a logical
error when comparing two routines. The error did not affect any
end users. However, for correctness and completeness reasons, this
commit fixes the error.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
Co-authored-by: Joel Jacobson <joel@compiler.org>
2025-12-23 11:51:05 -05:00
renovate[bot]andWolfgang Walther 7ff6755af7 chore(deps): update docker/setup-buildx-action action to v3.12.0 2025-12-20 20:24:23 +00:00
steve-chavez 29d6d35020 bump version to 14.2 2025-12-18 21:30:38 -05:00
steve-chavez 4d35eb02e7 fix: inaccurate Server-Timing durations
The transaction duration was notably off, doing:

```
curl localhost:3000/rpc/sleep?seconds=5 -i
```

Shows `46.1` for the `transaction;dur`, with this fix we obtain
`5007.3`.

Fixes https://github.com/PostgREST/postgrest/issues/4522

This also fixes inaccurate "schema cache queried" logs,
see https://github.com/PostgREST/postgrest/issues/4551.

(cherry picked from commit 013f078bc4)
2025-12-18 18:18:09 -05:00
renovate[bot]andWolfgang Walther e126956c1b chore(deps): update haskell-actions/setup action to v2.9.1 2025-12-17 18:01:25 +00:00
Joel JakobssonandSteve Chavez ce7871c047 fix: hasSingleUnnamedParam incorrectly matching named parameters
The hasSingleUnnamedParam function was only checking the parameter type
but not whether the parameter actually had no name. This caused functions
with a single NAMED parameter (e.g., `foo(data json)`) to incorrectly
match the single-param fallback mode.

The result was a confusing PostgreSQL error 42883 "function does not exist"
instead of a clean PGRST202 error explaining that no matching function
was found.

Added ppName == mempty check so functions with named parameters don't
incorrectly match the single-param fallback.

(cherry picked from commit fd6a3bdccf)
2025-12-16 17:24:20 -05:00
Taimoor ZaeemandSteve Chavez a59e6d97c5 refactor: create function to handle response preferences
Centralizes handling of preferences that are used to
create `Preference-Applied` header which is returned on
responses.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 29c2aa015f)
2025-12-16 11:20:33 -05:00
renovate[bot]andWolfgang Walther 40fec696a6 chore(deps): update korthout/backport-action action to v4.0.1 2025-12-15 20:17:46 +00:00
renovate[bot]andWolfgang Walther 9eb36f1244 chore(deps): update all dependencies 2025-12-13 11:31:10 +00:00
Laurence IslaandSteve Chavez 720eb8e528 chore(changelog): move incorrectly placed fixes 2025-12-12 14:47:26 -05:00
Laurence IslaandSteve Chavez aec95f7944 refactor: use only Lazy.ByteString to calculate the response body length 2025-12-12 14:47:26 -05:00
Laurence IslaandSteve Chavez 1c33d2dd38 fix: add missing Content-Length to empty HTTP 201 responses
For when a preference other than return=representation is requested.
2025-12-12 14:47:26 -05:00
Laurence IslaandSteve Chavez 7be638f0f2 fix: regression that truncates error message when offset is out of bounds
- Happens when offset > the number of rows and when "Prefer: count=exact" header is sent
- Regression introduced in commit 57ef998
2025-12-12 14:47:26 -05:00
Taimoor ZaeemandSteve Chavez d4b8109522 fix: misleading logs on unsupported postgresql versions
Postgrest fails on unsupported pg versions. However before killing
the thread, it continues to print a few more log messages which
were misleading. This commit fixes this by making sure that the
no log message should be printed after the unsupported pg version
observation and kill the thread immediately.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 0fa2f569a7)
2025-12-10 20:24:14 -05:00
renovate[bot]andWolfgang Walther f2b96b1d41 chore(deps): update codecov/codecov-action action to v5.5.2 2025-12-10 12:24:00 +00:00
renovate[bot]andWolfgang Walther cc43d356c1 chore(deps): update actions/create-github-app-token action to v2.2.1 2025-12-06 11:00:16 +00:00
renovate[bot]andWolfgang Walther 8bcbbd1bfd chore(deps): update all dependencies 2025-12-03 13:52:35 +00:00
renovate[bot]andWolfgang Walther ec4d2e2af3 chore(deps): update actions/checkout action to v6 2025-12-03 13:52:23 +00:00
Taimoor ZaeemandSteve Chavez e8cb0e33eb test(io): fix freeport function to prevent failures
Sometimes, a healthcheck related test fails as occurred in
https://github.com/PostgREST/postgrest/actions/runs/19771357953/job/56655949002.
This happens due to freeport function accidently picking up a used port.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 50eec773bf)
2025-11-30 13:38:55 -05:00
Taimoor ZaeemandSteve Chavez 05074f41c2 test(io): move fixtures to fixtures/ directory
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 35de13eebd)
2025-11-28 13:23:20 -05:00
Taimoor ZaeemandSteve Chavez c9c617cce1 test(io): rename fixtures.sql to load.sql
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 3baa6b0063)
2025-11-28 13:23:20 -05:00
Taimoor ZaeemandWolfgang Walther e429974f31 nix(shell): remove postgrest/ directory prefix when running pg (#4502)
When running postgres from nix-shell, nix creates a directory
structure like `postgrest/postgrest-with-pg-17-XXX` in the `/tmp`
directory. This commit removes the extra `postgrest/` prefix to
shorten length of absolute path length of filenames.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 802cce9a28)
2025-11-26 08:33:23 +00:00
Taimoor ZaeemandSteve Chavez 88538c1357 test(io): move authentication related tests to test_auth.py
The `test_io.py` module is too bloated (2100+ lines). To
logically group related tests, as a first step, this commit
separates authentication related IO tests into `test_auth.py`
module.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit c561a3749c)
2025-11-22 12:45:09 -05:00
Taimoor ZaeemandSteve Chavez 1cbe6b7c5f test(io): remove stale jwt cache test
Removes a test related to jwt cache which is stale
since #4084.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 379eaec8e0)
2025-11-19 16:48:25 -05:00
Taimoor ZaeemandWolfgang Walther f97200948f nix: shorten postgrest-with-postgresql-xx scripts
Renames these scripts to `postgrest-with-pg-xx`. The renaming
helps reduce the length of temporary filenames. This is needed
to ensure that socket file names remain under the maximum
allowed length of 107 chars.

Closes #4461.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 462798dc2d)
2025-11-18 18:27:17 +00:00
Taimoor ZaeemandWolfgang Walther 0bacce6909 docs: update server-host config in docker-compose example
The docker-compose example did not work with multiple containers
when PGRST_SERVER_HOST is set to `localhost`. This updates the
value to `0.0.0.0` allowing other containers to connect.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 08c6deba12)
2025-11-18 18:13:26 +00:00
renovate[bot]andWolfgang Walther 4c3ac2f087 chore(deps): update actions/checkout action to v5.0.1 2025-11-18 10:06:11 +00:00
Wolfgang Walther 1c6f215bd7 ci/test: remove macos x86 flake check
See previous commit.
2025-11-18 11:05:43 +01:00
Wolfgang Walther 94fb0a489a ci: remove macos x86 builds
The x86 GitHub runner will not be available anymore, soon.

We might be able to re-introduce this, once we can build a static
executable via Nix on darwin, too.
2025-11-18 11:01:41 +01:00
Taimoor ZaeemandSteve Chavez e4e1b626a6 test(io): move resource embedding tests to test_io.py
- Adds fixtures to `test/io/fixtures.sql` to test resource
  embedding related queries.

- Moves the resource embedding related tests that no longer
  require big schema from `test_big_schema.py` to `test_io.py`.

Closes #4417.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 91abcd49e1)
2025-11-17 13:24:49 -05:00
renovate[bot]andWolfgang Walther 810341f635 chore(deps): update ubuntu:noble docker digest to c35e29c 2025-11-15 13:52:10 +00:00
Taimoor ZaeemandWolfgang Walther 4a6f0b4a18 chore(cabal): remove unused haskell dependencies
This should reduce setup time for build process.

- cache: introduced in #2928, defunct since #4084
- clock: introduced in #2928, defunct since #4084
- heredoc: introduced in #714, defunct since #4390
- iproute: introduced in #3560, defunct since #4288

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 99217433b1)
2025-11-10 14:24:48 +00:00
steve-chavez b3b4e5ff35 bump version to 14.1 2025-11-05 09:09:22 -05:00
Taimoor ZaeemandWolfgang Walther 84f437b6c9 chore(changelog): update versioning scheme description
The changelog description mentioned that we follow semantic
versioning but from now on we don't. Hence updated the description
to reflect new versioning policy.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit eb908c696f)
2025-11-04 07:25:35 +00:00
renovate[bot]andWolfgang Walther a3c8065e58 chore(deps): update actions/checkout digest to 71cf226 2025-11-03 20:47:35 +00:00
Taimoor ZaeemandWolfgang Walther c797c09e22 fix: server-host !6 incorrectly binds to IPv4 address
Updates streaming-commons to version 0.2.3.1. This resolves #3202.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 58efc2680e)
2025-11-03 11:40:09 +00:00
Taimoor ZaeemandWolfgang Walther fc6fbe9748 chore(changelog): fix typo in changelog entry
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 8180905890)
2025-11-01 09:18:20 +00:00
Taimoor ZaeemandSteve Chavez 4aa712b9d8 fix: db-pre-config function failing with pg reserved words
When db-pre-config is accidentally set to a pg reserved word
like "true", it fails with a confusing error. The function
names should be properly quoted to avoid such errors. This commit
resolves this by quoting the pre-config function name.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit a688878236)
2025-10-30 14:32:37 -05:00
Taimoor ZaeemandSteve Chavez 939061baff refactor: move escapeIdent function to Identifiers.hs
Moves the functions `escapeIdent` and `trimNullChars` to
SchemaCache/Identifiers.hs module.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 66f84c5903)
2025-10-30 12:24:49 -05:00
Taimoor ZaeemandSteve Chavez 6150d53592 refactor: sort exports of Identifiers.hs and SqlFragments.hs
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
(cherry picked from commit 5d9b169380)
2025-10-30 12:24:49 -05:00
Wolfgang Walther d245e07df5 ci: fix tag job with new release workflow
A single component version is the development version, everything with
more components is not. Thus, we only need to check for a single dot.
2025-10-25 10:21:51 +02:00
renovate[bot]andWolfgang Walther e913efb8cb chore(deps): update all dependencies 2025-10-25 08:12:30 +00:00
61 changed files with 1287 additions and 778 deletions
@@ -112,7 +112,7 @@ runs:
echo "artifacts=${artifacts}" >> "$GITHUB_OUTPUT" echo "artifacts=${artifacts}" >> "$GITHUB_OUTPUT"
- name: Save artifact to GitHub Actions - name: Save artifact to GitHub Actions
if: steps.find-task.outputs.task_found if: steps.find-task.outputs.task_found
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: ${{ inputs.upload }} name: ${{ inputs.upload }}
path: ${{ steps.download.outputs.artifacts }} path: ${{ steps.download.outputs.artifacts }}
+2 -2
View File
@@ -19,14 +19,14 @@ inputs:
runs: runs:
using: composite using: composite
steps: steps:
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
if: ${{ startsWith(github.ref, 'refs/heads/') || (inputs.save-prs && startsWith(github.ref, 'refs/pull/')) }} if: ${{ startsWith(github.ref, 'refs/heads/') || (inputs.save-prs && startsWith(github.ref, 'refs/pull/')) }}
with: with:
path: ${{ inputs.path }} path: ${{ inputs.path }}
key: ${{ runner.os }}-${{ inputs.prefix }}-${{ inputs.suffix }} key: ${{ runner.os }}-${{ inputs.prefix }}-${{ inputs.suffix }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ inputs.prefix }}- ${{ runner.os }}-${{ inputs.prefix }}-
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
if: ${{ !startsWith(github.ref, 'refs/heads/') && !(inputs.save-prs && startsWith(github.ref, 'refs/pull/')) }} if: ${{ !startsWith(github.ref, 'refs/heads/') && !(inputs.save-prs && startsWith(github.ref, 'refs/pull/')) }}
with: with:
path: ${{ inputs.path }} path: ${{ inputs.path }}
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
# This actions creates the github token using the postgrest app secrets # This actions creates the github token using the postgrest app secrets
- name: Create Github App Token - name: Create Github App Token
id: app-token id: app-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ vars.POSTGREST_CI_APP_ID }} app-id: ${{ vars.POSTGREST_CI_APP_ID }}
private-key: ${{ secrets.POSTGREST_CI_PRIVATE_KEY }} private-key: ${{ secrets.POSTGREST_CI_PRIVATE_KEY }}
@@ -38,14 +38,14 @@ jobs:
# This is required for backport action to cherry-pick the PR # This is required for backport action to cherry-pick the PR
- name: Fetch PR ref - name: Fetch PR ref
uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
# Backport action that creates the PR with given settings # Backport action that creates the PR with given settings
- name: Create backport PR - name: Create backport PR
uses: korthout/backport-action@d07416681cab29bf2661702f925f020aaa962997 # v3.4.1 uses: korthout/backport-action@c656f5d5851037b2b38fb5db2691a03fa229e3b2 # v4.0.1
with: with:
github_token: ${{ steps.app-token.outputs.token }} github_token: ${{ steps.app-token.outputs.token }}
pull_description: 'Backport for #${pull_number}.' pull_description: 'Backport for #${pull_number}.'
+10 -19
View File
@@ -33,7 +33,7 @@ jobs:
name: Nix - Linux x86-64 static name: Nix - Linux x86-64 static
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
@@ -42,7 +42,7 @@ jobs:
- name: Build static executable - name: Build static executable
run: nix-build -A postgrestStatic -A postgrestStatic.tests run: nix-build -A postgrestStatic -A postgrestStatic.tests
- name: Save built executable as artifact - name: Save built executable as artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: postgrest-linux-static-x86-64 name: postgrest-linux-static-x86-64
path: result/bin/postgrest path: result/bin/postgrest
@@ -51,7 +51,7 @@ jobs:
- name: Build Docker image - name: Build Docker image
run: nix-build -A docker.image --out-link postgrest-docker.tar.gz run: nix-build -A docker.image --out-link postgrest-docker.tar.gz
- name: Save built Docker image as artifact - name: Save built Docker image as artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: postgrest-docker-x86-64 name: postgrest-docker-x86-64
path: postgrest-docker.tar.gz path: postgrest-docker.tar.gz
@@ -62,7 +62,7 @@ jobs:
name: Nix - MacOS name: Nix - MacOS
runs-on: macos-15 runs-on: macos-15
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
@@ -105,15 +105,6 @@ jobs:
artifact: postgrest-macos-aarch64 artifact: postgrest-macos-aarch64
deps: brew link --force libpq deps: brew link --force libpq
- name: MacOS x86-64
runs-on: macos-13
cache: |
~/.stack/pantry
~/.stack/snapshots
~/.stack/stack.sqlite3
artifact: postgrest-macos-x86-64
deps: brew link --force libpq
- name: Windows - name: Windows
runs-on: windows-2022 runs-on: windows-2022
cache: | cache: |
@@ -126,8 +117,8 @@ jobs:
name: Stack - ${{ matrix.name }} name: Stack - ${{ matrix.name }}
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: haskell-actions/setup@82e8b5066385702e477d9dc98f287070493e8abd # v2.8.2 - uses: haskell-actions/setup@dc63c94789664bb2910876ec3dfeeaa24d23b96b # v2.10.2
with: with:
# This must match the version in stack.yaml's resolver # This must match the version in stack.yaml's resolver
ghc-version: 9.6.7 ghc-version: 9.6.7
@@ -155,7 +146,7 @@ jobs:
- name: Strip Executable - name: Strip Executable
run: strip result/postgrest* run: strip result/postgrest*
- name: Save built executable as artifact - name: Save built executable as artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: ${{ matrix.artifact }} name: ${{ matrix.artifact }}
path: | path: |
@@ -168,7 +159,7 @@ jobs:
name: Stack - FreeBSD from CirrusCI name: Stack - FreeBSD from CirrusCI
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: ./.github/actions/artifact-from-cirrus - uses: ./.github/actions/artifact-from-cirrus
with: with:
token: ${{ github.token }} token: ${{ github.token }}
@@ -185,8 +176,8 @@ jobs:
name: Cabal - Linux x86-64 - GHC ${{ matrix.ghc }} name: Cabal - Linux x86-64 - GHC ${{ matrix.ghc }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: haskell-actions/setup@82e8b5066385702e477d9dc98f287070493e8abd # v2.8.2 - uses: haskell-actions/setup@dc63c94789664bb2910876ec3dfeeaa24d23b96b # v2.10.2
with: with:
ghc-version: ${{ matrix.ghc }} ghc-version: ${{ matrix.ghc }}
- name: Cache .cabal - name: Cache .cabal
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
name: Lint & Style name: Lint & Style
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
@@ -36,7 +36,7 @@ jobs:
name: Commit name: Commit
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
fetch-depth: 100 # fetch history (last 100 commits) instead of default shallow clone history, this is deemed enough for a PR history fetch-depth: 100 # fetch history (last 100 commits) instead of default shallow clone history, this is deemed enough for a PR history
- name: Setup Nix Environment - name: Setup Nix Environment
+2 -2
View File
@@ -50,14 +50,14 @@ jobs:
- test - test
- build - build
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ssh-key: ${{ secrets.POSTGREST_SSH_KEY }} ssh-key: ${{ secrets.POSTGREST_SSH_KEY }}
- name: Tag latest commit - name: Tag latest commit
run: | run: |
cabal_version="$(grep -oP '^version:\s*\K.*' postgrest.cabal)" cabal_version="$(grep -oP '^version:\s*\K.*' postgrest.cabal)"
if [[ "$cabal_version" == *.*.*.* ]]; then if [[ "$cabal_version" == *.* ]]; then
git fetch --tags git fetch --tags
if [ -z "$(git tag --list "v$cabal_version")" ]; then if [ -z "$(git tag --list "v$cabal_version")" ]; then
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
name: Build name: Build
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
@@ -41,7 +41,7 @@ jobs:
name: Spellcheck name: Spellcheck
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
linkcheck: linkcheck:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
+10 -13
View File
@@ -26,7 +26,7 @@ jobs:
needs: needs:
- build - build
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Check the version to be released - name: Check the version to be released
run: | run: |
cabal_version="$(grep -oP '^version:\s*\K.*' postgrest.cabal)" cabal_version="$(grep -oP '^version:\s*\K.*' postgrest.cabal)"
@@ -49,7 +49,7 @@ jobs:
echo "Relevant extract from CHANGELOG.md:" echo "Relevant extract from CHANGELOG.md:"
cat CHANGES.md cat CHANGES.md
- name: Save CHANGES.md as artifact - name: Save CHANGES.md as artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: release-changes name: release-changes
path: CHANGES.md path: CHANGES.md
@@ -64,9 +64,9 @@ jobs:
needs: needs:
- prepare - prepare
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: with:
path: artifacts path: artifacts
- name: Create release bundle with archives for all builds - name: Create release bundle with archives for all builds
@@ -81,9 +81,6 @@ jobs:
tar cJvf "release-bundle/postgrest-${GITHUB_REF_NAME}-macos-aarch64.tar.xz" \ tar cJvf "release-bundle/postgrest-${GITHUB_REF_NAME}-macos-aarch64.tar.xz" \
-C artifacts/postgrest-macos-aarch64 postgrest -C artifacts/postgrest-macos-aarch64 postgrest
tar cJvf "release-bundle/postgrest-${GITHUB_REF_NAME}-macos-x86-64.tar.xz" \
-C artifacts/postgrest-macos-x86-64 postgrest
tar cJvf "release-bundle/postgrest-${GITHUB_REF_NAME}-freebsd-x86-64.tar.xz" \ tar cJvf "release-bundle/postgrest-${GITHUB_REF_NAME}-freebsd-x86-64.tar.xz" \
-C artifacts/postgrest-freebsd-x86-64 postgrest -C artifacts/postgrest-freebsd-x86-64 postgrest
@@ -94,7 +91,7 @@ jobs:
artifacts/postgrest-windows-x86-64/postgrest.exe artifacts/postgrest-windows-x86-64/postgrest.exe
- name: Save release bundle - name: Save release bundle
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: release-bundle name: release-bundle
path: release-bundle path: release-bundle
@@ -138,16 +135,16 @@ jobs:
env: env:
DOCKER_REPO: ${{ vars.DOCKER_REPO }} DOCKER_REPO: ${{ vars.DOCKER_REPO }}
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download x86-64 Docker image - name: Download x86-64 Docker image
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: with:
name: postgrest-docker-x86-64 name: postgrest-docker-x86-64
- name: Download aarch64 binary - name: Download aarch64 binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: with:
name: postgrest-ubuntu-aarch64 name: postgrest-ubuntu-aarch64
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
username: ${{ vars.DOCKER_USER }} username: ${{ vars.DOCKER_USER }}
@@ -194,7 +191,7 @@ jobs:
vars.DOCKER_REPO && vars.DOCKER_USER && vars.DOCKER_REPO && vars.DOCKER_USER &&
github.ref == 'refs/tags/devel' github.ref == 'refs/tags/devel'
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 - uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with: with:
username: ${{ vars.DOCKER_USER }} username: ${{ vars.DOCKER_USER }}
+10 -11
View File
@@ -39,7 +39,7 @@ jobs:
# https://github.com/actions/runner/issues/241#issuecomment-842566950 # https://github.com/actions/runner/issues/241#issuecomment-842566950
shell: script -qec "bash --noprofile --norc -eo pipefail {0}" shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
@@ -51,7 +51,7 @@ jobs:
- name: Run coverage (IO tests and Spec tests against PostgreSQL 15) - name: Run coverage (IO tests and Spec tests against PostgreSQL 15)
run: postgrest-coverage run: postgrest-coverage
- name: Upload coverage to codecov - name: Upload coverage to codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
files: ./coverage/codecov.json files: ./coverage/codecov.json
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
@@ -78,33 +78,33 @@ jobs:
# https://github.com/actions/runner/issues/241#issuecomment-842566950 # https://github.com/actions/runner/issues/241#issuecomment-842566950
shell: script -qec "bash --noprofile --norc -eo pipefail {0}" shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
tools: tests.testSpec.bin tests.testIO.bin tests.testBigSchema.bin withTools.postgresql-${{ matrix.pgVersion }}.bin cabalTools.update.bin tools: tests.testSpec.bin tests.testIO.bin tests.testBigSchema.bin withTools.pg-${{ matrix.pgVersion }}.bin cabalTools.update.bin
- run: postgrest-cabal-update - run: postgrest-cabal-update
- name: Run spec tests - name: Run spec tests
if: always() if: always()
run: postgrest-with-postgresql-${{ matrix.pgVersion }} postgrest-test-spec run: postgrest-with-pg-${{ matrix.pgVersion }} postgrest-test-spec
- name: Run IO tests - name: Run IO tests
if: always() if: always()
run: postgrest-with-postgresql-${{ matrix.pgVersion }} postgrest-test-io -vv run: postgrest-with-pg-${{ matrix.pgVersion }} postgrest-test-io -vv
- name: Run IO tests on a big schema - name: Run IO tests on a big schema
if: always() if: always()
run: postgrest-with-postgresql-${{ matrix.pgVersion }} postgrest-test-big-schema -vv run: postgrest-with-pg-${{ matrix.pgVersion }} postgrest-test-big-schema -vv
memory: memory:
name: Memory name: Memory
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Nix Environment - name: Setup Nix Environment
uses: ./.github/actions/setup-nix uses: ./.github/actions/setup-nix
with: with:
@@ -124,7 +124,7 @@ jobs:
name: Loadtest name: Loadtest
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Nix Environment - name: Setup Nix Environment
@@ -152,14 +152,13 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
runs-on: runs-on:
- macos-13 # x86_64-darwin
- macos-14 # aarch64-darwin - macos-14 # aarch64-darwin
- ubuntu-24.04 # x86_64-linux - ubuntu-24.04 # x86_64-linux
- ubuntu-24.04-arm # aarch64-linux - ubuntu-24.04-arm # aarch64-linux
name: Flake Check name: Flake Check
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Nix Environment - name: Setup Nix Environment
+35 -2
View File
@@ -1,10 +1,43 @@
# Change Log # Change Log
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file. From version `14.0` onwards PostgREST follows a `MAJOR.PATCH` two-part versioning. Only even-numbered MAJOR versions will be released, reserving odd-numbered MAJOR versions for development.
This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased ## Unreleased
## [14.4] - 2026-01-29
### Fixed
- Ensure Listener connections are released by @mkleczek in #4614
- Fix incorrectly filtering the returned representation for PATCH requests when using `or/and` filters by @laurenceisla in #3707
- Fix listener running with exception masked after first failure by @mkleczek #4615
## [14.3] - 2026-01-03
### Fixed
- Fix performance and high memory usage of relation hint calculation by @mkleczek in #4462, #4463
## [14.2] - 2025-12-18
### Fixed
- Fix `hasSingleUnnamedParam` incorrectly matching functions with named parameters by @joelonsql in #4553
+ Functions with a single named parameter (e.g., `foo(data json)`) no longer incorrectly match the single-param fallback, returning a clean `PGRST202` error instead of a confusing PostgreSQL `42883` error.
- Fix misleading logs on unsupported PostgreSQL versions by @taimoorzaeem in #4519
- Fix regression where the `PGRST103` error response was truncated by @laurenceisla in #4455
+ Happened when an `offset` was greater than the rows requested and `Prefer: count=exact` was sent.
- Fix not returning `Content-Length` on empty HTTP `201` responses by @laurenceisla in #4518
- Fix inaccurate Server-Timing header durations by @steve-chavez in #4522
- Fix inaccurate "Schema cache queried" logs by @steve-chavez in #4522
## [14.1] - 2025-11-05
## Fixed
- Fix `db-pre-config` function failing when function names are pg reserved words by @taimoorzaeem in #4380
- Fix `server-host=!6` incorrectly binds to IPv4 address by @taimoorzaeem in #3202
## [14.0] - 2025-10-24 ## [14.0] - 2025-10-24
### Added ### Added
+1 -1
View File
@@ -2,7 +2,7 @@
# The x86-64 is a single-static-binary image built via Nix, see: # The x86-64 is a single-static-binary image built via Nix, see:
# nix/tools/docker/README.md # nix/tools/docker/README.md
FROM ubuntu:noble@sha256:66460d557b25769b102175144d538d88219c077c678a49af4afca6fbfc1b5252 AS postgrest FROM ubuntu:noble@sha256:c35e29c9450151419d9448b0fd75374fec4fff364a27f176fb458d472dfc9e54 AS postgrest
RUN apt-get update -y \ RUN apt-get update -y \
&& apt install -y --no-install-recommends libpq-dev zlib1g-dev jq gcc libnuma-dev \ && apt install -y --no-install-recommends libpq-dev zlib1g-dev jq gcc libnuma-dev \
+1 -1
View File
@@ -1 +1 @@
index-state: hackage.haskell.org 2025-10-13T04:53:27Z index-state: hackage.haskell.org 2025-10-29T04:02:18Z
+5 -5
View File
@@ -53,11 +53,11 @@ let
postgresqlVersions = postgresqlVersions =
[ [
{ name = "postgresql-17"; postgresql = pkgs.postgresql_17.withPackages (p: [ p.postgis p.pg_safeupdate ]); } { name = "pg-17"; postgresql = pkgs.postgresql_17.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-16"; postgresql = pkgs.postgresql_16.withPackages (p: [ p.postgis p.pg_safeupdate ]); } { name = "pg-16"; postgresql = pkgs.postgresql_16.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
{ name = "postgresql-15"; postgresql = pkgs.postgresql_15.withPackages (p: [ p.postgis p.pg_safeupdate ]); } { name = "pg-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 = "pg-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 = "pg-13"; postgresql = pkgs.postgresql_13.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
]; ];
haskellPackages = pkgs.haskell.packages."${compiler}"; haskellPackages = pkgs.haskell.packages."${compiler}";
+1
View File
@@ -302,6 +302,7 @@ linkcheck_ignore = [
r"https://www.cybertec-postgresql.com/.*", r"https://www.cybertec-postgresql.com/.*",
# Odd SSL error # Odd SSL error
r"https://www.dripdepot.com", r"https://www.dripdepot.com",
r"https://www.euronodes.com",
# New GitHub UI delays comment load, so anchor fails # New GitHub UI delays comment load, so anchor fails
r"https://github.com/.*#issuecomment", r"https://github.com/.*#issuecomment",
# Random 500 Internal Server Error # Random 500 Internal Server Error
+1 -1
View File
@@ -146,7 +146,7 @@ To avoid having to install the database at all, you can run both it and the serv
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
PGRST_SERVER_HOST: localhost # necessary for `postgrest --ready` flag to work PGRST_SERVER_HOST: 0.0.0.0 # necessary for `postgrest --ready` flag to work
PGRST_DB_URI: postgres://app_user:password@db:5432/app_db PGRST_DB_URI: postgres://app_user:password@db:5432/app_db
PGRST_OPENAPI_SERVER_PROXY_URI: http://127.0.0.1:3000 PGRST_OPENAPI_SERVER_PROXY_URI: http://127.0.0.1:3000
depends_on: depends_on:
-1
View File
@@ -213,7 +213,6 @@ In Production
Here are some companies that use PostgREST in production. Here are some companies that use PostgREST in production.
* `Catarse <https://www.catarse.me>`_ * `Catarse <https://www.catarse.me>`_
* `Datrium <https://www.datrium.com>`_
* `Drip Depot <https://www.dripdepot.com>`_ * `Drip Depot <https://www.dripdepot.com>`_
* `Image-charts <https://www.image-charts.com>`_ * `Image-charts <https://www.image-charts.com>`_
* `Netwo <https://www.netwo.io>`_ * `Netwo <https://www.netwo.io>`_
+7 -7
View File
@@ -91,11 +91,11 @@ postgrest-gen-ctags postgrest-watch
postgrest-gen-jwt postgrest-with-all postgrest-gen-jwt postgrest-with-all
postgrest-gen-secret postgrest-with-git postgrest-gen-secret postgrest-with-git
postgrest-git-hooks postgrest-with-pgrst postgrest-git-hooks postgrest-with-pgrst
postgrest-hsie-graph-modules postgrest-with-postgresql-13 postgrest-hsie-graph-modules postgrest-with-pg-13
postgrest-hsie-graph-symbols postgrest-with-postgresql-14 postgrest-hsie-graph-symbols postgrest-with-pg-14
postgrest-hsie-minimal-imports postgrest-with-postgresql-15 postgrest-hsie-minimal-imports postgrest-with-pg-15
postgrest-lint postgrest-with-postgresql-16 postgrest-lint postgrest-with-pg-16
postgrest-loadtest postgrest-with-postgresql-17 postgrest-loadtest postgrest-with-pg-17
postgrest-loadtest-against postgrest-with-slow-pg postgrest-loadtest-against postgrest-with-slow-pg
postgrest-loadtest-report postgrest-with-slow-postgrest postgrest-loadtest-report postgrest-with-slow-postgrest
postgrest-nixpkgs-upgrade postgrest-nixpkgs-upgrade
@@ -174,7 +174,7 @@ $ nix-shell --run "postgrest-with-all postgrest-test-spec"
# Run the tests against a specific version of PostgreSQL (use tab-completion in # Run the tests against a specific version of PostgreSQL (use tab-completion in
# nix-shell to see all available versions): # nix-shell to see all available versions):
$ nix-shell --run "postgrest-with-postgresql-13 postgrest-test-spec" $ nix-shell --run "postgrest-with-pg-13 postgrest-test-spec"
``` ```
@@ -284,7 +284,7 @@ Tools like `postgrest-build`, `postgrest-run`, `postgrest-repl` etc. are simple
also run in CI, with the exception of the IO and Memory checks that need to be run also run in CI, with the exception of the IO and Memory checks that need to be run
separately. separately.
`postgrest-with-postgresql-*` take a command as an argument and will run it `postgrest-with-pg-*` take a command as an argument and will run it
with a temporary database. `postgrest-with-all` will run the command against with a temporary database. `postgrest-with-all` will run the command against
all supported PostgreSQL versions. Tests run without `postgrest-with-*` are all supported PostgreSQL versions. Tests run without `postgrest-with-*` are
run against the latest PostgreSQL version by default. run against the latest PostgreSQL version by default.
@@ -104,8 +104,7 @@ let
'' ''
+ lib.optionalString withTmpDir '' + lib.optionalString withTmpDir ''
mkdir -p "''${TMPDIR:-/tmp}/postgrest" tmpdir="$(${coreutils}/bin/mktemp -d --tmpdir ${name}-XXX)"
tmpdir="$(${coreutils}/bin/mktemp -d --tmpdir postgrest/${name}-XXX)"
# we keep the tmpdir when an error occurs for debugging # we keep the tmpdir when an error occurs for debugging
trap 'echo Temporary directory kept at: $tmpdir' ERR trap 'echo Temporary directory kept at: $tmpdir' ERR
+10
View File
@@ -60,6 +60,16 @@ let
} }
{ }; { };
# TODO: Remove once available in nixpkgs haskellPackages
streaming-commons =
prev.callHackageDirect
{
pkg = "streaming-commons";
ver = "0.2.3.1";
sha256 = "sha256-Gl2eaJcWe1sxmcE/octWlH9uSnERguf+5H66K4fV87s=";
}
{ };
# Downgrade hasql and related packages while we are still on GHC 9.4 for the static build. # Downgrade hasql and related packages while we are still on GHC 9.4 for the static build.
hasql = lib.dontCheck (lib.doJailbreak prev.hasql_1_6_4_4); hasql = lib.dontCheck (lib.doJailbreak prev.hasql_1_6_4_4);
hasql-dynamic-statements = lib.dontCheck prev.hasql-dynamic-statements_0_3_1_5; hasql-dynamic-statements = lib.dontCheck prev.hasql-dynamic-statements_0_3_1_5;
+16 -1
View File
@@ -12,6 +12,7 @@
, silver-searcher , silver-searcher
, statix , statix
, stylish-haskell , stylish-haskell
, writeText
}: }:
let let
style = style =
@@ -51,6 +52,20 @@ let
${git}/bin/git diff-index --exit-code HEAD -- '*.hs' '*.lhs' '*.nix' '*.py' ${git}/bin/git diff-index --exit-code HEAD -- '*.hs' '*.lhs' '*.nix' '*.py'
''; '';
hlintConfig = writeText "hlintConfig.yml" ''
# Arguments passed to hlint
- arguments: [-j, -XQuasiQuotes, -XNoPatternSynonyms]
# Warnings
- warn: { lhs: "a == a", rhs: "True", note: "This comparison always evaluates to True" }
- warn: { lhs: "a /= a", rhs: "False", note: "This comparison always evaluates to False" }
- warn: { lhs: "a < a", rhs: "False", note: "This comparison always evaluates to False" }
- warn: { lhs: "a > a", rhs: "False", note: "This comparison always evaluates to False" }
- warn: { lhs: "a <= a", rhs: "True", note: "This comparison always evaluates to True" }
- warn: { lhs: "a >= a", rhs: "True", note: "This comparison always evaluates to True" }
'';
lint = lint =
checkedShellScript checkedShellScript
{ {
@@ -79,7 +94,7 @@ let
echo "Linting Haskell files..." echo "Linting Haskell files..."
# --vimgrep fixes a bug in ag: https://github.com/ggreer/the_silver_searcher/issues/753 # --vimgrep fixes a bug in ag: https://github.com/ggreer/the_silver_searcher/issues/753
${silver-searcher}/bin/ag -l --vimgrep -g '\.l?hs$' . \ ${silver-searcher}/bin/ag -l --vimgrep -g '\.l?hs$' . \
| xargs ${hlint}/bin/hlint -j -X QuasiQuotes -X NoPatternSynonyms | xargs ${hlint}/bin/hlint --hint=${hlintConfig}
''; '';
in in
+6 -6
View File
@@ -83,7 +83,7 @@ let
} }
'' ''
${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest ${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest
${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} -f test/io/fixtures.sql \ ${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} -f test/io/fixtures/load.sql \
${ioTestPython}/bin/pytest --ignore=test/io/test_big_schema.py --ignore=test/io/test_replica.py -v test/io "''${_arg_leftovers[@]}" ${ioTestPython}/bin/pytest --ignore=test/io/test_big_schema.py --ignore=test/io/test_replica.py -v test/io "''${_arg_leftovers[@]}"
''; '';
@@ -98,7 +98,7 @@ let
} }
'' ''
${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest ${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest
${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} -f test/io/big_schema.sql \ ${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} -f test/io/fixtures/big_schema.sql \
${ioTestPython}/bin/pytest -v test/io/test_big_schema.py "''${_arg_leftovers[@]}" ${ioTestPython}/bin/pytest -v test/io/test_big_schema.py "''${_arg_leftovers[@]}"
''; '';
@@ -113,7 +113,7 @@ let
} }
'' ''
${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest ${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest
${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} --replica -f test/io/replica.sql \ ${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} --replica -f test/io/fixtures/replica.sql \
${ioTestPython}/bin/pytest -v test/io/test_replica.py "''${_arg_leftovers[@]}" ${ioTestPython}/bin/pytest -v test/io/test_replica.py "''${_arg_leftovers[@]}"
''; '';
@@ -164,15 +164,15 @@ let
# collect all tests # collect all tests
HPCTIXFILE="$tmpdir"/io.tix \ HPCTIXFILE="$tmpdir"/io.tix \
${withTools.withPg} -f test/io/fixtures.sql \ ${withTools.withPg} -f test/io/fixtures/load.sql \
${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- ${ioTestPython}/bin/pytest --ignore=test/io/test_big_schema.py --ignore=test/io/test_replica.py -v test/io ${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- ${ioTestPython}/bin/pytest --ignore=test/io/test_big_schema.py --ignore=test/io/test_replica.py -v test/io
HPCTIXFILE="$tmpdir"/big_schema.tix \ HPCTIXFILE="$tmpdir"/big_schema.tix \
${withTools.withPg} -f test/io/big_schema.sql \ ${withTools.withPg} -f test/io/fixtures/big_schema.sql \
${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- ${ioTestPython}/bin/pytest -v test/io/test_big_schema.py ${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- ${ioTestPython}/bin/pytest -v test/io/test_big_schema.py
HPCTIXFILE="$tmpdir"/replica.tix \ HPCTIXFILE="$tmpdir"/replica.tix \
${withTools.withPg} --replica -f test/io/replica.sql \ ${withTools.withPg} --replica -f test/io/fixtures/replica.sql \
${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- ${ioTestPython}/bin/pytest -v test/io/test_replica.py ${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- ${ioTestPython}/bin/pytest -v test/io/test_replica.py
HPCTIXFILE="$tmpdir"/spec.tix \ HPCTIXFILE="$tmpdir"/spec.tix \
+8 -2
View File
@@ -46,7 +46,7 @@ let
} }
# Avoid starting multiple layers of withTmpDb, but make sure to have the last invocation # Avoid starting multiple layers of withTmpDb, but make sure to have the last invocation
# load fixtures. Otherwise postgrest-with-postgresql-xx postgrest-test-io would not be possible. # load fixtures. Otherwise postgrest-with-pg-xx postgrest-test-io would not be possible.
if ! test -v PGHOST; then if ! test -v PGHOST; then
mkdir -p "$tmpdir"/{db,socket} mkdir -p "$tmpdir"/{db,socket}
@@ -74,7 +74,13 @@ let
>> "$setuplog" >> "$setuplog"
log "Starting the database cluster..." log "Starting the database cluster..."
# Instead of listening on a local port, we will listen on a unix domain socket. # Instead of listening on a local port, we will listen on a unix domain socket.
# NOTE: unix domain socket filename name must remain under max limit.
# On Linux, it's 108 chars (including '\0' terminator)
# On MacOS, it's 104 chars
# See: https://serverfault.com/questions/641347/check-if-a-path-exceeds-maximum-for-unix-domain-socket
pg_ctl -l "$tmpdir/db.log" -w start -o "-F -c listen_addresses=\"\" -c hba_file=$HBA_FILE -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"
@@ -433,7 +439,7 @@ buildToolbox
withSlowPg withSlowPg
withSlowPgrst; withSlowPgrst;
} // builtins.listToAttrs ( } // builtins.listToAttrs (
# Create a `postgrest-with-postgresql-` for each PostgreSQL version # Create a `postgrest-with-pg-` for each PostgreSQL version
builtins.map (pg: { inherit (pg) name; value = withTmpDb pg; }) postgresqlVersions builtins.map (pg: { inherit (pg) name; value = withTmpDb pg; }) postgresqlVersions
); );
# make latest withPg available for other nix files # make latest withPg available for other nix files
+3 -7
View File
@@ -1,5 +1,5 @@
name: postgrest name: postgrest
version: 14.0 version: 14.4
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
@@ -94,6 +94,7 @@ library
PostgREST.Response.OpenAPI PostgREST.Response.OpenAPI
PostgREST.Response.GucHeader PostgREST.Response.GucHeader
PostgREST.Response.Performance PostgREST.Response.Performance
PostgREST.TimeIt
PostgREST.Version PostgREST.Version
build-depends: base >= 4.9 && < 4.20 build-depends: base >= 4.9 && < 4.20
, HTTP >= 4000.3.7 && < 4000.5 , HTTP >= 4000.3.7 && < 4000.5
@@ -102,10 +103,8 @@ library
, auto-update >= 0.1.4 && < 0.3 , auto-update >= 0.1.4 && < 0.3
, base64-bytestring >= 1 && < 1.3 , base64-bytestring >= 1 && < 1.3
, bytestring >= 0.10.8 && < 0.13 , bytestring >= 0.10.8 && < 0.13
, cache >= 0.1.3 && < 0.2.0
, case-insensitive >= 1.2 && < 1.3 , case-insensitive >= 1.2 && < 1.3
, cassava >= 0.4.5 && < 0.6 , cassava >= 0.4.5 && < 0.6
, clock >= 0.8.3 && < 0.9.0
, configurator-pg >= 0.2.11 && < 0.3 , configurator-pg >= 0.2.11 && < 0.3
, containers >= 0.5.7 && < 0.7 , containers >= 0.5.7 && < 0.7
, cookie >= 0.4.2 && < 0.6 , cookie >= 0.4.2 && < 0.6
@@ -118,11 +117,9 @@ library
, hasql-notifications >= 0.2.2.2 && < 0.2.3 , hasql-notifications >= 0.2.2.2 && < 0.2.3
, hasql-pool >= 1.0.1 && < 1.1 , hasql-pool >= 1.0.1 && < 1.1
, hasql-transaction >= 1.0.1 && < 1.2 , hasql-transaction >= 1.0.1 && < 1.2
, heredoc >= 0.2 && < 0.3
, http-client >= 0.7.19 && < 0.8 , http-client >= 0.7.19 && < 0.8
, 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
, iproute >= 1.7.0 && < 1.8
, jose-jwt >= 0.9.6 && < 0.11 , jose-jwt >= 0.9.6 && < 0.11
, lens >= 4.14 && < 5.4 , lens >= 4.14 && < 5.4
, lens-aeson >= 1.0.1 && < 1.3 , lens-aeson >= 1.0.1 && < 1.3
@@ -139,11 +136,10 @@ library
, regex-tdfa >= 1.2.2 && < 1.4 , regex-tdfa >= 1.2.2 && < 1.4
, retry >= 0.7.4 && < 0.10 , retry >= 0.7.4 && < 0.10
, scientific >= 0.3.4 && < 0.4 , scientific >= 0.3.4 && < 0.4
, streaming-commons >= 0.1.1 && < 0.3 , streaming-commons >= 0.2.3.1 && < 0.3
, swagger2 >= 2.4 && < 2.9 , swagger2 >= 2.4 && < 2.9
, text >= 1.2.2 && < 2.2 , text >= 1.2.2 && < 2.2
, time >= 1.6 && < 1.13 , time >= 1.6 && < 1.13
, timeit >= 2.0 && < 2.1
, unordered-containers >= 0.2.8 && < 0.3 , unordered-containers >= 0.2.8 && < 0.3
, unix-compat >= 0.5.4 && < 0.8 , unix-compat >= 0.5.4 && < 0.8
, vault >= 0.3.1.5 && < 0.4 , vault >= 0.3.1.5 && < 0.4
+1 -1
View File
@@ -51,13 +51,13 @@ import PostgREST.Observation (Observation (..))
import PostgREST.Response.Performance (ServerTiming (..), import PostgREST.Response.Performance (ServerTiming (..),
serverTimingHeader) serverTimingHeader)
import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.TimeIt (timeItT)
import PostgREST.Version (docsVersion, prettyVersion) import PostgREST.Version (docsVersion, prettyVersion)
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.List as L import qualified Data.List as L
import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP.Types as HTTP
import Protolude hiding (Handler) import Protolude hiding (Handler)
import System.TimeIt (timeItT)
type Handler = ExceptT Error type Handler = ExceptT Error
+11 -9
View File
@@ -44,8 +44,8 @@ import qualified PostgREST.Error as Error
import qualified PostgREST.Logger as Logger import qualified PostgREST.Logger as Logger
import qualified PostgREST.Metrics as Metrics import qualified PostgREST.Metrics as Metrics
import PostgREST.Observation import PostgREST.Observation
import PostgREST.TimeIt (timeItT)
import PostgREST.Version (prettyVersion) import PostgREST.Version (prettyVersion)
import System.TimeIt (timeItT)
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate, import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
updateAction) updateAction)
@@ -69,7 +69,7 @@ import PostgREST.Config.PgVersion (PgVersion (..),
import PostgREST.SchemaCache (SchemaCache (..), import PostgREST.SchemaCache (SchemaCache (..),
querySchemaCache, querySchemaCache,
showSummary) showSummary)
import PostgREST.SchemaCache.Identifiers (dumpQi) import PostgREST.SchemaCache.Identifiers (quoteQi)
import PostgREST.Unix (createAndBindDomainSocket) import PostgREST.Unix (createAndBindDomainSocket)
import Data.Streaming.Network (bindPortTCP, bindRandomPortTCP) import Data.Streaming.Network (bindPortTCP, bindRandomPortTCP)
@@ -382,14 +382,16 @@ retryingSchemaCacheLoad appState@AppState{stateObserver=observer, stateMainThrea
observer ExitDBNoRecoveryObs observer ExitDBNoRecoveryObs
killThread mainThreadId killThread mainThreadId
return Nothing return Nothing
Right actualPgVersion -> do Right actualPgVersion ->
when (actualPgVersion < minimumPgVersion) $ do if actualPgVersion < minimumPgVersion then do
observer $ ExitUnsupportedPgVersion actualPgVersion minimumPgVersion observer $ ExitUnsupportedPgVersion actualPgVersion minimumPgVersion
killThread mainThreadId killThread mainThreadId
observer $ DBConnectedObs $ pgvFullName actualPgVersion return Nothing
observer $ PoolInit configDbPoolSize else do
putPgVersion appState actualPgVersion observer $ DBConnectedObs $ pgvFullName actualPgVersion
return $ Just actualPgVersion observer $ PoolInit configDbPoolSize
putPgVersion appState actualPgVersion
return $ Just actualPgVersion
qInDbConfig :: IO () qInDbConfig :: IO ()
qInDbConfig = do qInDbConfig = do
@@ -441,7 +443,7 @@ readInDbConfig startingUp appState@AppState{stateObserver=observer} = do
pgVer <- getPgVersion appState pgVer <- getPgVersion appState
dbSettings <- dbSettings <-
if configDbConfig conf then do if configDbConfig conf then do
qDbSettings <- usePool appState (queryDbSettings (dumpQi <$> configDbPreConfig conf) (configDbPreparedStatements conf)) qDbSettings <- usePool appState (queryDbSettings (quoteQi <$> configDbPreConfig conf) (configDbPreparedStatements conf))
case qDbSettings of case qDbSettings of
Left e -> do Left e -> do
observer $ ConfigReadErrorObs e observer $ ConfigReadErrorObs e
+1 -1
View File
@@ -25,8 +25,8 @@ import qualified Network.Wai as Wai
import qualified Network.Wai.Middleware.HttpAuth as Wai import qualified Network.Wai.Middleware.HttpAuth as Wai
import Data.List (lookup) import Data.List (lookup)
import PostgREST.TimeIt (timeItT)
import System.IO.Unsafe (unsafePerformIO) import System.IO.Unsafe (unsafePerformIO)
import System.TimeIt (timeItT)
import PostgREST.AppState (AppState, getConfig, getJwtCacheState, import PostgREST.AppState (AppState, getConfig, getJwtCacheState,
getTime) getTime)
+7 -9
View File
@@ -3,6 +3,7 @@ Module : PostgREST.Error
Description : PostgREST error HTTP responses Description : PostgREST error HTTP responses
-} -}
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.Error module PostgREST.Error
@@ -41,6 +42,7 @@ import Network.HTTP.Types.Header (Header)
import PostgREST.MediaType (MediaType (..)) import PostgREST.MediaType (MediaType (..))
import qualified PostgREST.MediaType as MediaType import qualified PostgREST.MediaType as MediaType
import PostgREST.SchemaCache (SchemaCache (SchemaCache, dbTablesFuzzyIndex))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..), import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
Schema) Schema)
import PostgREST.SchemaCache.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
@@ -49,10 +51,8 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
RelationshipsMap) RelationshipsMap)
import PostgREST.SchemaCache.Routine (Routine (..), import PostgREST.SchemaCache.Routine (Routine (..),
RoutineParam (..)) RoutineParam (..))
import PostgREST.SchemaCache.Table (Table (..))
import Protolude import Protolude
class (ErrorBody a, JSON.ToJSON a) => PgrstError a where class (ErrorBody a, JSON.ToJSON a) => PgrstError a where
status :: a -> HTTP.Status status :: a -> HTTP.Status
headers :: a -> [Header] headers :: a -> [Header]
@@ -250,7 +250,7 @@ data SchemaCacheError
| NoRelBetween Text Text (Maybe Text) Text RelationshipsMap | NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
| NoRpc Text Text [Text] MediaType Bool [QualifiedIdentifier] [Routine] | NoRpc Text Text [Text] MediaType Bool [QualifiedIdentifier] [Routine]
| ColumnNotFound Text Text | ColumnNotFound Text Text
| TableNotFound Text Text [Table] | TableNotFound Text Text SchemaCache
deriving Show deriving Show
instance PgrstError SchemaCacheError where instance PgrstError SchemaCacheError where
@@ -313,7 +313,7 @@ instance ErrorBody SchemaCacheError where
where where
onlySingleParams = isInvPost && contentType `elem` [MTTextPlain, MTTextXML, MTOctetStream] onlySingleParams = isInvPost && contentType `elem` [MTTextPlain, MTTextXML, MTOctetStream]
hint (AmbiguousRpc _) = Just "Try renaming the parameters or the function itself in the database so function overloading can be resolved" hint (AmbiguousRpc _) = Just "Try renaming the parameters or the function itself in the database so function overloading can be resolved"
hint (TableNotFound schemaName relName tbls) = JSON.String <$> tableNotFoundHint schemaName relName tbls hint (TableNotFound schemaName relName schemaCache) = JSON.String <$> tableNotFoundHint schemaName relName schemaCache
hint _ = Nothing hint _ = Nothing
@@ -428,13 +428,11 @@ noRpcHint schema procName params allProcs overloadedProcs =
-- | -- |
-- Do a fuzzy search in all tables in the same schema and return closest result -- Do a fuzzy search in all tables in the same schema and return closest result
tableNotFoundHint :: Text -> Text -> [Table] -> Maybe Text tableNotFoundHint :: Text -> Text -> SchemaCache -> Maybe Text
tableNotFoundHint schema tblName tblList tableNotFoundHint schema tblName SchemaCache{dbTablesFuzzyIndex}
= fmap (\tbl -> "Perhaps you meant the table '" <> schema <> "." <> tbl <> "'") perhapsTable = fmap (\tbl -> "Perhaps you meant the table '" <> schema <> "." <> tbl <> "'") perhapsTable
where where
perhapsTable = Fuzzy.getOne fuzzyTableSet tblName perhapsTable = (`Fuzzy.getOne` tblName) =<< HM.lookup schema dbTablesFuzzyIndex
fuzzyTableSet = Fuzzy.fromList [ tableName tbl | tbl <- tblList, tableSchema tbl == schema]
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
+36 -21
View File
@@ -1,3 +1,4 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
@@ -15,6 +16,7 @@ import PostgREST.Version (prettyVersion)
import qualified PostgREST.AppState as AppState import qualified PostgREST.AppState as AppState
import qualified PostgREST.Config as Config import qualified PostgREST.Config as Config
import Data.Either.Combinators (whenRight)
import Protolude import Protolude
-- | Starts the Listener in a thread -- | Starts the Listener in a thread
@@ -22,15 +24,16 @@ runListener :: AppState -> IO ()
runListener appState = do runListener appState = do
AppConfig{..} <- getConfig appState AppConfig{..} <- getConfig appState
when configDbChannelEnabled $ when configDbChannelEnabled $
void . forkIO $ retryingListen appState void . forkIO . void $ retryingListen appState
-- | Starts a LISTEN connection and handles notifications. It recovers with exponential backoff with a cap of 32 seconds, if the LISTEN connection is lost. -- | Starts a LISTEN connection and handles notifications. It recovers with exponential backoff with a cap of 32 seconds, if the LISTEN connection is lost.
retryingListen :: AppState -> IO () -- | This function never returns (but can throw) and return type enforces that.
retryingListen :: AppState -> IO Void
retryingListen appState = do retryingListen appState = do
AppConfig{..} <- AppState.getConfig appState AppConfig{..} <- AppState.getConfig appState
let let
dbChannel = toS configDbChannel dbChannel = toS configDbChannel
handleFinally err = do onError err = do
AppState.putIsListenerOn appState False AppState.putIsListenerOn appState False
observer $ DBListenFail dbChannel (Right err) observer $ DBListenFail dbChannel (Right err)
unless configDbPoolAutomaticRecovery $ unless configDbPoolAutomaticRecovery $
@@ -42,29 +45,39 @@ retryingListen appState = do
threadDelay (delay * oneSecondInMicro) threadDelay (delay * oneSecondInMicro)
unless (delay == maxDelay) $ unless (delay == maxDelay) $
AppState.putNextListenerDelay appState (delay * 2) AppState.putNextListenerDelay appState (delay * 2)
-- loop running the listener
retryingListen appState retryingListen appState
-- forkFinally allows to detect if the thread dies -- Execute the listener with with error handling
void . flip forkFinally handleFinally $ do handle onError $ do
dbOrError <- SQL.acquire $ toUtf8 (Config.addTargetSessionAttrs $ Config.addFallbackAppName prettyVersion configDbUri) -- Make sure we don't leak connections on errors
case dbOrError of bracket
Right db -> do -- acquire connection
SQL.listen db $ SQL.toPgIdentifier dbChannel (SQL.acquire $ toUtf8 (Config.addTargetSessionAttrs $ Config.addFallbackAppName prettyVersion configDbUri))
AppState.putIsListenerOn appState True -- release connection
(`whenRight` releaseConnection) $
-- use connection
\case
Right db -> do
SQL.listen db $ SQL.toPgIdentifier dbChannel
AppState.putIsListenerOn appState True
delay <- AppState.getNextListenerDelay appState delay <- AppState.getNextListenerDelay appState
when (delay > 1) $ do -- if we did a retry when (delay > 1) $ do -- if we did a retry
-- assume we lost notifications, refresh the schema cache -- assume we lost notifications, refresh the schema cache
AppState.schemaCacheLoader appState AppState.schemaCacheLoader appState
-- reset the delay -- reset the delay
AppState.putNextListenerDelay appState 1 AppState.putNextListenerDelay appState 1
observer $ DBListenStart dbChannel observer $ DBListenStart dbChannel
SQL.waitForNotifications handleNotification db
Left err -> do -- wait for notifications
observer $ DBListenFail dbChannel (Left err) -- this will never return, in case of an error it will throw and be caught by onError
exitFailure forever $ SQL.waitForNotifications handleNotification db
Left err -> do
observer $ DBListenFail dbChannel (Left err)
exitFailure
where where
observer = AppState.getObserver appState observer = AppState.getObserver appState
mainThreadId = AppState.getMainThreadId appState mainThreadId = AppState.getMainThreadId appState
@@ -79,3 +92,5 @@ retryingListen appState = do
cacheReloader = cacheReloader =
AppState.schemaCacheLoader appState AppState.schemaCacheLoader appState
releaseConnection = void . forkIO . handle (observer . DBListenerConnectionCleanupFail) . SQL.release
+7 -5
View File
@@ -44,10 +44,11 @@ data Observation
| SchemaCacheLoadedObs Double | SchemaCacheLoadedObs Double
| ConnectionRetryObs Int | ConnectionRetryObs Int
| DBListenStart Text | DBListenStart Text
| DBListenFail Text (Either SQL.ConnectionError (Either SomeException ())) | DBListenFail Text (Either SQL.ConnectionError SomeException)
| DBListenRetry Int | DBListenRetry Int
| DBListenerGotSCacheMsg ByteString | DBListenerGotSCacheMsg ByteString
| DBListenerGotConfigMsg ByteString | DBListenerGotConfigMsg ByteString
| DBListenerConnectionCleanupFail SomeException
| QueryObs MainQuery Status | QueryObs MainQuery Status
| ConfigReadErrorObs SQL.UsageError | ConfigReadErrorObs SQL.UsageError
| ConfigInvalidObs Text | ConfigInvalidObs Text
@@ -118,6 +119,8 @@ observationMessage = \case
"Received a schema cache reload message on the " <> show channel <> " channel" "Received a schema cache reload message on the " <> show channel <> " channel"
DBListenerGotConfigMsg channel -> DBListenerGotConfigMsg channel ->
"Received a config reload message on the " <> show channel <> " channel" "Received a config reload message on the " <> show channel <> " channel"
DBListenerConnectionCleanupFail ex ->
"Failed during listener connection cleanup: " <> showOnSingleLine '\t' (show ex)
QueryObs{} -> QueryObs{} ->
mempty -- TODO pending refactor: The logic for printing the query cannot be done here. Join the observationMessage function into observationLogger to avoid this mempty. mempty -- TODO pending refactor: The logic for printing the query cannot be done here. Join the observationMessage function into observationLogger to avoid this mempty.
ConfigReadErrorObs usageErr -> ConfigReadErrorObs usageErr ->
@@ -156,7 +159,7 @@ observationMessage = \case
"Evicted entry from JWT cache" "Evicted entry from JWT cache"
where where
showMillis :: Double -> Text showMillis :: Double -> Text
showMillis x = toS $ showFFloat (Just 1) (x * 1000) "" showMillis x = toS $ showFFloat (Just 1) x ""
jsonMessage err = T.decodeUtf8 . LBS.toStrict . Error.errorPayload $ Error.PgError False err jsonMessage err = T.decodeUtf8 . LBS.toStrict . Error.errorPayload $ Error.PgError False err
@@ -164,9 +167,8 @@ observationMessage = \case
showListenerConnError :: SQL.ConnectionError -> Text showListenerConnError :: SQL.ConnectionError -> Text
showListenerConnError = maybe "Connection error" (showOnSingleLine '\t' . T.decodeUtf8) showListenerConnError = maybe "Connection error" (showOnSingleLine '\t' . T.decodeUtf8)
showListenerException :: Either SomeException () -> Text showListenerException :: SomeException -> Text
showListenerException (Right _) = "Failed getting notifications" -- should not happen as the listener will never finish (hasql-notifications uses `forever` internally) with a Right result showListenerException = showOnSingleLine '\t' . show
showListenerException (Left e) = showOnSingleLine '\t' $ show e
showOnSingleLine :: Char -> Text -> Text showOnSingleLine :: Char -> Text -> Text
+24 -15
View File
@@ -172,7 +172,7 @@ dbActionPlan dbAct conf apiReq sCache = case dbAct of
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Bool -> Either Error CrudPlan wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Bool -> Either Error CrudPlan
wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} headersOnly = do wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} headersOnly = do
qi <- findTable identifier (dbTables sCache) qi <- findTable identifier sCache
rPlan <- readPlan qi conf sCache apiRequest rPlan <- readPlan qi conf sCache apiRequest
(handler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest qi iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan) (handler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest qi iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right () if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
@@ -180,7 +180,7 @@ wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Prefe
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error CrudPlan mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error CrudPlan
mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do
qi <- findTable identifier (dbTables sCache) qi <- findTable identifier sCache
rPlan <- readPlan qi conf sCache apiRequest rPlan <- readPlan qi conf sCache apiRequest
mPlan <- mutatePlan mutation qi apiRequest sCache rPlan mPlan <- mutatePlan mutation qi apiRequest sCache rPlan
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right () if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
@@ -256,14 +256,16 @@ findProc qi argumentsKeys allProcs contentMediaType isInvPost =
| hasSingleUnnamedParam proc = (ts,proc:fs) | hasSingleUnnamedParam proc = (ts,proc:fs)
| otherwise = (ts,fs) | otherwise = (ts,fs)
-- If the function is called with post and has a single unnamed parameter -- If the function is called with post and has a single unnamed parameter
-- it can be called depending on content type and the parameter type -- it can be called depending on content type and the parameter type.
hasSingleUnnamedParam Function{pdParams=[RoutineParam{ppType}]} = isInvPost && case (contentMediaType, ppType) of -- The parameter must have no declared name (ppName == mempty).
(MTApplicationJSON, "json") -> True hasSingleUnnamedParam Function{pdParams=[RoutineParam{ppName, ppType}]} =
(MTApplicationJSON, "jsonb") -> True isInvPost && ppName == mempty && case (contentMediaType, ppType) of
(MTTextPlain, "text") -> True (MTApplicationJSON, "json") -> True
(MTTextXML, "xml") -> True (MTApplicationJSON, "jsonb") -> True
(MTOctetStream, "bytea") -> True (MTTextPlain, "text") -> True
_ -> False (MTTextXML, "xml") -> True
(MTOctetStream, "bytea") -> True
_ -> False
hasSingleUnnamedParam _ = False hasSingleUnnamedParam _ = False
matchesParams proc = matchesParams proc =
let let
@@ -810,10 +812,10 @@ validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest)
| otherwise = Node rp <$> traverse (validateAggFunctions aggFunctionsAllowed) forest | otherwise = Node rp <$> traverse (validateAggFunctions aggFunctionsAllowed) forest
-- | Lookup table in the schema cache before creating read plan -- | Lookup table in the schema cache before creating read plan
findTable :: QualifiedIdentifier -> TablesMap -> Either Error QualifiedIdentifier findTable :: QualifiedIdentifier -> SchemaCache -> Either Error QualifiedIdentifier
findTable qi@QualifiedIdentifier{..} tableMap = findTable qi@QualifiedIdentifier{..} sc@SchemaCache{dbTables} =
case HM.lookup qi tableMap of case HM.lookup qi dbTables of
Nothing -> Left $ SchemaCacheErr $ TableNotFound qiSchema qiName (HM.elems tableMap) Nothing -> Left $ SchemaCacheErr $ TableNotFound qiSchema qiName sc
Just _ -> Right qi Just _ -> Right qi
addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree
@@ -963,10 +965,17 @@ addRanges ApiRequest{..} rReq =
addLogicTrees :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree addLogicTrees :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree
addLogicTrees ctx ApiRequest{..} rReq = addLogicTrees ctx ApiRequest{..} rReq =
foldr addLogicTreeToNode (Right rReq) qsLogic foldr addLogicTreeToNode (Right rReq) logic
where where
QueryParams.QueryParams{..} = iQueryParams QueryParams.QueryParams{..} = iQueryParams
logic =
case iAction of
ActDb (ActRelationRead _ _) -> qsLogic
ActDb (ActRoutine _ _) -> qsLogic
-- For mutations, take the non-root logic filters. These will only affect the embeddings and not the top level of the returned representation.
_ -> filter (not . null . fst) qsLogic
addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either Error ReadPlanTree -> Either Error ReadPlanTree addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either Error ReadPlanTree -> Either Error ReadPlanTree
addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f) addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f)
+19 -24
View File
@@ -6,13 +6,22 @@ Module : PostgREST.Query.SqlFragment
Description : Helper functions for PostgREST.QueryBuilder. Description : Helper functions for PostgREST.QueryBuilder.
-} -}
module PostgREST.Query.SqlFragment module PostgREST.Query.SqlFragment
( noLocationF ( accessibleFuncs
, handlerF , accessibleTables
, addConfigPgrstInserted
, countF , countF
, groupF , currentSettingF
, escapeIdent
, escapeIdentList
, explainF
, fromJsonBodyF
, fromQi , fromQi
, groupF
, handlerF
, intercalateSnippet
, limitOffsetF , limitOffsetF
, locationF , locationF
, noLocationF
, orderF , orderF
, pgFmtColumn , pgFmtColumn
, pgFmtFilter , pgFmtFilter
@@ -21,28 +30,19 @@ module PostgREST.Query.SqlFragment
, pgFmtLogicTree , pgFmtLogicTree
, pgFmtOrderTerm , pgFmtOrderTerm
, pgFmtSelectItem , pgFmtSelectItem
, pgFmtSpreadSelectItem
, pgFmtSpreadJoinSelectItem , pgFmtSpreadJoinSelectItem
, fromJsonBodyF , pgFmtSpreadSelectItem
, responseHeadersF , responseHeadersF
, responseStatusF , responseStatusF
, addConfigPgrstInserted
, currentSettingF
, returningF , returningF
, schemaDescription
, setConfigWithConstantName
, setConfigWithConstantNameJSON
, setConfigWithDynamicName
, singleParameter , singleParameter
, sourceCTE , sourceCTE
, sourceCTEName , sourceCTEName
, unknownEncoder , unknownEncoder
, intercalateSnippet
, explainF
, setConfigWithConstantName
, setConfigWithDynamicName
, setConfigWithConstantNameJSON
, escapeIdent
, escapeIdentList
, schemaDescription
, accessibleTables
, accessibleFuncs
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
@@ -90,7 +90,8 @@ import PostgREST.RangeQuery (NonnegRange, allRange,
rangeLimit, rangeOffset) rangeLimit, rangeOffset)
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
RelIdentifier (..)) RelIdentifier (..),
escapeIdent, trimNullChars)
import PostgREST.SchemaCache.Routine (MediaHandler (..), import PostgREST.SchemaCache.Routine (MediaHandler (..),
Routine (..), Routine (..),
funcReturnsScalar, funcReturnsScalar,
@@ -163,9 +164,6 @@ pgBuildArrayLiteral vals =
pgFmtIdent :: Text -> SQL.Snippet pgFmtIdent :: Text -> SQL.Snippet
pgFmtIdent x = SQL.sql . encodeUtf8 $ escapeIdent x pgFmtIdent x = SQL.sql . encodeUtf8 $ escapeIdent x
escapeIdent :: Text -> Text
escapeIdent x = "\"" <> T.replace "\"" "\"\"" (trimNullChars x) <> "\""
-- Only use it if the input comes from the database itself, like on `jsonb_build_object('column_from_a_table', val)..` -- Only use it if the input comes from the database itself, like on `jsonb_build_object('column_from_a_table', val)..`
pgFmtLit :: Text -> Text pgFmtLit :: Text -> Text
pgFmtLit x = pgFmtLit x =
@@ -176,9 +174,6 @@ pgFmtLit x =
then "E" <> slashed then "E" <> slashed
else slashed else slashed
trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0')
-- | -- |
-- Format a list of identifiers and separate them by commas. -- Format a list of identifiers and separate them by commas.
-- --
+70 -44
View File
@@ -62,11 +62,12 @@ data PgrstResponse = PgrstResponse {
actionResponse :: DbResult -> ApiRequest -> (Text, Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Either Error.Error PgrstResponse actionResponse :: DbResult -> ApiRequest -> (Text, Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Either Error.Error PgrstResponse
actionResponse (DbCrudResult WrappedReadPlan{pMedia, wrHdrsOnly=headersOnly, crudQi=identifier} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} _ _ _ _ _ = do actionResponse (DbCrudResult plan@WrappedReadPlan{pMedia, wrHdrsOnly=headersOnly, crudQi=identifier} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
let let
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal (status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
cLHeader = if headersOnly then mempty else [contentLengthHeaderStrict rsBody] cLHeader = if headersOnly then mempty else [ contentLengthHeader bod ]
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone Nothing [] prefHeader = maybeToList . prefAppliedHeader $ responsePreferences plan ctxApiRequest
headers = headers =
[ contentRange [ contentRange
, ( "Content-Location" , ( "Content-Location"
@@ -87,12 +88,10 @@ actionResponse (DbCrudResult WrappedReadPlan{pMedia, wrHdrsOnly=headersOnly, cru
Right $ PgrstResponse ovStatus ovHeaders bod Right $ PgrstResponse ovStatus ovHeaders bod
actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationCreate, mrMutatePlan, pMedia, crudQi=QualifiedIdentifier{..}} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}, ..} _ _ _ _ _ = do actionResponse (DbCrudResult plan@MutateReadPlan{mrMutation=MutationCreate, pMedia, crudQi=QualifiedIdentifier{..}} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
let let
pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;} prefHeader = prefAppliedHeader $ responsePreferences plan ctxApiRequest
prefHeader = prefAppliedHeader $
Preferences (if null pkCols && isNothing (qsOnConflict iQueryParams) then Nothing else preferResolution)
preferRepresentation preferCount preferTransaction preferMissing preferHandling preferTimezone Nothing []
headers = headers =
catMaybes catMaybes
[ if null rsLocation then [ if null rsLocation then
@@ -105,37 +104,39 @@ actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationCreate, mrMutateP
<> HTTP.renderSimpleQuery True rsLocation <> HTTP.renderSimpleQuery True rsLocation
) )
, Just . RangeQuery.contentRangeH 1 0 $ , Just . RangeQuery.contentRangeH 1 0 $
if shouldCount preferCount then Just rsQueryTotal else Nothing if shouldCount (preferCount iPreferences) then Just rsQueryTotal else Nothing
, Just $ contentLengthHeaderStrict rsBody
, prefHeader ] , prefHeader ]
isInsertIfGTZero i = isInsertIfGTZero i =
if i <= 0 && preferResolution == Just MergeDuplicates then if i <= 0 && preferResolution iPreferences == Just MergeDuplicates then
HTTP.status200 HTTP.status200
else else
HTTP.status201 HTTP.status201
status = maybe HTTP.status200 isInsertIfGTZero rsInserted status = maybe HTTP.status200 isInsertIfGTZero rsInserted
(headers', bod) = case preferRepresentation of (headers', bod) = case preferRepresentation iPreferences of
Just Full -> (headers ++ contentTypeHeaders pMedia ctxApiRequest, LBS.fromStrict rsBody) Just Full -> (headers ++ contentTypeHeaders pMedia ctxApiRequest, LBS.fromStrict rsBody)
Just None -> (headers, mempty) Just None -> (headers, mempty)
Just HeadersOnly -> (headers, mempty) Just HeadersOnly -> (headers, mempty)
Nothing -> (headers, mempty) Nothing -> (headers, mempty)
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers' (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status $ contentLengthHeader bod:headers'
Right $ PgrstResponse ovStatus ovHeaders bod Right $ PgrstResponse ovStatus ovHeaders bod
actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationUpdate, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} _ _ _ _ _ = do actionResponse (DbCrudResult plan@MutateReadPlan{mrMutation=MutationUpdate, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
let let
contentRangeHeader = contentRangeHeader =
Just . RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $ Just . RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
if shouldCount preferCount then Just rsQueryTotal else Nothing if shouldCount (preferCount iPreferences) then Just rsQueryTotal else Nothing
prefHeader = prefAppliedHeader $ Preferences Nothing preferRepresentation preferCount preferTransaction preferMissing preferHandling preferTimezone preferMaxAffected []
prefHeader = prefAppliedHeader $ responsePreferences plan ctxApiRequest
headers = catMaybes [contentRangeHeader, prefHeader] headers = catMaybes [contentRangeHeader, prefHeader]
lbsBody = LBS.fromStrict rsBody
let (status, headers', body) = let (status, headers', body) =
case preferRepresentation of case preferRepresentation iPreferences of
Just Full -> (HTTP.status200, headers ++ [contentLengthHeaderStrict rsBody] ++ contentTypeHeaders pMedia ctxApiRequest, LBS.fromStrict rsBody) Just Full -> (HTTP.status200, headers ++ [contentLengthHeader lbsBody] ++ contentTypeHeaders pMedia ctxApiRequest, lbsBody)
Just None -> (HTTP.status204, headers, mempty) Just None -> (HTTP.status204, headers, mempty)
_ -> (HTTP.status204, headers, mempty) _ -> (HTTP.status204, headers, mempty)
@@ -143,31 +144,33 @@ actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationUpdate, pMedia} R
Right $ PgrstResponse ovStatus ovHeaders body Right $ PgrstResponse ovStatus ovHeaders body
actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationSingleUpsert, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} _ _ _ _ _ = do actionResponse (DbCrudResult plan@MutateReadPlan{mrMutation=MutationSingleUpsert, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
let let
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation preferCount preferTransaction Nothing preferHandling preferTimezone Nothing [] prefHeader = maybeToList . prefAppliedHeader $ responsePreferences plan ctxApiRequest
cLHeader = [contentLengthHeaderStrict rsBody] lbsBody = LBS.fromStrict rsBody
cLHeader = [contentLengthHeader lbsBody]
cTHeader = contentTypeHeaders pMedia ctxApiRequest cTHeader = contentTypeHeaders pMedia ctxApiRequest
let isInsertIfGTZero i = if i > 0 then HTTP.status201 else HTTP.status200 let isInsertIfGTZero i = if i > 0 then HTTP.status201 else HTTP.status200
upsertStatus = isInsertIfGTZero $ fromJust rsInserted upsertStatus = isInsertIfGTZero $ fromJust rsInserted
(status, headers, body) = (status, headers, body) =
case preferRepresentation of case preferRepresentation iPreferences of
Just Full -> (upsertStatus, cLHeader ++ cTHeader ++ prefHeader, LBS.fromStrict rsBody) Just Full -> (upsertStatus, cLHeader ++ cTHeader ++ prefHeader, lbsBody)
Just None -> (HTTP.status204, prefHeader, mempty) Just None -> (HTTP.status204, prefHeader, mempty)
_ -> (HTTP.status204, prefHeader, mempty) _ -> (HTTP.status204, prefHeader, mempty)
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers
Right $ PgrstResponse ovStatus ovHeaders body Right $ PgrstResponse ovStatus ovHeaders body
actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationDelete, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} _ _ _ _ _ = do actionResponse (DbCrudResult plan@MutateReadPlan{mrMutation=MutationDelete, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
let let
contentRangeHeader = RangeQuery.contentRangeH 1 0 $ if shouldCount preferCount then Just rsQueryTotal else Nothing contentRangeHeader = RangeQuery.contentRangeH 1 0 $ if shouldCount (preferCount iPreferences) then Just rsQueryTotal else Nothing
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation preferCount preferTransaction Nothing preferHandling preferTimezone preferMaxAffected [] prefHeader = maybeToList . prefAppliedHeader $ responsePreferences plan ctxApiRequest
headers = contentRangeHeader : prefHeader headers = contentRangeHeader : prefHeader
lbsBody = LBS.fromStrict rsBody
(status, headers', body) = (status, headers', body) =
case preferRepresentation of case preferRepresentation iPreferences of
Just Full -> (HTTP.status200, headers ++ [contentLengthHeaderStrict rsBody] ++ contentTypeHeaders pMedia ctxApiRequest, LBS.fromStrict rsBody) Just Full -> (HTTP.status200, headers ++ [contentLengthHeader lbsBody] ++ contentTypeHeaders pMedia ctxApiRequest, lbsBody)
Just None -> (HTTP.status204, headers, mempty) Just None -> (HTTP.status204, headers, mempty)
_ -> (HTTP.status204, headers, mempty) _ -> (HTTP.status204, headers, mempty)
@@ -175,7 +178,7 @@ actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationDelete, pMedia} R
Right $ PgrstResponse ovStatus ovHeaders body Right $ PgrstResponse ovStatus ovHeaders body
actionResponse (DbCrudResult CallReadPlan{pMedia, crInvMthd=invMethod, crProc=proc} RSStandard {..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} _ _ _ _ _ = do actionResponse (DbCrudResult plan@CallReadPlan{pMedia, crInvMthd=invMethod, crProc=proc} RSStandard {..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
let let
(status, contentRange) = (status, contentRange) =
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
@@ -184,8 +187,8 @@ actionResponse (DbCrudResult CallReadPlan{pMedia, crInvMthd=invMethod, crProc=pr
$ Error.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal) $ Error.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
else LBS.fromStrict rsBody else LBS.fromStrict rsBody
isHeadMethod = invMethod == InvRead True isHeadMethod = invMethod == InvRead True
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone preferMaxAffected [] prefHeader = maybeToList . prefAppliedHeader $ responsePreferences plan ctxApiRequest
cLHeader = if isHeadMethod then mempty else [contentLengthHeaderLazy rsOrErrBody] cLHeader = if isHeadMethod then mempty else [contentLengthHeader rsOrErrBody]
headers = contentRange : prefHeader headers = contentRange : prefHeader
(status', headers', body) = (status', headers', body) =
if Routine.funcReturnsVoid proc then if Routine.funcReturnsVoid proc then
@@ -200,19 +203,20 @@ actionResponse (DbCrudResult CallReadPlan{pMedia, crInvMthd=invMethod, crProc=pr
Right $ PgrstResponse ovStatus ovHeaders body Right $ PgrstResponse ovStatus ovHeaders body
actionResponse (DbPlanResult media plan) ctxApiRequest _ _ _ _ _ = actionResponse (DbPlanResult media plan) ctxApiRequest _ _ _ _ _ =
Right $ PgrstResponse HTTP.status200 (contentLengthHeaderStrict plan : contentTypeHeaders media ctxApiRequest) $ LBS.fromStrict plan let body = LBS.fromStrict plan in
Right $ PgrstResponse HTTP.status200 (contentLengthHeader body : contentTypeHeaders media ctxApiRequest) body
actionResponse (MaybeDbResult InspectPlan{ipHdrsOnly=headersOnly} body) _ versions conf sCache schema negotiatedByProfile = actionResponse (MaybeDbResult InspectPlan{ipHdrsOnly=headersOnly} body) _ versions conf sCache schema negotiatedByProfile =
let let
rsBody = maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache x y z) body rsBody = maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache x y z) body
cLHeader = if headersOnly then mempty else [contentLengthHeaderLazy rsBody] cLHeader = if headersOnly then mempty else [contentLengthHeader rsBody]
in in
Right $ PgrstResponse HTTP.status200 (MediaType.toContentType MTOpenAPI : cLHeader ++ maybeToList (profileHeader schema negotiatedByProfile)) rsBody Right $ PgrstResponse HTTP.status200 (MediaType.toContentType MTOpenAPI : cLHeader ++ maybeToList (profileHeader schema negotiatedByProfile)) rsBody
actionResponse (NoDbResult (RelInfoPlan qi@QualifiedIdentifier{..})) _ _ _ SchemaCache{dbTables} _ _ = actionResponse (NoDbResult (RelInfoPlan qi@QualifiedIdentifier{..})) _ _ _ sc@SchemaCache{dbTables} _ _ =
case HM.lookup qi dbTables of case HM.lookup qi dbTables of
Just tbl -> respondInfo $ allowH tbl Just tbl -> respondInfo $ allowH tbl
Nothing -> Left $ Error.SchemaCacheErr $ Error.TableNotFound qiSchema qiName (HM.elems dbTables) Nothing -> Left $ Error.SchemaCacheErr $ Error.TableNotFound qiSchema qiName sc
where where
allowH table = allowH table =
let hasPK = not . null $ tablePKCols table in let hasPK = not . null $ tablePKCols table in
@@ -232,7 +236,7 @@ actionResponse (NoDbResult SchemaInfoPlan) _ _ _ _ _ _ = respondInfo "OPTIONS,GE
respondInfo :: ByteString -> Either Error.Error PgrstResponse respondInfo :: ByteString -> Either Error.Error PgrstResponse
respondInfo allowHeader = respondInfo allowHeader =
let allOrigins = ("Access-Control-Allow-Origin", "*") in let allOrigins = ("Access-Control-Allow-Origin", "*") in
Right $ PgrstResponse HTTP.status200 [contentLengthHeaderStrict mempty, allOrigins, (HTTP.hAllow, allowHeader)] mempty Right $ PgrstResponse HTTP.status200 [contentLengthHeader mempty, allOrigins, (HTTP.hAllow, allowHeader)] mempty
-- Status and headers can be overridden as per https://postgrest.org/en/stable/references/transactions.html#response-headers -- Status and headers can be overridden as per https://postgrest.org/en/stable/references/transactions.html#response-headers
overrideStatusHeaders :: Maybe Text -> Maybe BS.ByteString -> HTTP.Status -> [HTTP.Header]-> Either Error.Error (HTTP.Status, [HTTP.Header]) overrideStatusHeaders :: Maybe Text -> Maybe BS.ByteString -> HTTP.Status -> [HTTP.Header]-> Either Error.Error (HTTP.Status, [HTTP.Header])
@@ -249,14 +253,8 @@ decodeGucStatus :: Maybe Text -> Either Error.Error (Maybe HTTP.Status)
decodeGucStatus = decodeGucStatus =
maybe (Right Nothing) $ first (const . Error.ApiRequestError $ Error.GucStatusError) . fmap (Just . toEnum . fst) . decimal maybe (Right Nothing) $ first (const . Error.ApiRequestError $ Error.GucStatusError) . fmap (Just . toEnum . fst) . decimal
contentLengthHeader :: Show b => (a -> b) -> a -> HTTP.Header contentLengthHeader :: LBS.ByteString -> HTTP.Header
contentLengthHeader lenFn body = ("Content-Length", show (lenFn body)) contentLengthHeader body = ("Content-Length", show (LBS.length body))
contentLengthHeaderStrict :: BS.ByteString -> HTTP.Header
contentLengthHeaderStrict = contentLengthHeader BS.length
contentLengthHeaderLazy :: LBS.ByteString -> HTTP.Header
contentLengthHeaderLazy = contentLengthHeader LBS.length
contentTypeHeaders :: MediaType -> ApiRequest -> [HTTP.Header] contentTypeHeaders :: MediaType -> ApiRequest -> [HTTP.Header]
contentTypeHeaders mediaType ApiRequest{..} = contentTypeHeaders mediaType ApiRequest{..} =
@@ -274,3 +272,31 @@ addHeadersIfNotIncluded :: [HTTP.Header] -> [HTTP.Header] -> [HTTP.Header]
addHeadersIfNotIncluded newHeaders initialHeaders = addHeadersIfNotIncluded newHeaders initialHeaders =
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++ filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
initialHeaders initialHeaders
-- | Get Preferences for Preference-Applied header per plan
responsePreferences :: CrudPlan -> ApiRequest -> Preferences
responsePreferences plan ApiRequest{iPreferences=Preferences{..}, iQueryParams=QueryParams{..}} =
let
-- Only returned on Inserts
preferResolution' = case plan of
MutateReadPlan{mrMutation=MutationCreate, mrMutatePlan} ->
let pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols ; _ -> mempty; }
in (if null pkCols && isNothing qsOnConflict then Nothing else preferResolution)
_ -> Nothing
preferRepresentation' = case plan of
MutateReadPlan{} -> preferRepresentation
_ -> Nothing
preferMissing' = case plan of
MutateReadPlan{mrMutation=MutationCreate} -> preferMissing
MutateReadPlan{mrMutation=MutationUpdate} -> preferMissing
_ -> Nothing
preferMaxAffected' = case plan of
MutateReadPlan{mrMutation=MutationUpdate} -> preferMaxAffected
MutateReadPlan{mrMutation=MutationDelete} -> preferMaxAffected
CallReadPlan{} -> preferMaxAffected
_ -> Nothing
in Preferences preferResolution' preferRepresentation' preferCount preferTransaction preferMissing' preferHandling preferTimezone preferMaxAffected' []
+2 -3
View File
@@ -1,4 +1,3 @@
{-# LANGUAGE NumericUnderscores #-}
module PostgREST.Response.Performance module PostgREST.Response.Performance
( ServerTiming (..) ( ServerTiming (..)
, serverTimingHeader , serverTimingHeader
@@ -24,12 +23,12 @@ data ServerTiming =
-- The duration precision is milliseconds, per the docs -- The duration precision is milliseconds, per the docs
-- --
-- >>> serverTimingHeader ServerTiming { plan=Just 0.1, transaction=Just 0.2, response=Just 0.3, jwt=Just 0.4, parse=Just 0.5} -- >>> serverTimingHeader ServerTiming { plan=Just 0.1, transaction=Just 0.2, response=Just 0.3, jwt=Just 0.4, parse=Just 0.5}
-- ("Server-Timing","jwt;dur=400.0, parse;dur=500.0, plan;dur=100.0, transaction;dur=200.0, response;dur=300.0") -- ("Server-Timing","jwt;dur=0.4, parse;dur=0.5, plan;dur=0.1, transaction;dur=0.2, response;dur=0.3")
serverTimingHeader :: ServerTiming -> HTTP.Header serverTimingHeader :: ServerTiming -> HTTP.Header
serverTimingHeader timing = serverTimingHeader timing =
("Server-Timing", renderTiming) ("Server-Timing", renderTiming)
where where
renderMetric metric = maybe "" (\dur -> BS.concat [metric, BS.pack $ ";dur=" <> showFFloat (Just 1) (dur * 1_000) ""]) renderMetric metric = maybe "" (\dur -> BS.concat [metric, BS.pack $ ";dur=" <> showFFloat (Just 1) dur ""])
renderTiming = BS.intercalate ", " $ (\(k, v) -> renderMetric k (v timing)) <$> renderTiming = BS.intercalate ", " $ (\(k, v) -> renderMetric k (v timing)) <$>
[ ("jwt", jwt) [ ("jwt", jwt)
, ("parse", parse) , ("parse", parse)
+30 -14
View File
@@ -20,6 +20,7 @@ These queries are executed once at startup or when PostgREST is reloaded.
module PostgREST.SchemaCache module PostgREST.SchemaCache
( SchemaCache(..) ( SchemaCache(..)
, TablesFuzzyIndex
, querySchemaCache , querySchemaCache
, showSummary , showSummary
, decodeFuncs , decodeFuncs
@@ -42,11 +43,11 @@ import NeatInterpolation (trimming)
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.Config.Database (TimezoneNames, import PostgREST.Config.Database (TimezoneNames,
toIsolationLevel) toIsolationLevel)
import PostgREST.Query.SqlFragment (escapeIdent)
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
RelIdentifier (..), RelIdentifier (..),
Schema, isAnyElement) Schema, escapeIdent,
isAnyElement)
import PostgREST.SchemaCache.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
Relationship (..), Relationship (..),
@@ -66,21 +67,28 @@ import PostgREST.SchemaCache.Table (Column (..), ColumnMap,
import qualified PostgREST.MediaType as MediaType import qualified PostgREST.MediaType as MediaType
import Control.Arrow ((&&&)) import Control.Arrow ((&&&))
import Protolude import qualified Data.FuzzySet as Fuzzy
import System.IO.Unsafe (unsafePerformIO) import Protolude
import System.IO.Unsafe (unsafePerformIO)
type TablesFuzzyIndex = HM.HashMap Schema Fuzzy.FuzzySet
data SchemaCache = SchemaCache data SchemaCache = SchemaCache
{ dbTables :: TablesMap { dbTables :: TablesMap
, dbRelationships :: RelationshipsMap , dbRelationships :: RelationshipsMap
, dbRoutines :: RoutineMap , dbRoutines :: RoutineMap
, dbRepresentations :: RepresentationsMap , dbRepresentations :: RepresentationsMap
, dbMediaHandlers :: MediaHandlerMap , dbMediaHandlers :: MediaHandlerMap
, dbTimezones :: TimezoneNames , dbTimezones :: TimezoneNames
} -- Memoized fuzzy index of table names per schema to support approximate matching
-- Since index construction can be expensive, we build it once and store in the SchemaCache
-- Haskell lazy evaluation ensures it's only built on first use and memoized afterwards
, dbTablesFuzzyIndex :: TablesFuzzyIndex
} deriving (Show)
instance JSON.ToJSON SchemaCache where instance JSON.ToJSON SchemaCache where
toJSON (SchemaCache tabs rels routs reps hdlers tzs) = JSON.object [ toJSON (SchemaCache tabs rels routs reps hdlers tzs _) = JSON.object [
"dbTables" .= JSON.toJSON tabs "dbTables" .= JSON.toJSON tabs
, "dbRelationships" .= JSON.toJSON rels , "dbRelationships" .= JSON.toJSON rels
, "dbRoutines" .= JSON.toJSON routs , "dbRoutines" .= JSON.toJSON routs
@@ -90,7 +98,7 @@ instance JSON.ToJSON SchemaCache where
] ]
showSummary :: SchemaCache -> Text showSummary :: SchemaCache -> Text
showSummary (SchemaCache tbls rels routs reps mediaHdlrs tzs) = showSummary (SchemaCache tbls rels routs reps mediaHdlrs tzs _) =
T.intercalate ", " T.intercalate ", "
[ show (HM.size tbls) <> " Relations" [ show (HM.size tbls) <> " Relations"
, show (HM.size rels) <> " Relationships" , show (HM.size rels) <> " Relationships"
@@ -138,6 +146,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
maxDbTablesForFuzzySearch :: Int
maxDbTablesForFuzzySearch = 500
querySchemaCache :: AppConfig -> SQL.Transaction SchemaCache querySchemaCache :: AppConfig -> SQL.Transaction SchemaCache
querySchemaCache conf@AppConfig{..} = do querySchemaCache conf@AppConfig{..} = do
@@ -166,6 +176,11 @@ querySchemaCache conf@AppConfig{..} = do
, dbRepresentations = reps , dbRepresentations = reps
, dbMediaHandlers = HM.union mHdlers initialMediaHandlers -- the custom handlers will override the initial ones , dbMediaHandlers = HM.union mHdlers initialMediaHandlers -- the custom handlers will override the initial ones
, dbTimezones = tzones , dbTimezones = tzones
, dbTablesFuzzyIndex =
-- Only build fuzzy index for schemas with a reasonable number of tables
-- Fuzzy.FuzzySet is memory heavy we just don't use it for large schemas
Fuzzy.fromList <$> HM.filter ((< maxDbTablesForFuzzySearch) . length) (HM.fromListWith (<>) ((qiSchema &&& pure . qiName) <$> HM.keys tabsWViewsPks))
} }
where where
schemas = toList configDbSchemas schemas = toList configDbSchemas
@@ -203,6 +218,7 @@ removeInternal schemas dbStruct =
, dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API , dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API
, dbMediaHandlers = dbMediaHandlers dbStruct , dbMediaHandlers = dbMediaHandlers dbStruct
, dbTimezones = dbTimezones dbStruct , dbTimezones = dbTimezones dbStruct
, dbTablesFuzzyIndex = dbTablesFuzzyIndex dbStruct
} }
where where
hasInternalJunction ComputedRelationship{} = False hasInternalJunction ComputedRelationship{} = False
+16 -3
View File
@@ -2,14 +2,17 @@
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
module PostgREST.SchemaCache.Identifiers module PostgREST.SchemaCache.Identifiers
( QualifiedIdentifier(..) ( FieldName
, QualifiedIdentifier(..)
, RelIdentifier(..) , RelIdentifier(..)
, isAnyElement
, Schema , Schema
, TableName , TableName
, FieldName
, dumpQi , dumpQi
, escapeIdent
, isAnyElement
, quoteQi
, toQi , toQi
, trimNullChars
) where ) where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
@@ -39,6 +42,10 @@ dumpQi :: QualifiedIdentifier -> Text
dumpQi (QualifiedIdentifier s i) = dumpQi (QualifiedIdentifier s i) =
(if T.null s then mempty else s <> ".") <> i (if T.null s then mempty else s <> ".") <> i
quoteQi :: QualifiedIdentifier -> Text
quoteQi (QualifiedIdentifier s i) =
(if T.null s then mempty else escapeIdent s <> ".") <> escapeIdent i
-- TODO: Handle a case where the QI comes like this: "my.fav.schema"."my.identifier" -- TODO: Handle a case where the QI comes like this: "my.fav.schema"."my.identifier"
-- Right now it only handles the schema.identifier case -- Right now it only handles the schema.identifier case
toQi :: Text -> QualifiedIdentifier toQi :: Text -> QualifiedIdentifier
@@ -46,6 +53,12 @@ toQi txt = case T.drop 1 <$> T.breakOn "." txt of
(i, "") -> QualifiedIdentifier mempty i (i, "") -> QualifiedIdentifier mempty i
(s, i) -> QualifiedIdentifier s i (s, i) -> QualifiedIdentifier s i
escapeIdent :: Text -> Text
escapeIdent x = "\"" <> T.replace "\"" "\"\"" (trimNullChars x) <> "\""
trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0')
type Schema = Text type Schema = Text
type TableName = Text type TableName = Text
type FieldName = Text type FieldName = Text
+1 -1
View File
@@ -90,7 +90,7 @@ data RoutineParam = RoutineParam
instance Ord Routine where instance Ord Routine where
Function schema1 name1 des1 prms1 rt1 vol1 hasVar1 iso1 sets1 `compare` Function schema2 name2 des2 prms2 rt2 vol2 hasVar2 iso2 sets2 Function schema1 name1 des1 prms1 rt1 vol1 hasVar1 iso1 sets1 `compare` Function schema2 name2 des2 prms2 rt2 vol2 hasVar2 iso2 sets2
| schema1 == schema2 && name1 == name2 && length prms1 < length prms2 = LT | schema1 == schema2 && name1 == name2 && length prms1 < length prms2 = LT
| schema2 == schema2 && name1 == name2 && length prms1 > length prms2 = GT | schema1 == schema2 && name1 == name2 && length prms1 > length prms2 = GT
| otherwise = (schema1, name1, des1, prms1, rt1, vol1, hasVar1, iso1, sets1) `compare` (schema2, name2, des2, prms2, rt2, vol2, hasVar2, iso2, sets2) | otherwise = (schema1, name1, des1, prms1, rt1, vol1, hasVar1, iso1, sets1) `compare` (schema2, name2, des2, prms2, rt2, vol2, hasVar2, iso2, sets2)
-- | A map of all procs, all of which can be overloaded(one entry will have more than one Routine). -- | A map of all procs, all of which can be overloaded(one entry will have more than one Routine).
+20
View File
@@ -0,0 +1,20 @@
module PostgREST.TimeIt
( timeItT
) where
import GHC.Clock
import Protolude
{-
- The signature is the same as https://hackage.haskell.org/package/timeit-2.0/docs/src/System-TimeIt.html#timeIt,
- we vendor this functionality because it gave errors as shown on https://github.com/PostgREST/postgrest/issues/4522 plus
- the function is small enough. This vendored function is different in that the result is in milliseconds.
-}
timeItT :: MonadIO m => m a -> m (Double, a)
timeItT p = do
s <- liftIO getMonotonicTime
x <- p
e <- liftIO getMonotonicTime
let time = (e - s) * 1000
return (time, x)
+1
View File
@@ -14,3 +14,4 @@ extra-deps:
- hasql-pool-1.0.1 - hasql-pool-1.0.1
- jose-jwt-0.10.0 - jose-jwt-0.10.0
- postgresql-libpq-0.10.1.0 - postgresql-libpq-0.10.1.0
- streaming-commons-0.2.3.1
+7
View File
@@ -39,6 +39,13 @@ packages:
size: 1096 size: 1096
original: original:
hackage: postgresql-libpq-0.10.1.0 hackage: postgresql-libpq-0.10.1.0
- completed:
hackage: streaming-commons-0.2.3.1@sha256:ed7999fea9e912b1211ea93d7e20a7998bf4753166370c94048885650f303bf0,4841
pantry-tree:
sha256: f08e83fb00fd45865fa8cfdef5ecef7161d5135257ee98050bfbe4b807ad65f8
size: 2374
original:
hackage: streaming-commons-0.2.3.1
snapshots: snapshots:
- completed: - completed:
sha256: 238fa745b64f91184f9aa518fe04bdde6552533d169b0da5256670df83a0f1a9 sha256: 238fa745b64f91184f9aa518fe04bdde6552533d169b0da5256670df83a0f1a9
@@ -1 +1,39 @@
[] - - - qiName: directors
qiSchema: public
- public
- - relCardinality:
relColumns:
- - id
- director_id
relCons: fk_director
tag: O2M
relFTableIsView: false
relForeignTable:
qiName: films
qiSchema: public
relIsSelf: false
relTable:
qiName: directors
qiSchema: public
relTableIsView: false
tag: Relationship
- - - qiName: films
qiSchema: public
- public
- - relCardinality:
relColumns:
- - director_id
- id
relCons: fk_director
tag: M2O
relFTableIsView: false
relForeignTable:
qiName: directors
qiSchema: public
relIsSelf: false
relTable:
qiName: films
qiSchema: public
relTableIsView: false
tag: Relationship
@@ -106,6 +106,23 @@
pdSchema: public pdSchema: public
pdVolatility: Volatile pdVolatility: Volatile
- - qiName: notify_pgrst
qiSchema: public
- - pdDescription: null
pdFuncSettings: []
pdHasVariadic: false
pdName: notify_pgrst
pdParams: []
pdReturnType:
contents:
contents:
qiName: void
qiSchema: pg_catalog
tag: Scalar
tag: Single
pdSchema: public
pdVolatility: Volatile
- - qiName: migrate_function - - qiName: migrate_function
qiSchema: public qiSchema: public
- - pdDescription: null - - pdDescription: null
@@ -461,6 +478,23 @@
pdSchema: public pdSchema: public
pdVolatility: Volatile pdVolatility: Volatile
- - qiName: 'true'
qiSchema: public
- - pdDescription: null
pdFuncSettings: []
pdHasVariadic: false
pdName: 'true'
pdParams: []
pdReturnType:
contents:
contents:
qiName: bool
qiSchema: pg_catalog
tag: Scalar
tag: Single
pdSchema: public
pdVolatility: Volatile
- - qiName: create_function - - qiName: create_function
qiSchema: public qiSchema: public
- - pdDescription: null - - pdDescription: null
@@ -71,6 +71,37 @@
tableSchema: public tableSchema: public
tableUpdatable: true tableUpdatable: true
- - qiName: directors
qiSchema: public
- tableColumns:
id:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: id
colNominalType: integer
colNullable: false
colType: integer
name:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: name
colNominalType: text
colNullable: true
colType: text
tableDeletable: true
tableDescription: null
tableInsertable: true
tableIsView: false
tableName: directors
tablePKCols:
- id
tableSchema: public
tableUpdatable: true
- - qiName: projects - - qiName: projects
qiSchema: public qiSchema: public
- tableColumns: {} - tableColumns: {}
@@ -95,6 +126,46 @@
tableSchema: public tableSchema: public
tableUpdatable: false tableUpdatable: false
- - qiName: films
qiSchema: public
- tableColumns:
director_id:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: director_id
colNominalType: integer
colNullable: true
colType: integer
id:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: id
colNominalType: integer
colNullable: false
colType: integer
title:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: title
colNominalType: text
colNullable: true
colType: text
tableDeletable: true
tableDescription: null
tableInsertable: true
tableIsView: false
tableName: films
tablePKCols:
- id
tableSchema: public
tableUpdatable: true
- - qiName: items - - qiName: items
qiSchema: public qiSchema: public
- tableColumns: - tableColumns:
+3 -1
View File
@@ -7,7 +7,9 @@ import yaml
BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent
CONFIGSDIR = BASEDIR / "configs" CONFIGSDIR = BASEDIR / "configs"
FIXTURES = yaml.load((BASEDIR / "fixtures.yaml").read_text(), Loader=yaml.Loader) FIXTURES = yaml.load(
(BASEDIR / "fixtures/fixtures.yaml").read_text(), Loader=yaml.Loader
)
POSTGREST_BIN = shutil.which("postgrest") POSTGREST_BIN = shutil.which("postgrest")
SECRET = "reallyreallyreallyreallyverysafe" SECRET = "reallyreallyreallyreallyverysafe"
@@ -5,7 +5,7 @@ We use it to test our metadata generation because it contains a good amount of d
Custom roles and privileges were removed. Custom roles and privileges were removed.
postgrest-with-postgresql-14 -f test/io/big_schema.sql psql postgrest-with-pg-14 -f test/io/big_schema.sql psql
Has 12 functions: Has 12 functions:
@@ -11375,12 +11375,34 @@ ALTER TABLE ONLY apflora.zielber
ALTER TABLE apflora."user" ENABLE ROW LEVEL SECURITY; ALTER TABLE apflora."user" ENABLE ROW LEVEL SECURITY;
CREATE SCHEMA fuzzysearch;
-- Create many tables to test fuzzy string search
-- computing hints for non existing tables
DO
$$
DECLARE
r record;
BEGIN
FOR r IN
SELECT
format('CREATE TABLE fuzzysearch.unknown_table_%s ()', n) AS ct
FROM
generate_series(1, 499) n
LOOP
EXECUTE r.ct;
END LOOP;
END
$$;
DROP ROLE IF EXISTS postgrest_test_anonymous; DROP ROLE IF EXISTS postgrest_test_anonymous;
CREATE ROLE postgrest_test_anonymous; CREATE ROLE postgrest_test_anonymous;
GRANT postgrest_test_anonymous TO :PGUSER; GRANT postgrest_test_anonymous TO :PGUSER;
GRANT USAGE ON SCHEMA apflora TO postgrest_test_anonymous; GRANT USAGE ON SCHEMA apflora TO postgrest_test_anonymous;
GRANT USAGE ON SCHEMA fuzzysearch TO postgrest_test_anonymous;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA apflora GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA apflora
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
@@ -256,3 +256,45 @@ select * from projects;
create or replace view infinite_recursion as create or replace view infinite_recursion as
select * from infinite_recursion; select * from infinite_recursion;
create or replace function "true"() returns boolean as $_$
select true;
$_$ language sql;
create or replace function notify_pgrst() returns void as $$
notify pgrst;
$$ language sql;
-- directors and films table can be used for resource embedding tests
create table directors (
id int primary key,
name text
);
create table films (
id int primary key,
title text,
director_id int,
constraint fk_director
foreign key (director_id) references directors (id)
on update cascade
on delete cascade
);
-- data to test resource embedding
truncate table directors cascade;
insert into directors
values (1, 'quentin tarantino'),
(2, 'christopher nolan'),
(3, 'yorgos lathinmos');
truncate table films cascade;
insert into films
values (1, 'pulp fiction', 1),
(2, 'intersteller',2),
(3, 'dogtooth',3),
(4, 'reservoir dogs', 1);
GRANT SELECT ON directors, films TO postgrest_test_anonymous, postgrest_test_w_superuser_settings;
+15 -7
View File
@@ -94,20 +94,28 @@ def run(
"Run PostgREST and yield an endpoint that is ready for connections." "Run PostgREST and yield an endpoint that is ready for connections."
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
# with python requests, "localhost" doesn't automatically resolves
# to [::1], hence we use this explicitly when host is ipv6 special
# address
ipv6_special_addresses = ["*6", "!6"]
localhost = "[::1]" if host in ipv6_special_addresses else "localhost"
if port: if port:
env["PGRST_SERVER_PORT"] = str(port) env["PGRST_SERVER_PORT"] = str(port)
env["PGRST_SERVER_HOST"] = host or "localhost" env["PGRST_SERVER_HOST"] = host or localhost
# When constructing IPv6 address, host address should be bracketed like [host] # When constructing IPv6 address, host address should be bracketed like [host]
apihost = f"[{host}]" if host and is_ipv6(host) else "localhost" apihost = f"[{host}]" if host and is_ipv6(host) else localhost
baseurl = f"http://{apihost}:{port}" baseurl = f"http://{apihost}:{port}"
else: else:
socketfile = pathlib.Path(tmpdir) / "postgrest.sock" socketfile = pathlib.Path(tmpdir) / "postgrest.sock"
env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile) env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile)
baseurl = "http+unix://" + urllib.parse.quote_plus(str(socketfile)) baseurl = "http+unix://" + urllib.parse.quote_plus(str(socketfile))
adminport = freeport(port) adminport = freeport(used_ports=[port])
env["PGRST_ADMIN_SERVER_PORT"] = str(adminport) env["PGRST_ADMIN_SERVER_PORT"] = str(adminport)
adminhost = f"[{host}]" if host and is_ipv6(host) else "localhost" adminhost = f"[{host}]" if host and is_ipv6(host) else localhost
adminurl = f"http://{adminhost}:{adminport}" adminurl = f"http://{adminhost}:{adminport}"
command = [POSTGREST_BIN] command = [POSTGREST_BIN]
@@ -157,14 +165,14 @@ def run(
process.wait() process.wait()
def freeport(used_port=None): def freeport(used_ports=None):
"Find a free port on localhost." "Find an unused free port on localhost."
while True: while True:
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind(("", 0)) s.bind(("", 0))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
port = s.getsockname()[1] port = s.getsockname()[1]
if port != used_port: if used_ports is None or port not in used_ports:
return port return port
+427
View File
@@ -0,0 +1,427 @@
"Auth related IO tests for PostgREST"
from datetime import datetime, timedelta, timezone
from operator import attrgetter
import signal
import time
import pytest
from config import BASEDIR, CONFIGSDIR, FIXTURES, SECRET
from util import authheader, jwtauthheader, parse_server_timings_header
from postgrest import (
run,
sleep_until_postgrest_config_reload,
sleep_until_postgrest_scache_reload,
wait_until_exit,
)
@pytest.mark.parametrize(
"secretpath",
[path for path in (BASEDIR / "secrets").iterdir() if path.suffix != ".jwt"],
ids=attrgetter("name"),
)
def test_read_secret_from_file(secretpath, defaultenv):
"Authorization should succeed when the secret is read from a file."
env = {**defaultenv, "PGRST_JWT_SECRET": f"@{secretpath}"}
if secretpath.suffix == ".b64":
env["PGRST_JWT_SECRET_IS_BASE64"] = "true"
secret = secretpath.read_bytes()
headers = authheader(secretpath.with_suffix(".jwt").read_text())
with run(stdin=secret, env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
print(response.text)
assert response.status_code == 200
def test_read_secret_from_stdin(defaultenv):
"Authorization should succeed when the secret is read from stdin."
env = {**defaultenv, "PGRST_DB_CONFIG": "false", "PGRST_JWT_SECRET": "@/dev/stdin"}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(stdin=SECRET.encode(), env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
print(response.text)
assert response.status_code == 200
# TODO: This test would fail right now, because of
# https://github.com/PostgREST/postgrest/issues/2126
@pytest.mark.skip
def test_read_secret_from_stdin_dbconfig(defaultenv):
"Authorization should succeed when the secret is read from stdin with db-config=true."
env = {**defaultenv, "PGRST_DB_CONFIG": "true", "PGRST_JWT_SECRET": "@/dev/stdin"}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(stdin=SECRET.encode(), env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
print(response.text)
assert response.status_code == 200
def test_jwt_errors(defaultenv):
"invalid JWT should throw error"
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET, "PGRST_JWT_AUD": "io tests"}
def relativeSeconds(sec):
return int((datetime.now(timezone.utc) + timedelta(seconds=sec)).timestamp())
with run(env=env) as postgrest:
headers = jwtauthheader({}, "other secret")
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "No suitable key or wrong key type"
assert (
response.json()["details"] == "None of the keys was able to decode the JWT"
)
headers = jwtauthheader({"role": "not_existing"}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == 'role "not_existing" does not exist'
# -35 seconds, because we allow clock skew of 30 seconds
headers = jwtauthheader({"exp": relativeSeconds(-35)}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT expired"
# 35 seconds, because we allow clock skew of 30 seconds
headers = jwtauthheader({"nbf": relativeSeconds(35)}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT not yet valid"
# 35 seconds, because we allow clock skew of 35 seconds
headers = jwtauthheader({"iat": relativeSeconds(35)}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT issued at future"
headers = jwtauthheader({"aud": "not set"}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT not in audience"
# partial token, no signature
headers = authheader("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGFuIG9iamVjdA")
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "Expected 3 parts in JWT; got 2"
# complete token but random characters
headers = authheader("quifquirndsjagnrgniur.fonvoienqhhdj.iuqvnvhojah")
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT cryptographic operation failed"
# token with algorithm "none"
headers = authheader(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.e30.yOBhlOIqn56T-4NvyEXCjfi3UmyQZ-BzXtePMO2NgRI"
)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "Wrong or unsupported encoding algorithm"
assert (
response.json()["details"]
== "JWT is unsecured but expected 'alg' was not 'none'"
)
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
}
# for code coverage with cache enabled and server-timing enabled
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only")
assert response.status_code == 401
assert response.json()["message"] == "permission denied for table authors_only"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "false",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
}
# for code coverage with cache enabled and server-timing disabled
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only")
assert response.status_code == 401
assert response.json()["message"] == "permission denied for table authors_only"
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
@pytest.mark.parametrize(
"roleclaim", FIXTURES["roleclaims"], ids=lambda claim: claim["key"]
)
def test_role_claim_key(roleclaim, defaultenv):
"Authorization should depend on a correct role-claim-key and JWT claim."
env = {
**defaultenv,
"PGRST_JWT_ROLE_CLAIM_KEY": roleclaim["key"],
"PGRST_JWT_SECRET": SECRET,
}
headers = jwtauthheader(roleclaim["data"], SECRET)
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == roleclaim["expected_status"]
@pytest.mark.parametrize(
"jwtaudroleclaim",
FIXTURES["jwtaudroleclaims"],
ids=lambda claim: claim["key"] + "_" + str(claim["expected_status"]),
)
def test_jwt_aud_in_role_claim_key(jwtaudroleclaim, defaultenv):
"Allows authorization with JWT aud claim in role-claim-key"
env = {
**defaultenv,
"PGRST_JWT_AUD": "postgrest_test_author",
"PGRST_JWT_ROLE_CLAIM_KEY": jwtaudroleclaim["key"],
"PGRST_JWT_SECRET": SECRET,
}
headers = jwtauthheader(jwtaudroleclaim["data"], SECRET)
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == jwtaudroleclaim["expected_status"]
def test_iat_claim(defaultenv):
"""
A claim with an 'iat' (issued at) attribute should be successful.
The PostgREST time cache leads to issues here, see:
https://github.com/PostgREST/postgrest/issues/1139
"""
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
claim = {"role": "postgrest_test_author", "iat": datetime.now(timezone.utc)}
headers = jwtauthheader(claim, SECRET)
with run(env=env) as postgrest:
for _ in range(10):
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200
time.sleep(0.1)
def test_jwt_secret_reload(tmp_path, defaultenv):
"JWT secret should be reloaded from file when PostgREST is sent SIGUSR2."
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
configfile = tmp_path / "test.config"
configfile.write_text(config)
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(configfile, env=defaultenv) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
# change setting
configfile.write_text(config.replace("invalid" * 5, SECRET))
# reload config
postgrest.process.send_signal(signal.SIGUSR2)
sleep_until_postgrest_config_reload()
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200
def test_jwt_secret_external_file_reload(tmp_path, defaultenv):
"JWT secret external file should be reloaded when PostgREST is sent a SIGUSR2 or a NOTIFY."
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
external_secret_file = tmp_path / "jwt-secret-config"
external_secret_file.write_text("invalid" * 5)
env = {
**defaultenv,
"PGRST_JWT_SECRET": f"@{external_secret_file}",
"PGRST_DB_CHANNEL_ENABLED": "true",
"PGRST_DB_CONFIG": "false",
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY
}
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
# change external file
external_secret_file.write_text(SECRET)
# SIGUSR1 doesn't reload external files, at least when db-config=false
postgrest.process.send_signal(signal.SIGUSR1)
sleep_until_postgrest_scache_reload()
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
# reload config and external file with SIGUSR2
postgrest.process.send_signal(signal.SIGUSR2)
sleep_until_postgrest_config_reload()
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200
# change external file to wrong value again
external_secret_file.write_text("invalid" * 5)
# reload config and external file with NOTIFY
response = postgrest.session.post("/rpc/reload_pgrst_config")
assert response.text == ""
assert response.status_code == 204
sleep_until_postgrest_config_reload()
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
# TODO: This test is more related to observability than authentication.
# So, move it an appropriate test module.
def test_jwt_cache_server_timing(defaultenv):
"server-timing duration is exposed for JWT with expiry"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader(
{
"role": "postgrest_test_author",
"exp": int(
(datetime.now(timezone.utc) + timedelta(minutes=30)).timestamp()
),
},
SECRET,
)
with run(env=env) as postgrest:
first = postgrest.session.get("/authors_only", headers=headers)
second = postgrest.session.get("/authors_only", headers=headers)
assert first.status_code == 200
assert second.status_code == 200
first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"]
second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"]
# with jwt caching the parse time of second request with the same token
# should be at least as fast as the first one
assert second_dur <= first_dur
def test_jwt_cache_without_server_timing(defaultenv):
"JWT cache does not break requests with server-timing disabled"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "false",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(env=env) as postgrest:
first = postgrest.session.get("/authors_only", headers=headers)
second = postgrest.session.get("/authors_only", headers=headers)
assert first.status_code == 200
assert second.status_code == 200
def test_jwt_cache_without_exp_claim(defaultenv):
"server-timing duration is exposed for JWT without expiry"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) # no exp
with run(env=env) as postgrest:
first = postgrest.session.get("/authors_only", headers=headers)
second = postgrest.session.get("/authors_only", headers=headers)
assert first.status_code == 200
assert second.status_code == 200
first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"]
second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"]
assert first_dur >= 0
assert second_dur >= 0
def test_invalidate_jwt_cache_when_secret_changes(tmp_path, defaultenv):
"JWT cache should be emptied after jwt-secret is changed in a config reload"
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
external_secret_file = tmp_path / "jwt-secret-config"
external_secret_file.write_text(SECRET)
env = {
**defaultenv,
"PGRST_JWT_SECRET": f"@{external_secret_file}",
"PGRST_DB_CHANNEL_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400", # enable cache
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY
}
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200 # jwt gets cached
# change external file
external_secret_file.write_text("invalid" * 5)
# reload config and external file with NOTIFY
# jwt-cache should get empty
response = postgrest.session.post("/rpc/reload_pgrst_config")
assert response.text == ""
assert response.status_code == 204
sleep_until_postgrest_config_reload()
# now the request should fail because the cached token is removed
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
+20 -52
View File
@@ -7,58 +7,6 @@ import pytest
from postgrest import run from postgrest import run
def test_requests_with_resource_embedding_wait_for_schema_cache_reload(defaultenv):
"requests that use the schema cache with resource embedding wait long for the schema cache to reload"
env = {
**defaultenv,
"PGRST_DB_SCHEMAS": "apflora",
"PGRST_DB_POOL": "2",
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous",
"PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP": "5100",
}
with run(env=env, wait_max_seconds=30) as postgrest:
# reload the schema cache
response = postgrest.session.get("/rpc/notify_pgrst")
assert response.status_code == 204
postgrest.wait_until_scache_starts_loading()
response = postgrest.session.get("/tpopmassn?select=*,tpop(*)")
assert response.status_code == 200
assert response.elapsed.total_seconds() > 5
def test_requests_without_resource_embedding_wait_for_schema_cache_reload(defaultenv):
"requests that use the schema cache without resource embedding wait less for the schema cache to reload"
env = {
**defaultenv,
"PGRST_DB_SCHEMAS": "apflora",
"PGRST_DB_POOL": "2",
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous",
"PGRST_INTERNAL_SCHEMA_CACHE_LOAD_SLEEP": "1100",
"PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP": "5000",
}
with run(env=env, wait_max_seconds=30) as postgrest:
# reload the schema cache
response = postgrest.session.get("/rpc/notify_pgrst")
assert response.status_code == 204
postgrest.wait_until_scache_starts_loading()
response = postgrest.session.get("/tpopmassn")
assert response.status_code == 200
assert (
response.elapsed.total_seconds() > 1
and response.elapsed.total_seconds() < 5
)
def test_schema_cache_load_max_duration(defaultenv): def test_schema_cache_load_max_duration(defaultenv):
"schema cache load should not surpass a max_duration of elapsed milliseconds" "schema cache load should not surpass a max_duration of elapsed milliseconds"
@@ -122,3 +70,23 @@ def test_should_not_fail_with_stack_overflow(defaultenv):
assert response.status_code == 404 assert response.status_code == 404
data = response.json() data = response.json()
assert data["code"] == "PGRST205" assert data["code"] == "PGRST205"
def test_second_request_for_non_existent_table_should_be_quick(defaultenv):
"requesting a non-existent relationship should be quick after the fuzzy search index is loaded (2nd request)"
env = {
**defaultenv,
"PGRST_DB_SCHEMAS": "fuzzysearch",
"PGRST_DB_POOL": "2",
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous",
}
with run(env=env, wait_max_seconds=30) as postgrest:
response = postgrest.session.get("/unknown-table")
assert response.status_code == 404
data = response.json()
assert data["code"] == "PGRST205"
first_duration = response.elapsed.total_seconds()
response = postgrest.session.get("/unknown-table")
assert response.elapsed.total_seconds() < first_duration / 10
+6 -2
View File
@@ -286,6 +286,7 @@ def test_jwt_secret_min_length(defaultenv):
assert "The JWT secret must be at least 32 characters long." in error assert "The JWT secret must be at least 32 characters long." in error
# TODO: Improve readability of "--ready" healthcheck tests
@pytest.mark.parametrize("host", ["127.0.0.1", "::1"], ids=["IPv4", "IPv6"]) @pytest.mark.parametrize("host", ["127.0.0.1", "::1"], ids=["IPv4", "IPv6"])
def test_cli_ready_flag_success(host, defaultenv): def test_cli_ready_flag_success(host, defaultenv):
"test PostgREST ready flag succeeds when ready" "test PostgREST ready flag succeeds when ready"
@@ -340,8 +341,11 @@ def test_cli_ready_flag_fail_with_http_exception(defaultenv):
# when healthcheck process sends the request to a wrong endpoint # when healthcheck process sends the request to a wrong endpoint
with run(env=defaultenv, port=port) as postgrest: with run(env=defaultenv, port=port) as postgrest:
# we set it to some freeport where admin is not running # we set it to some freeport where server and admin server is not running
postgrest.config["PGRST_ADMIN_SERVER_PORT"] = str(freeport()) admin_port = int(postgrest.config["PGRST_ADMIN_SERVER_PORT"])
used_ports = [port, admin_port]
postgrest.config["PGRST_ADMIN_SERVER_PORT"] = str(freeport(used_ports))
output = cli(["--ready"], env=postgrest.config, expect_error=True) output = cli(["--ready"], env=postgrest.config, expect_error=True)
(admin_host, admin_port) = get_admin_host_and_port_from_config(postgrest.config) (admin_host, admin_port) = get_admin_host_and_port_from_config(postgrest.config)
+152 -460
View File
@@ -1,15 +1,13 @@
"Unit tests for Input/Ouput of PostgREST seen as a black box." "Unit tests for Input/Ouput of PostgREST seen as a black box."
from datetime import datetime, timedelta, timezone
from operator import attrgetter
import os import os
import re import re
import signal import signal
import time import time
import pytest import pytest
from config import BASEDIR, CONFIGSDIR, FIXTURES, SECRET from config import CONFIGSDIR, FIXTURES, SECRET
from util import Thread, authheader, jwtauthheader, parse_server_timings_header from util import Thread, jwtauthheader, parse_server_timings_header
from postgrest import ( from postgrest import (
freeport, freeport,
is_ipv6, is_ipv6,
@@ -23,162 +21,6 @@ from postgrest import (
) )
@pytest.mark.parametrize(
"secretpath",
[path for path in (BASEDIR / "secrets").iterdir() if path.suffix != ".jwt"],
ids=attrgetter("name"),
)
def test_read_secret_from_file(secretpath, defaultenv):
"Authorization should succeed when the secret is read from a file."
env = {**defaultenv, "PGRST_JWT_SECRET": f"@{secretpath}"}
if secretpath.suffix == ".b64":
env["PGRST_JWT_SECRET_IS_BASE64"] = "true"
secret = secretpath.read_bytes()
headers = authheader(secretpath.with_suffix(".jwt").read_text())
with run(stdin=secret, env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
print(response.text)
assert response.status_code == 200
def test_read_secret_from_stdin(defaultenv):
"Authorization should succeed when the secret is read from stdin."
env = {**defaultenv, "PGRST_DB_CONFIG": "false", "PGRST_JWT_SECRET": "@/dev/stdin"}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(stdin=SECRET.encode(), env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
print(response.text)
assert response.status_code == 200
# TODO: This test would fail right now, because of
# https://github.com/PostgREST/postgrest/issues/2126
@pytest.mark.skip
def test_read_secret_from_stdin_dbconfig(defaultenv):
"Authorization should succeed when the secret is read from stdin with db-config=true."
env = {**defaultenv, "PGRST_DB_CONFIG": "true", "PGRST_JWT_SECRET": "@/dev/stdin"}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(stdin=SECRET.encode(), env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
print(response.text)
assert response.status_code == 200
def test_jwt_errors(defaultenv):
"invalid JWT should throw error"
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET, "PGRST_JWT_AUD": "io tests"}
def relativeSeconds(sec):
return int((datetime.now(timezone.utc) + timedelta(seconds=sec)).timestamp())
with run(env=env) as postgrest:
headers = jwtauthheader({}, "other secret")
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "No suitable key or wrong key type"
assert (
response.json()["details"] == "None of the keys was able to decode the JWT"
)
headers = jwtauthheader({"role": "not_existing"}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == 'role "not_existing" does not exist'
# -35 seconds, because we allow clock skew of 30 seconds
headers = jwtauthheader({"exp": relativeSeconds(-35)}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT expired"
# 35 seconds, because we allow clock skew of 30 seconds
headers = jwtauthheader({"nbf": relativeSeconds(35)}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT not yet valid"
# 35 seconds, because we allow clock skew of 35 seconds
headers = jwtauthheader({"iat": relativeSeconds(35)}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT issued at future"
headers = jwtauthheader({"aud": "not set"}, SECRET)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT not in audience"
# partial token, no signature
headers = authheader("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGFuIG9iamVjdA")
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "Expected 3 parts in JWT; got 2"
# complete token but random characters
headers = authheader("quifquirndsjagnrgniur.fonvoienqhhdj.iuqvnvhojah")
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "JWT cryptographic operation failed"
# token with algorithm "none"
headers = authheader(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.e30.yOBhlOIqn56T-4NvyEXCjfi3UmyQZ-BzXtePMO2NgRI"
)
response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401
assert response.json()["message"] == "Wrong or unsupported encoding algorithm"
assert (
response.json()["details"]
== "JWT is unsecured but expected 'alg' was not 'none'"
)
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
}
# for code coverage with cache enabled and server-timing enabled
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only")
assert response.status_code == 401
assert response.json()["message"] == "permission denied for table authors_only"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "false",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
}
# for code coverage with cache enabled and server-timing disabled
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only")
assert response.status_code == 401
assert response.json()["message"] == "permission denied for table authors_only"
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 = {
@@ -217,67 +59,6 @@ def test_read_dburi_from_stdin_with_eol(dburi, defaultenv):
pass pass
@pytest.mark.parametrize(
"roleclaim", FIXTURES["roleclaims"], ids=lambda claim: claim["key"]
)
def test_role_claim_key(roleclaim, defaultenv):
"Authorization should depend on a correct role-claim-key and JWT claim."
env = {
**defaultenv,
"PGRST_JWT_ROLE_CLAIM_KEY": roleclaim["key"],
"PGRST_JWT_SECRET": SECRET,
}
headers = jwtauthheader(roleclaim["data"], SECRET)
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == roleclaim["expected_status"]
@pytest.mark.parametrize(
"jwtaudroleclaim",
FIXTURES["jwtaudroleclaims"],
ids=lambda claim: claim["key"] + "_" + str(claim["expected_status"]),
)
def test_jwt_aud_in_role_claim_key(jwtaudroleclaim, defaultenv):
"Allows authorization with JWT aud claim in role-claim-key"
env = {
**defaultenv,
"PGRST_JWT_AUD": "postgrest_test_author",
"PGRST_JWT_ROLE_CLAIM_KEY": jwtaudroleclaim["key"],
"PGRST_JWT_SECRET": SECRET,
}
headers = jwtauthheader(jwtaudroleclaim["data"], SECRET)
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == jwtaudroleclaim["expected_status"]
def test_iat_claim(defaultenv):
"""
A claim with an 'iat' (issued at) attribute should be successful.
The PostgREST time cache leads to issues here, see:
https://github.com/PostgREST/postgrest/issues/1139
"""
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
claim = {"role": "postgrest_test_author", "iat": datetime.now(timezone.utc)}
headers = jwtauthheader(claim, SECRET)
with run(env=env) as postgrest:
for _ in range(10):
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200
time.sleep(0.1)
def test_app_settings_flush_pool(defaultenv): def test_app_settings_flush_pool(defaultenv):
""" """
App settings should not reset when the db pool is flushed. App settings should not reset when the db pool is flushed.
@@ -353,79 +134,6 @@ def test_app_settings_reload(tmp_path, defaultenv):
assert response.text == '"Jane"' assert response.text == '"Jane"'
def test_jwt_secret_reload(tmp_path, defaultenv):
"JWT secret should be reloaded from file when PostgREST is sent SIGUSR2."
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
configfile = tmp_path / "test.config"
configfile.write_text(config)
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(configfile, env=defaultenv) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
# change setting
configfile.write_text(config.replace("invalid" * 5, SECRET))
# reload config
postgrest.process.send_signal(signal.SIGUSR2)
sleep_until_postgrest_config_reload()
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200
def test_jwt_secret_external_file_reload(tmp_path, defaultenv):
"JWT secret external file should be reloaded when PostgREST is sent a SIGUSR2 or a NOTIFY."
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
external_secret_file = tmp_path / "jwt-secret-config"
external_secret_file.write_text("invalid" * 5)
env = {
**defaultenv,
"PGRST_JWT_SECRET": f"@{external_secret_file}",
"PGRST_DB_CHANNEL_ENABLED": "true",
"PGRST_DB_CONFIG": "false",
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY
}
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
# change external file
external_secret_file.write_text(SECRET)
# SIGUSR1 doesn't reload external files, at least when db-config=false
postgrest.process.send_signal(signal.SIGUSR1)
sleep_until_postgrest_scache_reload()
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
# reload config and external file with SIGUSR2
postgrest.process.send_signal(signal.SIGUSR2)
sleep_until_postgrest_config_reload()
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200
# change external file to wrong value again
external_secret_file.write_text("invalid" * 5)
# reload config and external file with NOTIFY
response = postgrest.session.post("/rpc/reload_pgrst_config")
assert response.text == ""
assert response.status_code == 204
sleep_until_postgrest_config_reload()
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
def test_db_schema_reload(tmp_path, defaultenv): def test_db_schema_reload(tmp_path, defaultenv):
"DB schema should be reloaded from file when PostgREST is sent SIGUSR2." "DB schema should be reloaded from file when PostgREST is sent SIGUSR2."
config = (CONFIGSDIR / "sigusr2-settings.config").read_text() config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
@@ -1350,6 +1058,56 @@ def test_schema_cache_concurrent_notifications(slow_schema_cache_env):
assert response.status_code == 200 assert response.status_code == 200
def test_schema_cache_query_sleep_logs(defaultenv):
"""Schema cache sleep should be reflected in the logged query duration."""
env = {
**defaultenv,
"PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP": "1000",
}
log_pattern = re.compile(r"Schema cache queried in ([\d.]+) milliseconds")
with run(env=env, wait_max_seconds=3, no_startup_stdout=False) as postgrest:
observed_ms = None
collected = []
lines = postgrest.read_stdout(nlines=10)
collected.extend(lines)
for line in lines:
match = log_pattern.search(line)
if match:
observed_ms = float(match.group(1))
break
assert observed_ms is not None
assert 1000 < observed_ms < 2000
def test_schema_cache_load_sleep_logs(defaultenv):
"""Schema cache load sleep should be reflected in the logged load duration."""
env = {
**defaultenv,
"PGRST_INTERNAL_SCHEMA_CACHE_LOAD_SLEEP": "1000",
}
log_pattern = re.compile(r"Schema cache loaded in ([\d.]+) milliseconds")
with run(env=env, wait_max_seconds=3, no_startup_stdout=False) as postgrest:
observed_ms = None
collected = []
lines = postgrest.read_stdout(nlines=10)
collected.extend(lines)
for line in lines:
match = log_pattern.search(line)
if match:
observed_ms = float(match.group(1))
break
assert observed_ms is not None
assert 1000 < observed_ms < 2000
@pytest.mark.parametrize("dburi_type", ["no_params", "no_params_qmark", "with_params"]) @pytest.mark.parametrize("dburi_type", ["no_params", "no_params_qmark", "with_params"])
def test_get_pgrst_version_with_uri_connection_string(dburi_type, dburi, defaultenv): def test_get_pgrst_version_with_uri_connection_string(dburi_type, dburi, defaultenv):
"The fallback_application_name should be added to the db-uri if it has a URI format" "The fallback_application_name should be added to the db-uri if it has a URI format"
@@ -1489,90 +1247,6 @@ def test_fail_with_automatic_recovery_disabled_and_terminated_using_query(defaul
assert exitCode == 1 assert exitCode == 1
def test_jwt_cache_server_timing(defaultenv):
"server-timing duration is exposed for JWT with expiry"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader(
{
"role": "postgrest_test_author",
"exp": int(
(datetime.now(timezone.utc) + timedelta(minutes=30)).timestamp()
),
},
SECRET,
)
with run(env=env) as postgrest:
first = postgrest.session.get("/authors_only", headers=headers)
second = postgrest.session.get("/authors_only", headers=headers)
assert first.status_code == 200
assert second.status_code == 200
first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"]
second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"]
# with jwt caching the parse time of second request with the same token
# should be at least as fast as the first one
assert second_dur <= first_dur
def test_jwt_cache_without_server_timing(defaultenv):
"JWT cache does not break requests with server-timing disabled"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "false",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(env=env) as postgrest:
first = postgrest.session.get("/authors_only", headers=headers)
second = postgrest.session.get("/authors_only", headers=headers)
assert first.status_code == 200
assert second.status_code == 200
def test_jwt_cache_without_exp_claim(defaultenv):
"server-timing duration is exposed for JWT without expiry"
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) # no exp
with run(env=env) as postgrest:
first = postgrest.session.get("/authors_only", headers=headers)
second = postgrest.session.get("/authors_only", headers=headers)
assert first.status_code == 200
assert second.status_code == 200
first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"]
second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"]
assert first_dur >= 0
assert second_dur >= 0
def test_preflight_request_with_cors_allowed_origin_config(defaultenv): def test_preflight_request_with_cors_allowed_origin_config(defaultenv):
"OPTIONS preflight request should return Access-Control-Allow-Origin equal to origin" "OPTIONS preflight request should return Access-Control-Allow-Origin equal to origin"
@@ -1805,53 +1479,6 @@ def test_schema_cache_startup_load_with_in_db_config(defaultenv, metapostgrest):
assert response.status_code == 204 assert response.status_code == 204
def test_jwt_cache_purges_expired_entries(defaultenv):
"test expired cache entries are purged on cache miss"
# The verification of actual cache size reduction is done manually, see https://github.com/PostgREST/postgrest/pull/3801#issuecomment-2620776041
# This test is written for code coverage of purgeExpired function
def relativeSeconds(sec):
return int((datetime.now(timezone.utc) + timedelta(seconds=sec)).timestamp())
def headers(sec):
return jwtauthheader(
{"role": "postgrest_test_author", "exp": relativeSeconds(sec)}, SECRET
)
env = {
**defaultenv,
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
with run(env=env) as postgrest:
# Generate two unique JWT tokens
# The 1 second sleep is needed for it generate a unique token
hdrs1 = headers(5)
postgrest.session.get("/authors_only", headers=hdrs1)
time.sleep(1)
hdrs2 = headers(5)
postgrest.session.get("/authors_only", headers=hdrs2)
# Wait 5 seconds for the tokens to expire
time.sleep(5)
hdrs3 = headers(5)
# Make another request which should cause a cache miss and so
# the purgeExpired function will be triggered.
#
# This should remove the 2 expired tokens but adds another to cache
response = postgrest.session.get("/authors_only", headers=hdrs3)
assert response.status_code == 200
def test_pgrst_log_503_client_error_to_stderr(defaultenv): def test_pgrst_log_503_client_error_to_stderr(defaultenv):
"PostgREST should log 503 errors to stderr" "PostgREST should log 503 errors to stderr"
@@ -1962,41 +1589,6 @@ def test_proxy_status_header(defaultenv, metapostgrest):
assert data["message"] == "canceling statement due to statement timeout" assert data["message"] == "canceling statement due to statement timeout"
def test_invalidate_jwt_cache_when_secret_changes(tmp_path, defaultenv):
"JWT cache should be emptied after jwt-secret is changed in a config reload"
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
external_secret_file = tmp_path / "jwt-secret-config"
external_secret_file.write_text(SECRET)
env = {
**defaultenv,
"PGRST_JWT_SECRET": f"@{external_secret_file}",
"PGRST_DB_CHANNEL_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400", # enable cache
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY
}
with run(env=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200 # jwt gets cached
# change external file
external_secret_file.write_text("invalid" * 5)
# reload config and external file with NOTIFY
# jwt-cache should get empty
response = postgrest.session.post("/rpc/reload_pgrst_config")
assert response.text == ""
assert response.status_code == 204
sleep_until_postgrest_config_reload()
# now the request should fail because the cached token is removed
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
def test_allow_configs_to_be_set_to_empty(defaultenv): def test_allow_configs_to_be_set_to_empty(defaultenv):
'configs that are explicitly set to empty (= "<empty>") should not throw parse error' 'configs that are explicitly set to empty (= "<empty>") should not throw parse error'
@@ -2046,3 +1638,103 @@ def test_log_listener_connection_errors(defaultenv):
in line in line
for line in output for line in output
) )
def test_db_pre_config_with_pg_reserved_words(defaultenv):
"The db-pre-config should not fail unexpectedly when function name is a postgres reserved word"
env = {
**defaultenv,
"PGRST_DB_PRE_CONFIG": "true", # call true function
}
with run(env=env) as postgrest:
response = postgrest.session.post("/rpc/true")
assert response.status_code == 200
env = {
**defaultenv,
"PGRST_DB_PRE_CONFIG": "select", # no "select" function in our fixtures, fail gracefully at startup
}
with run(env=env, no_startup_stdout=False, wait_for_readiness=False) as postgrest:
output = postgrest.read_stdout(nlines=8)
assert any(
'Failed to query database settings for the config parameters.{"code":"42883","details":null,"hint":"No function matches the given name and argument types. You might need to add explicit type casts.","message":"function select() does not exist"}'
in line
for line in output
)
def test_requests_with_resource_embedding_wait_for_schema_cache_reload(defaultenv):
"requests that use the schema cache with resource embedding wait long for the schema cache to reload"
env = {
**defaultenv,
"PGRST_DB_POOL": "2",
"PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP": "5100",
}
with run(env=env, wait_max_seconds=30) as postgrest:
# reload the schema cache
response = postgrest.session.get("/rpc/notify_pgrst")
assert response.status_code == 204
postgrest.wait_until_scache_starts_loading()
response = postgrest.session.get("/directors?select=id,name,films(title)")
assert response.status_code == 200
assert response.elapsed.total_seconds() > 5
def test_requests_without_resource_embedding_wait_for_schema_cache_reload(defaultenv):
"requests that use the schema cache without resource embedding wait less for the schema cache to reload"
env = {
**defaultenv,
"PGRST_DB_POOL": "2",
"PGRST_INTERNAL_SCHEMA_CACHE_LOAD_SLEEP": "1100",
"PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP": "5000",
}
with run(env=env, wait_max_seconds=30) as postgrest:
# reload the schema cache
response = postgrest.session.get("/rpc/notify_pgrst")
assert response.status_code == 204
postgrest.wait_until_scache_starts_loading()
response = postgrest.session.get("/films")
assert response.status_code == 200
assert (
response.elapsed.total_seconds() > 1
and response.elapsed.total_seconds() < 5
)
def test_server_timing_transaction_duration(defaultenv, metapostgrest):
"server-timing transaction duration should be accurate"
# just to ensure we don't timeout
role = "timeout_authenticator"
set_statement_timeout(metapostgrest, role, 3000) # 3 seconds
env = {
**defaultenv,
"PGUSER": role,
"PGRST_DB_ANON_ROLE": role,
"PGRST_SERVER_TIMING_ENABLED": "true",
}
with run(env=env) as postgrest:
response = postgrest.session.get("/rpc/sleep?seconds=2")
assert response.status_code == 204
response_dur = parse_server_timings_header(response.headers["Server-Timing"])[
"transaction"
]
assert 2000 <= response_dur < 3000
+2 -2
View File
@@ -3,9 +3,9 @@
Can be used as: Can be used as:
``` ```
postgrest-with-postgresql-15 -f test/pgbench/fixtures.sql pgbench -U postgres -n -T 10 -f test/pgbench/1567/old.sql postgrest-with-pg-15 -f test/pgbench/fixtures.sql pgbench -U postgres -n -T 10 -f test/pgbench/1567/old.sql
postgrest-with-postgresql-15 -f test/pgbench/fixtures.sql pgbench -U postgres -n -T 10 -f test/pgbench/1567/new.sql postgrest-with-pg-15 -f test/pgbench/fixtures.sql pgbench -U postgres -n -T 10 -f test/pgbench/1567/new.sql
``` ```
## Directory structure ## Directory structure
+20 -2
View File
@@ -252,21 +252,39 @@ spec =
[json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|] [json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|]
{ matchStatus = 201 } { matchStatus = 201 }
context "used with PATCH" $ context "used with PATCH" $ do
it "succeeds when using and/or params" $ it "succeeds when using and/or params" $
request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name" request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
[json|{ name : "updated grandchild entity"}|] `shouldRespondWith` [json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|] [json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "succeeds when the filtered column is modified" $
request methodPatch "/entities?select=id,name&or=(name.is.null,name.like.*test*)"
[("Prefer", "return=representation")]
[json|{ "name" : "updated entity" }|] `shouldRespondWith`
[json|[{ "id": 4, "name": "updated entity" }]|]
{ matchHeaders = [matchContentTypeJson] }
it "succeeds when the filtered column is not selected in the returned representation" $
request methodPatch "/entities?select=id&or=(name.is.null,name.like.*test*)"
[("Prefer", "return=representation")]
[json|{ "name" : "updated entity" }|] `shouldRespondWith`
[json|[{ "id": 4 }]|]
{ matchHeaders = [matchContentTypeJson] }
context "used with DELETE" $ context "used with DELETE" $ do
it "succeeds when using and/or params" $ it "succeeds when using and/or params" $
request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name" request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
"" ""
`shouldRespondWith` `shouldRespondWith`
[json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|] [json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|]
it "succeeds when the filtered column is not selected in the returned representation" $
request methodDelete "/entities?select=id&or=(name.is.null,name.like.*test*)"
[("Prefer", "return=representation")]
""
`shouldRespondWith`
[json|[{ "id": 4 }]|]
it "can query columns that begin with and/or reserved words" $ it "can query columns that begin with and/or reserved words" $
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200 get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
+15 -3
View File
@@ -138,6 +138,7 @@ spec actualPgVersion = do
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/projects?id=eq.11" , "Location" <:> "/projects?id=eq.11"
, "Content-Range" <:> "*/*" , "Content-Range" <:> "*/*"
, "Content-Length" <:> "0"
, "Preference-Applied" <:> "return=headers-only"] , "Preference-Applied" <:> "return=headers-only"]
} }
@@ -151,6 +152,7 @@ spec actualPgVersion = do
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021" , "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021"
, "Content-Range" <:> "*/*" , "Content-Range" <:> "*/*"
, "Content-Length" <:> "0"
, "Preference-Applied" <:> "return=headers-only"] , "Preference-Applied" <:> "return=headers-only"]
} }
@@ -163,7 +165,8 @@ spec actualPgVersion = do
"" ""
{ matchStatus = 201 { matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, matchHeaderAbsent hLocation ] , matchHeaderAbsent hLocation
, "Content-Length" <:> "0"]
} }
context "from an html form" $ context "from an html form" $
@@ -175,7 +178,8 @@ spec actualPgVersion = do
`shouldRespondWith` `shouldRespondWith`
"" ""
{ matchStatus = 201 { matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType ] , matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Length" <:> "0"]
} }
context "with no pk supplied" $ do context "with no pk supplied" $ do
@@ -199,6 +203,7 @@ spec actualPgVersion = do
"" ""
{ matchStatus = 201 { matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Length" <:> "0"
, "Location" <:> "/auto_incrementing_pk?id=eq.2" , "Location" <:> "/auto_incrementing_pk?id=eq.2"
, "Preference-Applied" <:> "return=headers-only"] , "Preference-Applied" <:> "return=headers-only"]
} }
@@ -741,6 +746,7 @@ spec actualPgVersion = do
"" ""
{ matchStatus = 201 { matchStatus = 201
, matchHeaders = [matchHeaderAbsent hContentType , matchHeaders = [matchHeaderAbsent hContentType
, "Content-Length" <:> "0"
, "Preference-Applied" <:> "return=minimal"] , "Preference-Applied" <:> "return=minimal"]
} }
@@ -753,7 +759,8 @@ spec actualPgVersion = do
"" ""
{ matchStatus = 201 { matchStatus = 201
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, matchHeaderAbsent hLocation ] , matchHeaderAbsent hLocation
, "Content-Length" <:> "0"]
} }
it "returns a location header with pks from both tables" $ it "returns a location header with pks from both tables" $
@@ -765,6 +772,7 @@ spec actualPgVersion = do
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/with_multiple_pks?pk1=eq.1&pk2=eq.2" , "Location" <:> "/with_multiple_pks?pk1=eq.1&pk2=eq.2"
, "Content-Range" <:> "*/*" , "Content-Range" <:> "*/*"
, "Content-Length" <:> "0"
, "Preference-Applied" <:> "return=headers-only"] , "Preference-Applied" <:> "return=headers-only"]
} }
@@ -778,6 +786,7 @@ spec actualPgVersion = do
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/compound_pk_view?k1=eq.1&k2=eq.test" , "Location" <:> "/compound_pk_view?k1=eq.1&k2=eq.test"
, "Content-Range" <:> "*/*" , "Content-Range" <:> "*/*"
, "Content-Length" <:> "0"
, "Preference-Applied" <:> "return=headers-only"] , "Preference-Applied" <:> "return=headers-only"]
} }
@@ -790,6 +799,7 @@ spec actualPgVersion = do
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null" , "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"
, "Content-Range" <:> "*/*" , "Content-Range" <:> "*/*"
, "Content-Length" <:> "0"
, "Preference-Applied" <:> "return=headers-only"] , "Preference-Applied" <:> "return=headers-only"]
} }
@@ -807,6 +817,7 @@ spec actualPgVersion = do
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/datarep_todos?id=eq.5" , "Location" <:> "/datarep_todos?id=eq.5"
, "Content-Range" <:> "*/*" , "Content-Range" <:> "*/*"
, "Content-Length" <:> "0"
, "Preference-Applied" <:> "return=headers-only"] , "Preference-Applied" <:> "return=headers-only"]
} }
@@ -862,6 +873,7 @@ spec actualPgVersion = do
, matchHeaders = [ matchHeaderAbsent hContentType , matchHeaders = [ matchHeaderAbsent hContentType
, "Location" <:> "/datarep_todos_computed?id=eq.5" , "Location" <:> "/datarep_todos_computed?id=eq.5"
, "Content-Range" <:> "*/*" , "Content-Range" <:> "*/*"
, "Content-Length" <:> "0"
, "Preference-Applied" <:> "return=headers-only"] , "Preference-Applied" <:> "return=headers-only"]
} }
+6 -3
View File
@@ -126,7 +126,8 @@ spec = do
"hint":null "hint":null
}|] }|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"] , matchHeaders = [ "Content-Range" <:> "*/0"
, "Content-Length" <:> "144"]
} }
it "refuses a range requesting start past last item" $ it "refuses a range requesting start past last item" $
@@ -288,7 +289,8 @@ spec = do
"hint":null "hint":null
}|] }|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"] , matchHeaders = [ "Content-Range" <:> "*/0"
, "Content-Length" <:> "144"]
} }
it "refuses a range requesting start past last item" $ it "refuses a range requesting start past last item" $
@@ -470,7 +472,8 @@ spec = do
"hint":null "hint":null
}|] }|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"] , matchHeaders = [ "Content-Range" <:> "*/0"
, "Content-Length" <:> "144"]
} }
it "refuses a range requesting start past last item" $ it "refuses a range requesting start past last item" $
+13 -1
View File
@@ -1146,12 +1146,24 @@ spec =
} }
context "single unnamed param" $ do context "single unnamed param" $ do
it "can insert json directly" $ it "can insert json directly with unnamed parameter" $
post "/rpc/unnamed_json_param" post "/rpc/unnamed_json_param"
[json|{"A": 1, "B": 2, "C": 3}|] [json|{"A": 1, "B": 2, "C": 3}|]
`shouldRespondWith` `shouldRespondWith`
[json|{"A": 1, "B": 2, "C": 3}|] [json|{"A": 1, "B": 2, "C": 3}|]
it "rejects json body when single param has a name" $
post "/rpc/named_json_param"
[json|{"A": 1, "B": 2, "C": 3}|]
`shouldRespondWith`
[json|{
"code":"PGRST202",
"message":"Could not find the function test.named_json_param(A, B, C) in the schema cache",
"details":"Searched for the function test.named_json_param with parameters A, B, C or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache.",
"hint":null
}|]
{ matchStatus = 404 }
it "can insert text directly" $ do it "can insert text directly" $ do
request methodPost "/rpc/unnamed_text_param" request methodPost "/rpc/unnamed_text_param"
[("Content-Type", "text/plain"), ("Accept", "text/plain")] [("Content-Type", "text/plain"), ("Accept", "text/plain")]
+3 -1
View File
@@ -20,7 +20,9 @@ postItem =
`shouldRespondWith` `shouldRespondWith`
"" ""
{ matchStatus = 201 { matchStatus = 201
, matchHeaders = [matchHeaderAbsent hContentType] } , matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Length" <:> "0" ]
}
-- removes Items left over from POST, PUT, and PATCH -- removes Items left over from POST, PUT, and PATCH
deleteItems = deleteItems =
+5
View File
@@ -2356,6 +2356,11 @@ create or replace function test.unnamed_json_param(json) returns json as $$
select $1; select $1;
$$ language sql; $$ language sql;
-- Function with a NAMED json parameter (for testing single param fallback behavior)
create or replace function test.named_json_param(data json) returns json as $$
select data;
$$ language sql;
create or replace function test.unnamed_text_param(text) returns "text/plain" as $$ create or replace function test.unnamed_text_param(text) returns "text/plain" as $$
select $1::"text/plain"; select $1::"text/plain";
$$ language sql; $$ language sql;