diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ece0825
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.github
+target/
+tmp/
+tinql/docs/book/
+Dockerfile
+.dockerignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index 380d953..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,77 +0,0 @@
-name: CI
-
-on:
- push:
- workflow_dispatch:
-
-permissions:
- contents: read
-
-jobs:
- build-and-test:
- name: ${{ matrix.arch.name }} / PostgreSQL ${{ matrix.postgres }}
- runs-on: ${{ matrix.arch.runner }}
- timeout-minutes: 60
- strategy:
- fail-fast: false
- matrix:
- arch:
- - name: x86-64
- runner: ubuntu-24.04
- - name: arm64
- runner: ubuntu-24.04-arm
- postgres: [17, 18]
-
- env:
- CARGO_TERM_COLOR: always
- RUST_BACKTRACE: 1
-
- steps:
- - name: Check out source
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
-
- - name: Configure pgrx home
- run: echo "PGRX_HOME=$RUNNER_TEMP/pgrx" >> "$GITHUB_ENV"
-
- - name: Install PostgreSQL build dependencies
- run: |
- sudo apt-get update
- sudo apt-get install --yes --no-install-recommends \
- bison \
- build-essential \
- ccache \
- flex \
- libreadline-dev \
- libssl-dev \
- libxml2-dev \
- libxml2-utils \
- libxslt1-dev \
- pkg-config \
- xsltproc \
- zlib1g-dev
-
- - name: Install pinned Rust toolchain and Clippy
- run: rustup component add clippy
-
- - name: Install cargo-pgrx
- run: cargo install cargo-pgrx --version 0.19.1 --locked
-
- - name: Install PostgreSQL ${{ matrix.postgres }}
- env:
- PG_MAJOR: ${{ matrix.postgres }}
- run: cargo pgrx init "--pg${PG_MAJOR}=download" --jobs "$(nproc)"
-
- - name: Build
- env:
- PG_MAJOR: ${{ matrix.postgres }}
- run: cargo build --locked --package tin --no-default-features --features "pg${PG_MAJOR}"
-
- - name: Clippy
- env:
- PG_MAJOR: ${{ matrix.postgres }}
- run: cargo clippy --locked --workspace --all-targets --no-default-features --features "pg${PG_MAJOR} pg_test" -- -D warnings
-
- - name: Test local suite
- env:
- PG_MAJOR: ${{ matrix.postgres }}
- run: cargo pgrx test "pg${PG_MAJOR}" --package tin --no-default-features --features "pg${PG_MAJOR}"
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..e01ca78
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,154 @@
+name: CI
+
+on:
+ push:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+env:
+ IMAGE: planetscale/lead
+ DOCKERHUB_OIDC_CONNECTIONID: 00074362-4196-4eb3-91cd-8b07dfdd71c7
+
+jobs:
+ build-and-test:
+ name: ${{ matrix.arch.name }} / PostgreSQL ${{ matrix.postgres }}
+ runs-on: ${{ matrix.arch.runner }}
+ permissions:
+ contents: read
+ id-token: write
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ arch:
+ - name: x86-64
+ runner: ubuntu-24.04
+ docker: amd64
+ - name: arm64
+ runner: ubuntu-24.04-arm
+ docker: arm64
+ postgres: [17, 18]
+
+ env:
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: 1
+ PG_MAJOR: ${{ matrix.postgres }}
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+
+ - name: Build builder image
+ run: docker build --target builder --build-arg "PG_MAJOR=${PG_MAJOR}" --tag lead-builder .
+
+ - name: Build
+ run: |
+ docker/in-builder lead-builder "$RUNNER_TEMP/work" \
+ cargo build --locked --package tin --no-default-features --features "pg${PG_MAJOR}"
+
+ - name: Clippy
+ run: |
+ docker/in-builder lead-builder "$RUNNER_TEMP/work" \
+ cargo clippy --locked --workspace --all-targets --no-default-features \
+ --features "pg${PG_MAJOR} pg_test" -- -D warnings
+
+ - name: Test local suite
+ run: |
+ docker/in-builder lead-builder "$RUNNER_TEMP/work" \
+ cargo pgrx test "pg${PG_MAJOR}" --package tin --no-default-features --features "pg${PG_MAJOR}"
+
+ - name: Package extension
+ run: |
+ docker/in-builder lead-builder "$RUNNER_TEMP/work" \
+ cargo pgrx package --debug --package tin --no-default-features --features "pg${PG_MAJOR}" \
+ --pg-config "/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config" --out-dir /out
+
+ - name: Build image
+ run: |
+ docker build --build-arg "PG_MAJOR=${PG_MAJOR}" \
+ --build-context "artifacts=$RUNNER_TEMP/work/out" --tag "lead:pg${PG_MAJOR}" .
+
+ - name: Smoke test image
+ run: |
+ docker run --detach --name lead --env POSTGRES_PASSWORD=postgres "lead:pg${PG_MAJOR}"
+ for _ in $(seq 60); do
+ if docker exec lead pg_isready --username postgres --host 127.0.0.1; then
+ break
+ fi
+ sleep 1
+ done
+ docker exec lead pg_isready --username postgres --host 127.0.0.1
+ test "$(docker exec lead psql --username postgres --no-psqlrc --tuples-only --no-align \
+ --command "SELECT extname FROM pg_extension WHERE extname = 'tin'")" = tin
+ test "$(docker exec lead psql --username postgres --no-psqlrc --tuples-only --no-align \
+ --command "SELECT 'hello world' ==> 'hello'")" = t
+
+ - name: Show container logs
+ if: failure()
+ run: docker logs lead || true
+
+ - name: Log in to Docker Hub
+ if: github.ref == 'refs/heads/main'
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ username: planetscale
+
+ - name: Push image
+ if: github.ref == 'refs/heads/main'
+ env:
+ ARCH: ${{ matrix.arch.docker }}
+ run: |
+ docker tag "lead:pg${PG_MAJOR}" "${IMAGE}:${GITHUB_SHA::12}-pg${PG_MAJOR}-${ARCH}"
+ docker push "${IMAGE}:${GITHUB_SHA::12}-pg${PG_MAJOR}-${ARCH}"
+
+ manifest:
+ name: Manifest / PostgreSQL ${{ matrix.postgres }}
+ if: github.ref == 'refs/heads/main'
+ needs: build-and-test
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ id-token: write
+ timeout-minutes: 10
+ strategy:
+ matrix:
+ postgres: [17, 18]
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+
+ - name: Determine version
+ id: version
+ run: |
+ version="$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml)"
+ test -n "$version"
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ username: planetscale
+
+ - name: Create multi-arch manifest
+ env:
+ PG_MAJOR: ${{ matrix.postgres }}
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ tags=(
+ --tag "${IMAGE}:${GITHUB_SHA::12}-pg${PG_MAJOR}"
+ --tag "${IMAGE}:${VERSION}-pg${PG_MAJOR}"
+ --tag "${IMAGE}:pg${PG_MAJOR}"
+ )
+ if [[ "$PG_MAJOR" == 18 ]]; then
+ tags+=(--tag "${IMAGE}:latest")
+ fi
+ docker buildx imagetools create "${tags[@]}" \
+ "${IMAGE}:${GITHUB_SHA::12}-pg${PG_MAJOR}-amd64" \
+ "${IMAGE}:${GITHUB_SHA::12}-pg${PG_MAJOR}-arm64"
diff --git a/Cargo.lock b/Cargo.lock
index 7a07a50..634d601 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -108,7 +108,7 @@ dependencies = [
[[package]]
name = "boldi-vigna"
-version = "1.0.4"
+version = "1.0.6"
dependencies = [
"proptest",
"rustc-hash",
@@ -178,9 +178,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.4.6"
+version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
+checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [
"find-msvc-tools",
"shlex 2.0.1",
@@ -340,9 +340,9 @@ dependencies = [
[[package]]
name = "ctutils"
-version = "0.4.2"
+version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [
"cmov",
]
@@ -382,9 +382,9 @@ dependencies = [
[[package]]
name = "either"
-version = "1.18.0"
+version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emojis"
@@ -397,13 +397,12 @@ dependencies = [
[[package]]
name = "encoding_rs"
-version = "0.8.41"
+version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"
+checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
- "multiversion",
"multiversion_no_op",
"rustversion",
"scopeguard",
@@ -471,9 +470,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
-version = "0.1.12"
+version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
@@ -767,9 +766,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
-version = "0.3.105"
+version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
@@ -778,9 +777,9 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.189"
+version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libloading"
@@ -794,9 +793,9 @@ dependencies = [
[[package]]
name = "libredox"
-version = "0.1.24"
+version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a"
+checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"libc",
]
@@ -852,36 +851,15 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
-version = "1.2.3"
+version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys",
]
-[[package]]
-name = "multiversion"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"
-dependencies = [
- "multiversion-macros",
-]
-
-[[package]]
-name = "multiversion-macros"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"
-dependencies = [
- "proc-macro2",
- "quote",
- "rustversion",
- "syn 3.0.6",
-]
-
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -918,9 +896,9 @@ dependencies = [
[[package]]
name = "objc2"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -1038,9 +1016,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
-version = "2.9.1"
+version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
+checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
dependencies = [
"memchr",
"ucd-trie",
@@ -1048,9 +1026,9 @@ dependencies = [
[[package]]
name = "pest_derive"
-version = "2.9.1"
+version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
+checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
dependencies = [
"pest",
"pest_generator",
@@ -1058,9 +1036,9 @@ dependencies = [
[[package]]
name = "pest_generator"
-version = "2.9.1"
+version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
+checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
dependencies = [
"pest",
"pest_meta",
@@ -1071,9 +1049,9 @@ dependencies = [
[[package]]
name = "pest_meta"
-version = "2.9.1"
+version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
+checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
dependencies = [
"pest",
]
@@ -1206,7 +1184,7 @@ dependencies = [
"pgrx-macros",
"pgrx-pg-config",
"postgres",
- "rand 0.10.2",
+ "rand 0.10.3",
"regex",
"shlex 2.0.1",
"sysinfo",
@@ -1285,7 +1263,7 @@ dependencies = [
"hmac",
"md-5",
"memchr",
- "rand 0.10.2",
+ "rand 0.10.3",
"sha2",
"stringprep",
]
@@ -1392,9 +1370,9 @@ dependencies = [
[[package]]
name = "rand"
-version = "0.10.2"
+version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
@@ -1644,9 +1622,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
-version = "1.0.3"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "slab"
@@ -1656,9 +1634,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
-version = "1.16.1"
+version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "socket2"
@@ -1765,18 +1743,18 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.20"
+version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
-version = "2.0.20"
+version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
@@ -1785,7 +1763,7 @@ dependencies = [
[[package]]
name = "tin"
-version = "1.0.4"
+version = "1.0.6"
dependencies = [
"boldi-vigna",
"pgrx",
@@ -1799,7 +1777,7 @@ dependencies = [
[[package]]
name = "tinql"
-version = "1.0.4"
+version = "1.0.6"
dependencies = [
"boldi-vigna",
"pest",
@@ -1838,7 +1816,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokenizer"
-version = "1.0.4"
+version = "1.0.6"
dependencies = [
"emojis",
"proptest",
@@ -1852,9 +1830,9 @@ dependencies = [
[[package]]
name = "tokio"
-version = "1.53.1"
+version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
@@ -1883,7 +1861,7 @@ dependencies = [
"pin-project-lite",
"postgres-protocol",
"postgres-types",
- "rand 0.10.2",
+ "rand 0.10.3",
"socket2",
"tokio",
"tokio-util",
@@ -2049,9 +2027,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
-version = "1.26.1"
+version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -2112,9 +2090,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.128"
+version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
@@ -2125,9 +2103,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.128"
+version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2135,9 +2113,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.128"
+version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -2148,18 +2126,18 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.128"
+version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
-version = "0.3.105"
+version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -2374,9 +2352,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
@@ -2386,18 +2364,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.57"
+version = "0.8.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
+checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.57"
+version = "0.8.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
+checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
dependencies = [
"proc-macro2",
"quote",
diff --git a/Cargo.toml b/Cargo.toml
index cef84e9..0c22efe 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "3"
members = ["boldi-vigna", "postgres", "tinql", "tokenizer"]
[workspace.package]
-version = "1.0.4"
+version = "1.0.6"
edition = "2024"
authors = ["PlanetScale"]
license = "AGPL-3.0-or-later"
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b19e653
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,72 @@
+# syntax=docker/dockerfile:1
+ARG PG_MAJOR=18
+
+# The builder stage has the Rust toolchain, cargo-pgrx, and the PGDG
+# Postgres headers, but no Lead source. CI runs the tests in it as the
+# postgres user (Postgres refuses to run as root), so the directories that
+# cargo and `cargo pgrx test` write to are world-writable.
+FROM postgres:${PG_MAJOR}-trixie AS builder
+ARG PG_MAJOR
+ENV DEBIAN_FRONTEND=noninteractive \
+ RUSTUP_HOME=/usr/local/rustup \
+ CARGO_HOME=/usr/local/cargo \
+ PGRX_HOME=/usr/local/pgrx \
+ USER=postgres \
+ CARGO_NET_RETRY=10 \
+ CARGO_HTTP_MULTIPLEXING=false \
+ PATH=/usr/local/cargo/bin:$PATH \
+ PG_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config
+
+RUN apt-get update \
+ && apt-get install --yes --no-install-recommends \
+ build-essential \
+ ca-certificates \
+ clang \
+ curl \
+ git \
+ libclang-dev \
+ libssl-dev \
+ pkg-config \
+ postgresql-server-dev-${PG_MAJOR} \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /src
+COPY rust-toolchain.toml .
+RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
+ | sh -s -- -y --profile minimal --default-toolchain none \
+ && rustup toolchain install \
+ && rustup component add clippy
+
+RUN cargo install cargo-pgrx --version 0.19.1 --locked \
+ && cargo pgrx init --pg${PG_MAJOR}=${PG_CONFIG} \
+ && rm -rf "$CARGO_HOME/registry" \
+ && chmod -R a+rwX "$CARGO_HOME" "$PGRX_HOME" \
+ "$(${PG_CONFIG} --pkglibdir)" "$(${PG_CONFIG} --sharedir)/extension"
+
+FROM builder AS package
+ARG PG_MAJOR
+COPY . .
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+ --mount=type=cache,target=/src/target \
+ cargo pgrx package --debug --package tin \
+ --no-default-features --features pg${PG_MAJOR} \
+ --pg-config ${PG_CONFIG} --out-dir /out
+
+# CI replaces this stage with the files from its own `cargo pgrx package`
+# run, using `--build-context artifacts=
`, and then skips the package
+# stage.
+FROM scratch AS artifacts
+COPY --from=package /out/ /
+
+FROM postgres:${PG_MAJOR}-trixie
+ARG PG_MAJOR
+
+LABEL org.opencontainers.image.title="Lead" \
+ org.opencontainers.image.description="PostgreSQL ${PG_MAJOR} with the Lead extension (debug build, for development and CI, not production)" \
+ org.opencontainers.image.source="https://github.com/planetscale/lead" \
+ org.opencontainers.image.licenses="AGPL-3.0-or-later"
+
+COPY --from=artifacts / /
+COPY docker/initdb-tin.sql /docker-entrypoint-initdb.d/10-tin.sql
+
+EXPOSE 5432
diff --git a/README.md b/README.md
index 2ba30da..8193782 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,30 @@ cargo pgrx run pg18 --package tin
Then run `CREATE EXTENSION tin` in the database. Lead loads on demand and does not require `shared_preload_libraries` or `session_preload_libraries`.
+## Docker
+
+The `Dockerfile` builds an image of the official Postgres image plus Lead. It is a debug build on purpose, with debug assertions and full stack traces, for development and CI. Do not use it in production.
+
+```sh
+docker build --build-arg PG_MAJOR=18 -t lead:pg18 .
+```
+
+`PG_MAJOR` can be 17 or 18. To build for both architectures, use buildx:
+
+```sh
+docker buildx build --platform linux/amd64,linux/arm64 --build-arg PG_MAJOR=18 -t lead:pg18 .
+```
+
+Run the image like the official Postgres image. It runs `CREATE EXTENSION tin` in the default database on first start:
+
+```sh
+docker run -d --name lead -p 5432:5432 -e POSTGRES_PASSWORD=secret lead:pg18
+psql postgresql://postgres:secret@localhost:5432/postgres
+docker exec -it lead psql -U postgres
+```
+
+Prebuilt images for amd64 and arm64 are on Docker Hub as `planetscale/lead:pg17` and `planetscale/lead:pg18` (also `latest`), and as `planetscale/lead:-pg`. Each push to `main` also publishes `planetscale/lead:-pg`, where `` is the first 12 characters of the commit hash, so you can pin an exact build.
+
## Compatibility boundary
Lead provides the `tin` access method, the `==>` operator, TINQL parsing, tokenizer and index reloptions (including the Snowball `stemmer` option), and the scoring functions `tin.score`, `tin.full_score`, `tin.max_score`, and `tin.score_inspect`, plus explicit and implicitly bound `tin.highlight` and `tin.highlight_ansi`. Postgres 17 and 18 are build targets. Search results are exact because the access method returns whole-page candidates and Postgres evaluates `==>` against each visible heap tuple, including expression and partial-index rechecks.
diff --git a/boldi-vigna/src/query.rs b/boldi-vigna/src/query.rs
index 0046a04..fd924fe 100644
--- a/boldi-vigna/src/query.rs
+++ b/boldi-vigna/src/query.rs
@@ -174,6 +174,40 @@ impl SpanQuery {
SpanQueryWithTerms { query: self, terms }
}
+ /// Calls `f` with the index of every `Term` leaf outside an excluded
+ /// operand, once per occurrence, left to right. An excluded operand
+ /// (`NotContaining`'s `little`, `NotContainedBy`'s `big`,
+ /// `NonOverlapping`'s `b`) only filters its sibling's spans, so no leaf
+ /// beneath it is visited.
+ pub fn for_each_positive_term(&self, f: &mut impl FnMut(usize)) {
+ match self {
+ SpanQuery::Empty => {}
+ SpanQuery::Term(i) => f(*i),
+ SpanQuery::Ordered(children)
+ | SpanQuery::Unordered(children)
+ | SpanQuery::Or(children) => {
+ for child in children {
+ child.for_each_positive_term(f);
+ }
+ }
+ SpanQuery::MaxGaps { inner, .. }
+ | SpanQuery::GapsInRange { inner, .. }
+ | SpanQuery::MaxWidth { inner, .. }
+ | SpanQuery::WithinPositions { inner, .. }
+ | SpanQuery::NotContaining { big: inner, .. }
+ | SpanQuery::NotContainedBy { little: inner, .. }
+ | SpanQuery::NonOverlapping { a: inner, .. } => inner.for_each_positive_term(f),
+ SpanQuery::Containing { big: a, little: b }
+ | SpanQuery::ContainedBy { little: a, big: b }
+ | SpanQuery::Overlapping { a, b }
+ | SpanQuery::Before { a, b }
+ | SpanQuery::After { a, b } => {
+ a.for_each_positive_term(f);
+ b.for_each_positive_term(f);
+ }
+ }
+ }
+
/// Maximum term index referenced anywhere in this query tree.
pub fn max_term_index(&self) -> Option {
match self {
@@ -199,6 +233,30 @@ impl SpanQuery {
}
}
+ /// Whether an `Unordered` sits anywhere in this query tree: the operator
+ /// whose gap-read form walks every child order (`NodeState::compile_gaps`).
+ pub(crate) fn holds_unordered(&self) -> bool {
+ match self {
+ SpanQuery::Unordered(_) => true,
+ SpanQuery::Empty | SpanQuery::Term(_) => false,
+ SpanQuery::Ordered(children) | SpanQuery::Or(children) => {
+ children.iter().any(SpanQuery::holds_unordered)
+ }
+ SpanQuery::MaxGaps { inner, .. }
+ | SpanQuery::GapsInRange { inner, .. }
+ | SpanQuery::MaxWidth { inner, .. }
+ | SpanQuery::WithinPositions { inner, .. } => inner.holds_unordered(),
+ SpanQuery::Containing { big: a, little: b }
+ | SpanQuery::NotContaining { big: a, little: b }
+ | SpanQuery::ContainedBy { little: a, big: b }
+ | SpanQuery::NotContainedBy { little: a, big: b }
+ | SpanQuery::Overlapping { a, b }
+ | SpanQuery::NonOverlapping { a, b }
+ | SpanQuery::Before { a, b }
+ | SpanQuery::After { a, b } => a.holds_unordered() || b.holds_unordered(),
+ }
+ }
+
/// Number of distinct term indices needed (max_term_index + 1, or 0 if no terms).
pub fn num_terms(&self) -> usize {
self.max_term_index().map_or(0, |i| i + 1)
@@ -292,6 +350,31 @@ mod tests {
assert!(q.validate(1).is_err());
}
+ #[test]
+ fn positive_terms_repeat_and_skip_excluded_operands() {
+ let mut leaves = Vec::new();
+ let q = SpanQuery::NotContaining {
+ big: Box::new(SpanQuery::phrase([0, 1, 0])),
+ little: Box::new(SpanQuery::Term(2)),
+ };
+ q.for_each_positive_term(&mut |idx| leaves.push(idx));
+ assert_eq!(leaves, [0, 1, 0]);
+
+ leaves.clear();
+ let q = SpanQuery::Containing {
+ big: Box::new(SpanQuery::NonOverlapping {
+ a: Box::new(SpanQuery::Term(0)),
+ b: Box::new(SpanQuery::Term(1)),
+ }),
+ little: Box::new(SpanQuery::NotContainedBy {
+ little: Box::new(SpanQuery::Or(vec![SpanQuery::Term(0), SpanQuery::Term(2)])),
+ big: Box::new(SpanQuery::Term(3)),
+ }),
+ };
+ q.for_each_positive_term(&mut |idx| leaves.push(idx));
+ assert_eq!(leaves, [0, 0, 2]);
+ }
+
#[test]
fn num_terms_nested() {
let q = SpanQuery::Ordered(vec![
diff --git a/boldi-vigna/src/state/mod.rs b/boldi-vigna/src/state/mod.rs
index 28f4a2e..7d2b7fe 100644
--- a/boldi-vigna/src/state/mod.rs
+++ b/boldi-vigna/src/state/mod.rs
@@ -20,9 +20,13 @@ mod filter;
mod ordered;
mod phrase;
mod relation;
+mod shared;
mod term;
mod unordered;
+use std::cell::RefCell;
+use std::rc::Rc;
+
use crate::interval::Interval;
use crate::positions::TermPositions;
use crate::query::SpanQuery;
@@ -35,9 +39,24 @@ pub(crate) use self::filter::{
pub(crate) use self::ordered::OrderedState;
pub(crate) use self::phrase::PhraseState;
pub(crate) use self::relation::RelationState;
+pub(crate) use self::shared::{SharedSource, SharedState};
pub(crate) use self::term::TermState;
pub(crate) use self::unordered::UnorderedState;
+// States this thread compiled and reset: the unit tests' size pins.
+#[cfg(test)]
+thread_local! {
+ static STATES_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) };
+ static STATES_RESET: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// Counts one compiled state (unit tests only).
+#[inline(always)]
+fn built() {
+ #[cfg(test)]
+ STATES_BUILT.with(|built| built.set(built.get() + 1));
+}
+
/// Internal state machine for a single node in the operator tree.
/// Mirrors the structure of `SpanQuery` but carries mutable iteration state.
pub(crate) enum NodeState {
@@ -52,81 +71,91 @@ pub(crate) enum NodeState {
MaxWidth(MaxWidthState),
WithinPositions(WithinPositionsState),
Relation(RelationState),
+ Shared(SharedState),
}
impl NodeState {
/// Compile a `SpanQuery` tree into a `NodeState` tree.
pub(crate) fn compile(query: &SpanQuery) -> Self {
+ Self::compile_gaps(query, false)
+ }
+
+ /// Compiles `query`; `gaps_read` says an enclosing gap filter reads this
+ /// node's gap count. A gap filter passes no overlapping sub-intervals, so a
+ /// gap-read `Unordered` compiles to its disjoint form: one `Ordered` per
+ /// distinct child order, unioned. Building the overlapping form there would
+ /// let an overlapping minimal interval (two children on one position)
+ /// shadow the disjoint spans that contain it, and the filter would then
+ /// reject them all.
+ fn compile_gaps(query: &SpanQuery, gaps_read: bool) -> Self {
+ built();
if let Some(term_indices) = exact_phrase_term_indices(query) {
return NodeState::Phrase(PhraseState::new(term_indices));
}
+ let compile = |q: &SpanQuery| NodeState::compile_gaps(q, false);
+ let forward = |q: &SpanQuery| NodeState::compile_gaps(q, gaps_read);
+ let filtered = |q: &SpanQuery| NodeState::compile_gaps(q, true);
match query {
SpanQuery::Empty => NodeState::Empty(EmptyState::new()),
SpanQuery::Term(i) => NodeState::Term(TermState::new(*i)),
SpanQuery::Ordered(children) => {
- let child_states: Vec<_> = children.iter().map(NodeState::compile).collect();
- NodeState::Ordered(OrderedState::new(child_states))
+ NodeState::Ordered(OrderedState::new(children.iter().map(compile).collect()))
}
+ SpanQuery::Unordered(children) if gaps_read => disjoint_unordered(children),
SpanQuery::Unordered(children) => {
NodeState::Unordered(UnorderedState::compile(children))
}
SpanQuery::Or(children) => {
- let child_states: Vec<_> = children.iter().map(NodeState::compile).collect();
- NodeState::Or(OrState::new(child_states))
+ NodeState::Or(OrState::new(children.iter().map(forward).collect()))
}
SpanQuery::MaxGaps { max_gaps, inner } => {
- NodeState::MaxGaps(MaxGapsState::new(NodeState::compile(inner), *max_gaps))
+ NodeState::MaxGaps(MaxGapsState::new(filtered(inner), *max_gaps))
}
SpanQuery::GapsInRange {
min_gaps,
max_gaps,
inner,
- } => NodeState::GapsInRange(GapsInRangeState::new(
- NodeState::compile(inner),
- *min_gaps,
- *max_gaps,
- )),
+ } => {
+ NodeState::GapsInRange(GapsInRangeState::new(filtered(inner), *min_gaps, *max_gaps))
+ }
SpanQuery::MaxWidth { max_width, inner } => {
- NodeState::MaxWidth(MaxWidthState::new(NodeState::compile(inner), *max_width))
+ NodeState::MaxWidth(MaxWidthState::new(forward(inner), *max_width))
}
- SpanQuery::WithinPositions { inner, lo, hi } => NodeState::WithinPositions(
- WithinPositionsState::new(NodeState::compile(inner), *lo, *hi),
- ),
- SpanQuery::Containing { big, little } => NodeState::Relation(
- RelationState::containing(NodeState::compile(big), NodeState::compile(little)),
- ),
- SpanQuery::ContainedBy { little, big } => NodeState::Relation(
- RelationState::contained_by(NodeState::compile(little), NodeState::compile(big)),
- ),
- SpanQuery::NotContaining { big, little } => NodeState::Relation(
- RelationState::not_containing(NodeState::compile(big), NodeState::compile(little)),
- ),
- SpanQuery::NotContainedBy { little, big } => {
- NodeState::Relation(RelationState::not_contained_by(
- NodeState::compile(little),
- NodeState::compile(big),
- ))
+ SpanQuery::WithinPositions { inner, lo, hi } => {
+ NodeState::WithinPositions(WithinPositionsState::new(forward(inner), *lo, *hi))
+ }
+ // A relation reports its first operand's gaps.
+ SpanQuery::Containing { big, little } => {
+ NodeState::Relation(RelationState::containing(forward(big), compile(little)))
+ }
+ SpanQuery::ContainedBy { little, big } => {
+ NodeState::Relation(RelationState::contained_by(forward(little), compile(big)))
+ }
+ SpanQuery::NotContaining { big, little } => {
+ NodeState::Relation(RelationState::not_containing(forward(big), compile(little)))
}
- SpanQuery::Overlapping { a, b } => NodeState::Relation(RelationState::overlapping(
- NodeState::compile(a),
- NodeState::compile(b),
- )),
- SpanQuery::NonOverlapping { a, b } => NodeState::Relation(
- RelationState::non_overlapping(NodeState::compile(a), NodeState::compile(b)),
+ SpanQuery::NotContainedBy { little, big } => NodeState::Relation(
+ RelationState::not_contained_by(forward(little), compile(big)),
),
- SpanQuery::Before { a, b } => NodeState::Relation(RelationState::before(
- NodeState::compile(a),
- NodeState::compile(b),
- )),
- SpanQuery::After { a, b } => NodeState::Relation(RelationState::after(
- NodeState::compile(a),
- NodeState::compile(b),
- )),
+ SpanQuery::Overlapping { a, b } => {
+ NodeState::Relation(RelationState::overlapping(forward(a), compile(b)))
+ }
+ SpanQuery::NonOverlapping { a, b } => {
+ NodeState::Relation(RelationState::non_overlapping(forward(a), compile(b)))
+ }
+ SpanQuery::Before { a, b } => {
+ NodeState::Relation(RelationState::before(forward(a), compile(b)))
+ }
+ SpanQuery::After { a, b } => {
+ NodeState::Relation(RelationState::after(forward(a), compile(b)))
+ }
}
}
pub(crate) fn reset(&mut self) {
+ #[cfg(test)]
+ STATES_RESET.with(|reset| reset.set(reset.get() + 1));
match self {
NodeState::Empty(s) => s.reset(),
NodeState::Term(s) => s.reset(),
@@ -139,6 +168,7 @@ impl NodeState {
NodeState::MaxWidth(s) => s.reset(),
NodeState::WithinPositions(s) => s.reset(),
NodeState::Relation(s) => s.reset(),
+ NodeState::Shared(s) => s.reset(),
}
}
@@ -155,6 +185,7 @@ impl NodeState {
NodeState::MaxWidth(s) => s.next_interval(positions),
NodeState::WithinPositions(s) => s.next_interval(positions),
NodeState::Relation(s) => s.next_interval(positions),
+ NodeState::Shared(s) => s.next_interval(positions),
}
}
@@ -172,7 +203,78 @@ impl NodeState {
NodeState::MaxWidth(s) => s.gaps(),
NodeState::WithinPositions(s) => s.gaps(),
NodeState::Relation(s) => s.gaps(),
+ NodeState::Shared(s) => s.gaps(),
+ }
+ }
+}
+
+/// Disjoint intervals are strictly ordered, so the minimal spans holding one
+/// disjoint interval per child are the union over child orders of `Ordered`,
+/// which consumes disjoint intervals by construction. Identical children give
+/// identical orders; each distinct order compiles once. tinql's NEAR has two
+/// children: at most two ordered walks. A child holding an Unordered compiles
+/// once, and each walk reads it through a replay reader of its own
+/// ([`SharedState`]): compiled once per walk, a chain of NEARs doubled at every
+/// level, 2^65 states for 66 terms (wp-m4.9-gate.md §U6.13). Any other child,
+/// a term, a phrase or an OR of them, compiles once per walk, as before.
+fn disjoint_unordered(children: &[SpanQuery]) -> NodeState {
+ let mut orders = Vec::new();
+ let mut remaining: Vec<&SpanQuery> = children.iter().collect();
+ child_orders(&mut Vec::new(), &mut remaining, &mut orders);
+ let shared: Vec