diff --git a/docs/architecture docs/adrs/ADR 210 - Index-type registry.md b/docs/architecture docs/adrs/ADR 210 - Index-type registry.md new file mode 100644 index 0000000000..bf6b80cfb9 --- /dev/null +++ b/docs/architecture docs/adrs/ADR 210 - Index-type registry.md @@ -0,0 +1,103 @@ +# ADR 210 — Index-type registry + +**Status:** Proposed +**Date:** 2026-05-06 +**Domain:** SQL family — contract authoring, validation, schema migration + +**Spec:** [projects/index-type-registry/spec.md](../../../projects/index-type-registry/spec.md) + +## Context + +The SQL family supports `@@index` in PSL and `constraints.index(...)` in TS authoring, but both surfaces only carry columns and an optional name today. The Contract IR has placeholder fields named `using` and `config` that no active codepath populates: the PSL parser ignores them, the schema IR omits them, and the Postgres adapter emits a plain `CREATE INDEX` without an index method or storage parameters. The placeholder names also misalign with Prisma's `@@index(type:)` precedent. + +Extensions that need a non-default index method work around this with bespoke per-extension helpers that hand-build an IR node carrying the right `using` value and a typed payload. There is no central concept that knows which index types exist, what their option shapes look like, or how those options should be rendered. The asymmetry creates three concrete problems: authors can put any string into the type slot and any payload into the options slot, and the contract round-trips silently — the divergence only surfaces at DDL time, if at all; extension authors duplicate the typing-and-validation work per index type; and there is no clean extension point for users to add their own index types without forking the schema validator. + +## Decision + +Introduce an **index-type registry** in the SQL family's `1-core/contract` package, alongside `IndexSchema`. An entry is keyed by a `type` literal and carries a single piece of data: an arktype validator describing the entry's `options` shape. The registry is the only place that knows which `type` values are legal in a contract. Five aligned choices fall out of treating the registry as the single source of truth. + +### 1. Field-name convention: `type` and `options` + +The IR fields are renamed `using` → `type` and `config` → `options` across `IndexDef`, the authoring `IndexNode`/`IndexOptions`/`IndexConstraint`, the schema validator, the schema IR, and contract lowering — in lockstep, with no compatibility shim. The names match Prisma's `@@index(type:)` precedent and stay dialect-neutral: the Postgres-specific keywords `USING` and `WITH` live exclusively inside the renderer and never appear in the contract vocabulary. + +The lockstep rename is justified by the fields being inert today. The only in-repo writers of those names are the bespoke helpers being replaced as part of the same change. There is no observable behaviour to preserve, so a shim would only widen the surface area without protecting any caller. + +### 2. Factory builder declares the type literal once + +Index types are contributed via a factory builder. The call site names the literal, attaches the arktype validator for `options`, and the builder exposes both a runtime registration helper and a derived TypeScript type extracted by `typeof` from the builder's output. Because the validator is constructed via the repo's `type.declare().type(...)` pattern, the runtime shape is constrained at compile time to match the canonical TS type. Drift between the two halves becomes a TypeScript error at the place where the entry is declared, rather than a runtime surprise downstream. + +The single-declaration-site property is the load-bearing one. It is what lets extension authors add an entry without touching framework code, and what lets the validator and the adapter share a single canonical shape. + +### 3. Extension-pack threading via the registration value + +Each pack stores its registered index types in a single field whose value is the read-only output of the factory builder. The same value carries both the runtime entry list and a TypeScript-only phantom carrying the map of literal → option shape; the builder type extends a read-only registration interface that exposes only those two fields, so the pack can't be misused as a mutable registry. The contract-definition pipeline reads each pack's registration, intersects the per-pack maps, and threads the merged map into the contract authoring surface. When `constraints.index(cols.x, { type: 'X' })` is authored, `options` is narrowed against the merged map's entry for `'X'`; an unregistered `type` literal is a compile error. + +No global `declare module` augmentation is used; the merged set is purely a function of the packs attached to a given contract. This avoids cross-contract coupling — two contracts with different pack lists in the same workspace see different valid `type` sets, as they should. + +### 4. Validation seam at the ContractIR → Contract boundary + +Registry-aware validation runs at the single point where both authoring surfaces converge: the lowering function that turns a `ContractDefinition` (the in-memory IR) into a final `Contract`. The TS authoring chain (`defineContract({...})` → `buildContractFromDsl` → `buildSqlContractFromDefinition`) and the PSL interpreter (`interpretPslDocumentToSqlContract`, which constructs a `ContractDefinition` from the PSL AST and calls the same lowering) hit the same seam. The lowering function builds a per-contract registry from the definition's attached packs, walks every index in the storage IR, and rejects unregistered `type` values, options that fail the registered validator, and `options` set without `type`. Errors fire at authoring time — at the line that wrote the offending model — not when a downstream consumer loads the emitted `contract.json`. + +Validating at the lowering boundary keeps the runtime path simple. The framework's `validateContract` (consumed by runtime drivers like `postgres({contractJson})`) still does structural and referential validation, but it does not need a registry: by the time a contract reaches a driver, the lowering already validated index types against the packs in scope. + +Strictness — whether unknown keys in `options` are rejected — is a property of the validator each registrant constructs, not something the framework imposes on top. arktype is loose-by-default; a registrant who wants extra-key rejection opts in when building their option-shape validator. The recommendation is to do so: an entry's option shape is a contract between the registrant and the renderer, and an unrecognised key is much more likely to be a typo than a genuine extension point. Silently dropping it at validate time would mask it from authors and produce surprising DDL. + +### 5. Single framework-owned renderer for `WITH (...)` + +The Postgres adapter's `createIndex` reads `type` and `options` directly from the validated IR and renders `CREATE INDEX ... USING ... WITH (key = literal, ...)`. There is **no per-entry rendering hook**. A single universal renderer formats `options` as `key = literal, ...`, using the adapter's existing scalar quoting and escaping helpers for strings, numbers, and booleans. + +Two consequences are worth naming. First: validators constrain `options` leaves to scalar types, so the universal renderer covers every entry — past, present, and future. Second: the absence of an extension-supplied rendering path means an extension author cannot accidentally introduce an unsafe rendering path. SQL injection risk is bounded to the framework-owned helpers, which already round-trip Postgres literals correctly elsewhere in the adapter. + +Index-IR changes that affect `columns`, `type`, or `options` are emitted by the migration planner as `DROP INDEX` followed by `CREATE INDEX`. Postgres has no `ALTER INDEX ... SET METHOD` for changing the index method, and option changes are inconsistent across `WITH` keys, so `ALTER` is the wrong primitive for these fields uniformly. + +## How matching, lookup, and dispatch compose + +When an extension pack declares its index types, the factory call site is the single point where the type literal is named. The builder produces a single value that carries both the runtime entry list — `(type, options-validator)` pairs ready to be aggregated — and a TypeScript-only phantom map of literal → option shape. The pack stores that value verbatim in its registration field; both halves stay in lockstep automatically. + +When a contract is defined, two things happen — strictly per-contract, with no global state. On the type side, the contract-definition pipeline walks the attached packs, reads each pack's registration, and intersects the per-pack maps. The merged map is what narrows `options` per-`type` for `constraints.index(...)`. Every contract sees only the packs it asked for. On the runtime side, contract assembly creates a fresh per-contract registry and calls `register` for each entry the attached packs contribute. A duplicate `type` across packs surfaces as a registration-time error naming the conflict; this is contract-level, not workspace-level. + +When the lowering builds the contract, it consults that contract's freshly-built registry. Lookup is by `type` literal; validation of `options` is one arktype invocation per index, against whichever shape the registrant constructed (loose or strict). The framework owns the Postgres-shaped renderer for `options`; per-entry validators have no rendering responsibility and no rendering surface area. + +When the Postgres adapter renders DDL, it consults only the validated IR. The renderer never re-invokes the registry: by the time a node reaches the adapter, its `options` is already canonical. This keeps the adapter's surface narrow and means the registry's correctness needs to hold only at lowering time. + +## Alternatives considered + +**Per-entry rendering hooks.** Let each registered entry carry a function that turns its options into a string. Rejected on uniformity and security grounds. The repo already exposes safe scalar quoting helpers; an extension authoring its own renderer would either duplicate them or, worse, build SQL by string concatenation. The universal renderer is sufficient because validators constrain leaves to scalars. + +**`declare module` augmentation for index types.** A common pattern in TypeScript libraries: each pack augments a global type to add its entries. Rejected because it does not compose with our pack model — two contracts in the same workspace would see the union of all packs ever loaded, not just their own. Storing the per-pack registration value keeps the merged set scoped to each contract. + +**Capability gating per index type.** The capability system exists to negotiate runtime environment features (e.g. is a particular operation supported by this connection, this server version). It is not the right vocabulary for a design-time decision about whether a contract can name a given `type` value. The registry is the design-time vocabulary; capabilities are orthogonal. A registered entry does not assert that the database has the underlying server-side extension installed — that surfaces as a Postgres DDL error at apply time, which is the right behaviour. + +**Backward-compatibility shim for `using`/`config`.** Keep accepting the old field names alongside the new ones. Rejected because the fields are inert today (no PSL or TS surface populates them through any active codepath) and the only in-repo writers are being replaced as part of the same change. A shim would expand the validated IR shape with no caller to protect. + +**Closed-set identifier syntax in PSL (`type: BTree`).** Prisma's stable PSL uses identifier values for `@@index(type:)`. Rejected because our registry is open-ended by design — extension packs contribute new types — and a closed-set grammar would either need to be regenerated per workspace or fall through to the same string-typed argument anyway. PSL accepts a string-quoted `type` value, validated downstream against the merged registry just like the TS surface. + +## Consequences + +### Positive + +- An extension author adds a new index type by writing a single factory call and storing the resulting registration on their pack. The TS authoring surface, the runtime validator, and the Postgres DDL renderer all light up without touching framework code. +- Type narrowing of `options` per-`type` happens at the call site, against the exact set of packs the contract asked for. Unknown types, mistyped keys, and bad values are compile errors at the call site or runtime errors at validate time, not surprise DDL output. +- The IR vocabulary is dialect-neutral. The contract is portable across adapters even though the renderer is Postgres-shaped today. +- The validation surface is bounded to one registry lookup plus one arktype invocation per index — no measurable regression versus today's `IndexSchema`. + +### Negative + +- The IR rename touches a small but non-trivial set of in-repo call sites in lockstep. We accept this in exchange for not carrying a shim for fields that nothing populates. +- Future SQL adapters that don't share Postgres's `USING WITH (...)` shape would need their own rendering path if they ever want to read `type`/`options`. The IR vocabulary stays neutral; the renderer is per-adapter. +- Any change to an index's `type` or `options` rebuilds the index. This is an inherent property of how Postgres handles index method and `WITH`-key changes, not a regression introduced here. +- PSL must learn object-literal grammar for `options: { ... }`. V1 admits string literals as leaves only; booleans and numbers are deferred to the same follow-up that seeds built-in entries (which actually need them). + +## Non-goals + +- Built-in registry entries for `btree`, `hash`, `gin`, `gist`, `brin`, `spgist`. Tracked as a follow-up. V1 ships the mechanism; in-repo extensions that already needed a registry-shaped helper are migrated onto it in the same change. +- Boolean and number literals in PSL `options` payloads. V1 supports string-leaf only; the parser extension and the built-in-entry seeding ship together. +- Rendering paths for any future SQL adapter beyond Postgres. The IR vocabulary is neutral; the renderer is Postgres-shaped. +- Per-column index options (e.g. `gist`'s per-column operator classes). V1 carries `options` as a single object on the index, not per-column. +- `ALTER INDEX` paths for `type`/`options` changes. Always `DROP` + `CREATE`. +- Capability gating per index type. The registry is the design-time gate; runtime extension presence is verified by Postgres at apply time. + +## References + +- [ADR 117 — Extension capability keys](ADR%20117%20-%20Extension%20capability%20keys.md) — the orthogonal mechanism that index types are *not* +- [ADR 161 — Explicit foreign key constraint and index configuration](ADR%20161%20-%20Explicit%20foreign%20key%20constraint%20and%20index%20configuration.md) — neighbouring decision in the index/constraint area diff --git a/examples/paradedb-demo/.env.example b/examples/paradedb-demo/.env.example new file mode 100644 index 0000000000..a8066940d6 --- /dev/null +++ b/examples/paradedb-demo/.env.example @@ -0,0 +1 @@ +DATABASE_URL=postgres://postgres:postgres@localhost:5434/demo diff --git a/examples/paradedb-demo/README.md b/examples/paradedb-demo/README.md new file mode 100644 index 0000000000..e009b1f744 --- /dev/null +++ b/examples/paradedb-demo/README.md @@ -0,0 +1,38 @@ +# paradedb-demo + +End-to-end demo of `@prisma-next/extension-paradedb` against a live ParadeDB server in Docker. + +Exercises: + +- `paradeDbMatch(col, query)` / `paradeDbMatchAny` / `paradeDbMatchAll` / `paradeDbTerm` / `paradeDbPhrase` — the five match-mode operators (`@@@` / `|||` / `&&&` / `===` / `###`). +- `paradeDbScore(keyCol)` — BM25 relevance score (`pdb.score`). +- `paradeDbFuzzy` / `paradeDbBoost` / `paradeDbConst` / `paradeDbSlop` — typmod casts (`'q'::pdb.fuzzy(N)` etc.); compose into match operators. +- `paradeDbProximity(start).within(distance, term, { ordered? })…` — chained proximity (`##` / `##>`); composes through `paradeDbMatch`. +- Automatic `CREATE EXTENSION pg_search` via `databaseDependencies`. +- Automatic `CREATE INDEX ... USING bm25 (...) WITH (key_field='...')` via upstream's index-type registry. + +## Run it + +```bash +cp .env.example .env +pnpm docker:up +pnpm emit +pnpm db:init +pnpm seed +pnpm start -- match 'headphones' +pnpm start -- top 'laptop' 5 +pnpm start -- fuzzy 'laptp' 2 +pnpm start -- proximity 'wireless' 'keyboard' 3 +pnpm start -- proximity-chain 'cooling' '>1' 'fan' '>1' 'and' +pnpm start -- chain-demo +pnpm start -- mode-tour +pnpm start -- cast-demo +``` + +`pnpm db:init` produces the BM25 index directly from the `constraints.index([...], { type: 'bm25', options: { key_field: 'id' } })` declaration in `prisma/contract.ts`. + +Teardown: + +```bash +pnpm docker:down +``` diff --git a/examples/paradedb-demo/biome.jsonc b/examples/paradedb-demo/biome.jsonc new file mode 100644 index 0000000000..b8994a7330 --- /dev/null +++ b/examples/paradedb-demo/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "extends": "//" +} diff --git a/examples/paradedb-demo/docker-compose.yaml b/examples/paradedb-demo/docker-compose.yaml new file mode 100644 index 0000000000..5bf0923664 --- /dev/null +++ b/examples/paradedb-demo/docker-compose.yaml @@ -0,0 +1,13 @@ +services: + paradedb: + image: paradedb/paradedb:latest + ports: + - "5434:5432" + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: postgres + volumes: + - ./init:/docker-entrypoint-initdb.d:ro + tmpfs: + - /var/lib/postgresql diff --git a/examples/paradedb-demo/init/01-create-demo-db.sql b/examples/paradedb-demo/init/01-create-demo-db.sql new file mode 100644 index 0000000000..34ced07306 --- /dev/null +++ b/examples/paradedb-demo/init/01-create-demo-db.sql @@ -0,0 +1,2 @@ +-- Avoids the paradedb image's PostGIS/tiger tables preloaded into `postgres`. +CREATE DATABASE demo; diff --git a/examples/paradedb-demo/package.json b/examples/paradedb-demo/package.json new file mode 100644 index 0000000000..b69ca0edbb --- /dev/null +++ b/examples/paradedb-demo/package.json @@ -0,0 +1,48 @@ +{ + "name": "paradedb-demo", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "emit": "prisma-next contract emit", + "db:init": "prisma-next db init", + "db:drop": "tsx scripts/drop-db.ts", + "seed": "tsx scripts/seed.ts", + "start": "tsx src/main.ts", + "docker:up": "docker compose up -d", + "docker:down": "docker compose down -v", + "test": "vitest run", + "typecheck": "tsc --project tsconfig.json --noEmit", + "lint": "biome check . --error-on-warnings" + }, + "dependencies": { + "@prisma-next/adapter-postgres": "workspace:*", + "@prisma-next/contract": "workspace:*", + "@prisma-next/driver-postgres": "workspace:*", + "@prisma-next/extension-paradedb": "workspace:*", + "@prisma-next/family-sql": "workspace:*", + "@prisma-next/postgres": "workspace:*", + "@prisma-next/sql-builder": "workspace:*", + "@prisma-next/sql-contract": "workspace:*", + "@prisma-next/sql-contract-ts": "workspace:*", + "@prisma-next/sql-runtime": "workspace:*", + "@prisma-next/target-postgres": "workspace:*", + "arktype": "^2.1.29", + "dotenv": "^16.4.5", + "pg": "catalog:" + }, + "devDependencies": { + "@prisma-next/cli": "workspace:*", + "@prisma-next/emitter": "workspace:*", + "@prisma-next/sql-contract-emitter": "workspace:*", + "@prisma-next/test-utils": "workspace:*", + "@prisma-next/tsconfig": "workspace:*", + "@types/node": "catalog:", + "@types/pg": "catalog:", + "tsx": "^4.19.2", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/paradedb-demo/prisma-next.config.ts b/examples/paradedb-demo/prisma-next.config.ts new file mode 100644 index 0000000000..365c6496fc --- /dev/null +++ b/examples/paradedb-demo/prisma-next.config.ts @@ -0,0 +1,22 @@ +import 'dotenv/config'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; +import { defineConfig } from '@prisma-next/cli/config-types'; +import postgresDriver from '@prisma-next/driver-postgres/control'; +import paradedb from '@prisma-next/extension-paradedb/control'; +import sql from '@prisma-next/family-sql/control'; +import { typescriptContract } from '@prisma-next/sql-contract-ts/config-types'; +import postgres from '@prisma-next/target-postgres/control'; +import { contract } from './prisma/contract'; + +export default defineConfig({ + family: sql, + target: postgres, + driver: postgresDriver, + adapter: postgresAdapter, + extensionPacks: [paradedb], + contract: typescriptContract(contract, 'src/prisma/contract.json'), + db: { + // biome-ignore lint/style/noNonNullAssertion: loaded from .env + connection: process.env['DATABASE_URL']!, + }, +}); diff --git a/examples/paradedb-demo/prisma/contract.ts b/examples/paradedb-demo/prisma/contract.ts new file mode 100644 index 0000000000..aa4c04b317 --- /dev/null +++ b/examples/paradedb-demo/prisma/contract.ts @@ -0,0 +1,45 @@ +import { int4Column, textColumn } from '@prisma-next/adapter-postgres/column-types'; +import paradedb from '@prisma-next/extension-paradedb/pack'; +import sqlFamily from '@prisma-next/family-sql/pack'; +import { defineContract } from '@prisma-next/sql-contract-ts/contract-builder'; +import postgresPack from '@prisma-next/target-postgres/pack'; + +export const contract = defineContract( + { + family: sqlFamily, + target: postgresPack, + extensionPacks: { paradedb }, + capabilities: { + postgres: { + lateral: true, + returning: true, + 'paradedb/bm25': true, + }, + }, + }, + ({ field, model }) => { + const Item = model('Item', { + fields: { + id: field.column(int4Column).id(), + description: field.column(textColumn), + category: field.column(textColumn), + rating: field.column(int4Column), + }, + }); + + return { + models: { + Item: Item.sql(({ cols, constraints }) => ({ + table: 'item', + indexes: [ + constraints.index([cols.id, cols.description, cols.category, cols.rating], { + type: 'bm25', + options: { key_field: 'id' }, + name: 'item_bm25_idx', + }), + ], + })), + }, + }; + }, +); diff --git a/examples/paradedb-demo/scripts/drop-db.ts b/examples/paradedb-demo/scripts/drop-db.ts new file mode 100644 index 0000000000..0038af3662 --- /dev/null +++ b/examples/paradedb-demo/scripts/drop-db.ts @@ -0,0 +1,33 @@ +import 'dotenv/config'; +import pg from 'pg'; + +async function dropDatabase() { + const databaseUrl = process.env['DATABASE_URL']; + if (!databaseUrl) { + console.error('DATABASE_URL environment variable is required'); + process.exit(1); + } + + const client = new pg.Client({ connectionString: databaseUrl }); + + try { + await client.connect(); + console.log('Connected to database'); + + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + console.log('✔ Dropped and recreated public schema'); + + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + console.log('✔ Dropped prisma_contract schema'); + + console.log('\nDatabase reset complete'); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } finally { + await client.end(); + } +} + +dropDatabase(); diff --git a/examples/paradedb-demo/scripts/seed.ts b/examples/paradedb-demo/scripts/seed.ts new file mode 100644 index 0000000000..4be59b97ae --- /dev/null +++ b/examples/paradedb-demo/scripts/seed.ts @@ -0,0 +1,75 @@ +import 'dotenv/config'; +import { loadAppConfig } from '../src/app-config'; +import { db } from '../src/prisma/db'; + +const items: ReadonlyArray<{ + readonly id: number; + readonly description: string; + readonly category: string; + readonly rating: number; +}> = [ + { + id: 1, + description: 'Ergonomic mesh office chair with lumbar support', + category: 'furniture', + rating: 5, + }, + { + id: 2, + description: 'Wireless mechanical keyboard with RGB lighting', + category: 'electronics', + rating: 4, + }, + { + id: 3, + description: 'Stainless steel electric kettle 1.7 liters', + category: 'kitchen', + rating: 5, + }, + { + id: 4, + description: 'Noise cancelling over-ear headphones', + category: 'electronics', + rating: 5, + }, + { + id: 5, + description: 'Ultralight backpacking tent for two people', + category: 'outdoors', + rating: 4, + }, + { + id: 6, + description: 'Laptop stand with cooling fan and USB hub', + category: 'electronics', + rating: 3, + }, + { id: 7, description: 'Cast iron skillet 12 inch pre-seasoned', category: 'kitchen', rating: 5 }, + { id: 8, description: 'Running shoes with carbon plate midsole', category: 'sports', rating: 4 }, + { id: 9, description: 'Standing desk converter for laptops', category: 'furniture', rating: 4 }, + { + id: 10, + description: 'Insulated water bottle keeps cold 24 hours', + category: 'outdoors', + rating: 5, + }, +]; + +async function main() { + const { databaseUrl } = loadAppConfig(); + const runtime = await db.connect({ url: databaseUrl }); + + try { + for (const item of items) { + await runtime.execute(db.sql.item.insert(item).build()); + } + console.log(`Seeded ${items.length} items`); + } finally { + await runtime.close(); + } +} + +main().catch((e) => { + console.error('Error seeding database:', e); + process.exitCode = 1; +}); diff --git a/examples/paradedb-demo/src/app-config.ts b/examples/paradedb-demo/src/app-config.ts new file mode 100644 index 0000000000..90a19cba82 --- /dev/null +++ b/examples/paradedb-demo/src/app-config.ts @@ -0,0 +1,16 @@ +import { type as arktype } from 'arktype'; + +const appConfigSchema = arktype({ + DATABASE_URL: 'string', +}); + +export function loadAppConfig() { + const result = appConfigSchema({ + DATABASE_URL: process.env['DATABASE_URL'], + }); + if (result instanceof arktype.errors) { + const message = result.map((p: { message: string }) => p.message).join('; '); + throw new Error(`Invalid app configuration: ${message}`); + } + return { databaseUrl: result.DATABASE_URL }; +} diff --git a/examples/paradedb-demo/src/main.ts b/examples/paradedb-demo/src/main.ts new file mode 100644 index 0000000000..74d7098474 --- /dev/null +++ b/examples/paradedb-demo/src/main.ts @@ -0,0 +1,116 @@ +import 'dotenv/config'; +import { loadAppConfig } from './app-config'; +import { db } from './prisma/db'; +import { bm25CastDemo } from './queries/bm25-cast-demo'; +import { bm25ChainDemo } from './queries/bm25-chain-demo'; +import { bm25Fuzzy } from './queries/bm25-fuzzy'; +import { bm25Match } from './queries/bm25-match'; +import { bm25ModeTour } from './queries/bm25-mode-tour'; +import { bm25Proximity } from './queries/bm25-proximity'; +import { bm25ProximityChain, type ProximityChainStep } from './queries/bm25-proximity-chain'; +import { bm25TopByScore } from './queries/bm25-top-by-score'; + +function parseProximityChainArgs(args: readonly string[]): { + readonly start: string; + readonly steps: readonly ProximityChainStep[]; +} { + if (args.length < 3 || args.length % 2 !== 1) { + throw new Error('Usage: pnpm start -- proximity-chain [ ...]'); + } + const [start, ...rest] = args; + if (start === undefined) { + throw new Error('proximity-chain: is required'); + } + const steps: ProximityChainStep[] = []; + for (let i = 0; i < rest.length; i += 2) { + const distRaw = rest[i]; + const term = rest[i + 1]; + if (distRaw === undefined || term === undefined) { + throw new Error( + `proximity-chain: trailing distance with no following term at position ${i + 1}`, + ); + } + const ordered = distRaw.startsWith('>'); + const distStr = ordered ? distRaw.slice(1) : distRaw; + const distance = Number.parseInt(distStr, 10); + if (!Number.isInteger(distance) || distance < 0) { + throw new Error( + `proximity-chain: distance at position ${i + 1} must be a non-negative integer (optionally prefixed '>' for ordered); got '${distRaw}'`, + ); + } + steps.push({ distance, term, ordered }); + } + return { start, steps }; +} + +const argv = process.argv.slice(2).filter((arg) => arg !== '--'); +const [cmd, ...args] = argv; + +async function main() { + const { databaseUrl } = loadAppConfig(); + const runtime = await db.connect({ url: databaseUrl }); + + try { + if (cmd === 'match') { + const [query, limitStr] = args; + if (!query) { + console.error('Usage: pnpm start -- match [limit]'); + process.exit(1); + } + const limit = limitStr ? Number.parseInt(limitStr, 10) : 20; + const rows = await bm25Match(query, limit); + console.log(JSON.stringify(rows, null, 2)); + } else if (cmd === 'top') { + const [query, limitStr] = args; + if (!query) { + console.error('Usage: pnpm start -- top [limit]'); + process.exit(1); + } + const limit = limitStr ? Number.parseInt(limitStr, 10) : 10; + const rows = await bm25TopByScore(query, limit); + console.log(JSON.stringify(rows, null, 2)); + } else if (cmd === 'fuzzy') { + const [term, distanceStr, limitStr] = args; + if (!term || !distanceStr) { + console.error('Usage: pnpm start -- fuzzy [limit]'); + process.exit(1); + } + const distance = Number.parseInt(distanceStr, 10); + const limit = limitStr ? Number.parseInt(limitStr, 10) : 20; + const rows = await bm25Fuzzy(term, distance, limit); + console.log(JSON.stringify(rows, null, 2)); + } else if (cmd === 'proximity') { + const [term1, term2, distanceStr, limitStr] = args; + if (!term1 || !term2 || !distanceStr) { + console.error('Usage: pnpm start -- proximity [limit]'); + process.exit(1); + } + const distance = Number.parseInt(distanceStr, 10); + const limit = limitStr ? Number.parseInt(limitStr, 10) : 20; + const rows = await bm25Proximity(term1, term2, distance, limit); + console.log(JSON.stringify(rows, null, 2)); + } else if (cmd === 'proximity-chain') { + const { start, steps } = parseProximityChainArgs(args); + const rows = await bm25ProximityChain(start, steps); + console.log(JSON.stringify(rows, null, 2)); + } else if (cmd === 'chain-demo') { + const rows = await bm25ChainDemo(); + console.log(JSON.stringify(rows, null, 2)); + } else if (cmd === 'mode-tour') { + const rows = await bm25ModeTour(); + console.log(JSON.stringify(rows, null, 2)); + } else if (cmd === 'cast-demo') { + const rows = await bm25CastDemo(); + console.log(JSON.stringify(rows, null, 2)); + } else { + console.log( + 'Usage: pnpm start -- [match [limit] | top [limit] | fuzzy [limit] | proximity [limit] | proximity-chain [ ...] | chain-demo | mode-tour | cast-demo]', + ); + process.exit(1); + } + } finally { + await runtime.close(); + } +} + +await main(); diff --git a/examples/paradedb-demo/src/prisma/contract.d.ts b/examples/paradedb-demo/src/prisma/contract.d.ts new file mode 100644 index 0000000000..4a7433d943 --- /dev/null +++ b/examples/paradedb-demo/src/prisma/contract.d.ts @@ -0,0 +1,189 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { CodecTypes as PgTypes } from '@prisma-next/target-postgres/codec-types'; +import type { JsonValue } from '@prisma-next/target-postgres/codec-types'; +import type { Char } from '@prisma-next/target-postgres/codec-types'; +import type { Varchar } from '@prisma-next/target-postgres/codec-types'; +import type { Numeric } from '@prisma-next/target-postgres/codec-types'; +import type { Bit } from '@prisma-next/target-postgres/codec-types'; +import type { VarBit } from '@prisma-next/target-postgres/codec-types'; +import type { Timestamp } from '@prisma-next/target-postgres/codec-types'; +import type { Timestamptz } from '@prisma-next/target-postgres/codec-types'; +import type { Time } from '@prisma-next/target-postgres/codec-types'; +import type { Timetz } from '@prisma-next/target-postgres/codec-types'; +import type { Interval } from '@prisma-next/target-postgres/codec-types'; +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as ParadeDbQueryOperationTypes } from '@prisma-next/extension-paradedb/operation-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:0a0a081bf67f866d5adea963fe0b83557074255f44478473fc27f120e5093d06'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:4afac8f9fa43b8aa78ee7fb8b1b3e079e92b64ef745e9f115be11e201eb08ef1'>; + +export type CodecTypes = PgTypes; +export type OperationTypes = Record; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps & + ParadeDbQueryOperationTypes; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly Item: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly description: CodecTypes['pg/text@1']['output']; + readonly category: CodecTypes['pg/text@1']['output']; + readonly rating: CodecTypes['pg/int4@1']['output']; + }; +}; +export type FieldInputTypes = { + readonly Item: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly description: CodecTypes['pg/text@1']['input']; + readonly category: CodecTypes['pg/text@1']['input']; + readonly rating: CodecTypes['pg/int4@1']['input']; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + OperationTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = ContractType< + { + readonly tables: { + readonly item: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly description: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly category: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly rating: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly [ + { + readonly columns: readonly ['id', 'description', 'category', 'rating']; + readonly name: 'item_bm25_idx'; + readonly type: 'bm25'; + readonly options: { readonly key_field: 'id' }; + }, + ]; + foreignKeys: readonly []; + }; + }; + readonly types: Record; + readonly storageHash: StorageHash; + }, + { + readonly Item: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly description: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly category: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly rating: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'item'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly description: { readonly column: 'description' }; + readonly category: { readonly column: 'category' }; + readonly rating: { readonly column: 'rating' }; + }; + }; + }; + } +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { readonly item: 'Item' }; + readonly capabilities: { + readonly postgres: { + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly 'paradedb/bm25': true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly returning: true; + }; + }; + readonly extensionPacks: { + readonly paradedb: { + readonly capabilities: { readonly postgres: { readonly 'paradedb/bm25': true } }; + readonly familyId: 'sql'; + readonly id: 'paradedb'; + readonly kind: 'extension'; + readonly targetId: 'postgres'; + readonly types: { + readonly queryOperationTypes: { + readonly import: { + readonly alias: 'ParadeDbQueryOperationTypes'; + readonly named: 'QueryOperationTypes'; + readonly package: '@prisma-next/extension-paradedb/operation-types'; + }; + }; + }; + readonly version: '0.0.1'; + }; + }; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Tables = Contract['storage']['tables']; +export type Models = Contract['models']; diff --git a/examples/paradedb-demo/src/prisma/contract.json b/examples/paradedb-demo/src/prisma/contract.json new file mode 100644 index 0000000000..8ddce8d05f --- /dev/null +++ b/examples/paradedb-demo/src/prisma/contract.json @@ -0,0 +1,156 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:4afac8f9fa43b8aa78ee7fb8b1b3e079e92b64ef745e9f115be11e201eb08ef1", + "roots": { + "item": "Item" + }, + "models": { + "Item": { + "fields": { + "category": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "description": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "rating": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "category": { + "column": "category" + }, + "description": { + "column": "description" + }, + "id": { + "column": "id" + }, + "rating": { + "column": "rating" + } + }, + "table": "item" + } + } + }, + "storage": { + "storageHash": "sha256:0a0a081bf67f866d5adea963fe0b83557074255f44478473fc27f120e5093d06", + "tables": { + "item": { + "columns": { + "category": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "description": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "rating": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [ + { + "columns": [ + "id", + "description", + "category", + "rating" + ], + "name": "item_bm25_idx", + "options": { + "key_field": "id" + }, + "type": "bm25" + } + ], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } + } + }, + "capabilities": { + "postgres": { + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "paradedb/bm25": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "returning": true + } + }, + "extensionPacks": { + "paradedb": { + "capabilities": { + "postgres": { + "paradedb/bm25": true + } + }, + "familyId": "sql", + "id": "paradedb", + "kind": "extension", + "targetId": "postgres", + "types": { + "queryOperationTypes": { + "import": { + "alias": "ParadeDbQueryOperationTypes", + "named": "QueryOperationTypes", + "package": "@prisma-next/extension-paradedb/operation-types" + } + } + }, + "version": "0.0.1" + } + }, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} \ No newline at end of file diff --git a/examples/paradedb-demo/src/prisma/db.ts b/examples/paradedb-demo/src/prisma/db.ts new file mode 100644 index 0000000000..c00313e85a --- /dev/null +++ b/examples/paradedb-demo/src/prisma/db.ts @@ -0,0 +1,9 @@ +import paradedb from '@prisma-next/extension-paradedb/runtime'; +import postgres from '@prisma-next/postgres/runtime'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +export const db = postgres({ + contractJson, + extensions: [paradedb], +}); diff --git a/examples/paradedb-demo/src/queries/bm25-cast-demo.ts b/examples/paradedb-demo/src/queries/bm25-cast-demo.ts new file mode 100644 index 0000000000..787d782ddd --- /dev/null +++ b/examples/paradedb-demo/src/queries/bm25-cast-demo.ts @@ -0,0 +1,35 @@ +import { db } from '../prisma/db'; + +export async function bm25CastDemo() { + const runtime = db.runtime(); + + const boosted = await runtime.execute( + db.sql.item + .select('id', 'description') + .select('score', (f, fns) => fns.paradeDbScore(f.id)) + .where((f, fns) => fns.paradeDbMatchAny(f.description, fns.paradeDbBoost('keyboard', 5))) + .orderBy((f, fns) => fns.paradeDbScore(f.id), { direction: 'desc' }) + .limit(3) + .build(), + ); + + const constScored = await runtime.execute( + db.sql.item + .select('id', 'description') + .select('score', (f, fns) => fns.paradeDbScore(f.id)) + .where((f, fns) => fns.paradeDbMatchAny(f.description, fns.paradeDbConst('keyboard', 1))) + .orderBy((f, fns) => fns.paradeDbScore(f.id), { direction: 'desc' }) + .limit(3) + .build(), + ); + + const phraseSlop = await runtime.execute( + db.sql.item + .select('id', 'description') + .where((f, fns) => fns.paradeDbPhrase(f.description, fns.paradeDbSlop('cooling fan', 1))) + .limit(3) + .build(), + ); + + return { boosted, constScored, phraseSlop }; +} diff --git a/examples/paradedb-demo/src/queries/bm25-chain-demo.ts b/examples/paradedb-demo/src/queries/bm25-chain-demo.ts new file mode 100644 index 0000000000..8339234b3d --- /dev/null +++ b/examples/paradedb-demo/src/queries/bm25-chain-demo.ts @@ -0,0 +1,20 @@ +import { db } from '../prisma/db'; + +export async function bm25ChainDemo() { + const plan = db.sql.item + .select('id', 'description', 'category', 'rating') + .select('score', (f, fns) => fns.paradeDbScore(f.id)) + .where((f, fns) => + fns.paradeDbMatch( + f.description, + fns + .paradeDbProximity('wireless') + .within(1, 'mechanical') + .within(1, 'keyboard', { ordered: true }), + ), + ) + .orderBy((f, fns) => fns.paradeDbScore(f.id), { direction: 'desc' }) + .limit(5) + .build(); + return db.runtime().execute(plan); +} diff --git a/examples/paradedb-demo/src/queries/bm25-fuzzy.ts b/examples/paradedb-demo/src/queries/bm25-fuzzy.ts new file mode 100644 index 0000000000..f282a2741c --- /dev/null +++ b/examples/paradedb-demo/src/queries/bm25-fuzzy.ts @@ -0,0 +1,12 @@ +import { db } from '../prisma/db'; + +export async function bm25Fuzzy(term: string, distance: number, limit = 20) { + const plan = db.sql.item + .select('id', 'description', 'category', 'rating') + .select('score', (f, fns) => fns.paradeDbScore(f.id)) + .where((f, fns) => fns.paradeDbMatch(f.description, fns.paradeDbFuzzy(term, distance))) + .orderBy((f, fns) => fns.paradeDbScore(f.id), { direction: 'desc' }) + .limit(limit) + .build(); + return db.runtime().execute(plan); +} diff --git a/examples/paradedb-demo/src/queries/bm25-match.ts b/examples/paradedb-demo/src/queries/bm25-match.ts new file mode 100644 index 0000000000..937f1b7800 --- /dev/null +++ b/examples/paradedb-demo/src/queries/bm25-match.ts @@ -0,0 +1,10 @@ +import { db } from '../prisma/db'; + +export async function bm25Match(query: string, limit = 20) { + const plan = db.sql.item + .select('id', 'description', 'category', 'rating') + .where((f, fns) => fns.paradeDbMatch(f.description, query)) + .limit(limit) + .build(); + return db.runtime().execute(plan); +} diff --git a/examples/paradedb-demo/src/queries/bm25-mode-tour.ts b/examples/paradedb-demo/src/queries/bm25-mode-tour.ts new file mode 100644 index 0000000000..688d27f0ed --- /dev/null +++ b/examples/paradedb-demo/src/queries/bm25-mode-tour.ts @@ -0,0 +1,109 @@ +import { db } from '../prisma/db'; + +export async function bm25ModeTour() { + const cases = [ + { + label: "matchAny 'with cooling'", + note: "tokenized OR — items with 'with' OR 'cooling'", + run: () => + db.runtime().execute( + db.sql.item + .select('id', 'description') + .where((f, fns) => fns.paradeDbMatchAny(f.description, 'with cooling')) + .build(), + ), + }, + { + label: "matchAll 'with cooling'", + note: "tokenized AND — items with 'with' AND 'cooling' (any order)", + run: () => + db.runtime().execute( + db.sql.item + .select('id', 'description') + .where((f, fns) => fns.paradeDbMatchAll(f.description, 'with cooling')) + .build(), + ), + }, + { + label: "phrase 'cooling fan'", + note: 'exact ordered, consecutive — adjacent tokens', + run: () => + db.runtime().execute( + db.sql.item + .select('id', 'description') + .where((f, fns) => fns.paradeDbPhrase(f.description, 'cooling fan')) + .build(), + ), + }, + { + label: "phrase 'fan cooling'", + note: 'same tokens reversed — fails because phrase is order-sensitive', + run: () => + db.runtime().execute( + db.sql.item + .select('id', 'description') + .where((f, fns) => fns.paradeDbPhrase(f.description, 'fan cooling')) + .build(), + ), + }, + { + label: "matchAll 'shoes running'", + note: 'AND ignores order — both tokens present, anywhere', + run: () => + db.runtime().execute( + db.sql.item + .select('id', 'description') + .where((f, fns) => fns.paradeDbMatchAll(f.description, 'shoes running')) + .build(), + ), + }, + { + label: "phrase 'shoes running'", + note: "same tokens — phrase requires the original 'running shoes' order", + run: () => + db.runtime().execute( + db.sql.item + .select('id', 'description') + .where((f, fns) => fns.paradeDbPhrase(f.description, 'shoes running')) + .build(), + ), + }, + { + label: "term 'wireless'", + note: 'exact indexed token — finds the literal post-tokenizer term', + run: () => + db.runtime().execute( + db.sql.item + .select('id', 'description') + .where((f, fns) => fns.paradeDbTerm(f.description, 'wireless')) + .build(), + ), + }, + { + label: "term 'wireless mechanical'", + note: 'multi-word string — never an indexed token, so empty', + run: () => + db.runtime().execute( + db.sql.item + .select('id', 'description') + .where((f, fns) => fns.paradeDbTerm(f.description, 'wireless mechanical')) + .build(), + ), + }, + ]; + + const results: Array<{ + label: string; + note: string; + matches: ReadonlyArray<{ id: number; description: string }>; + }> = []; + for (const c of cases) { + const rows = await c.run(); + results.push({ + label: c.label, + note: c.note, + matches: rows.map((r) => ({ id: r.id, description: r.description })), + }); + } + return results; +} diff --git a/examples/paradedb-demo/src/queries/bm25-proximity-chain.ts b/examples/paradedb-demo/src/queries/bm25-proximity-chain.ts new file mode 100644 index 0000000000..673c077085 --- /dev/null +++ b/examples/paradedb-demo/src/queries/bm25-proximity-chain.ts @@ -0,0 +1,28 @@ +import { db } from '../prisma/db'; + +export interface ProximityChainStep { + readonly distance: number; + readonly term: string; + readonly ordered: boolean; +} + +export async function bm25ProximityChain( + start: string, + steps: readonly ProximityChainStep[], + limit = 20, +) { + const plan = db.sql.item + .select('id', 'description', 'category', 'rating') + .select('score', (f, fns) => fns.paradeDbScore(f.id)) + .where((f, fns) => { + const chain = steps.reduce( + (acc, step) => acc.within(step.distance, step.term, { ordered: step.ordered }), + fns.paradeDbProximity(start), + ); + return fns.paradeDbMatch(f.description, chain); + }) + .orderBy((f, fns) => fns.paradeDbScore(f.id), { direction: 'desc' }) + .limit(limit) + .build(); + return db.runtime().execute(plan); +} diff --git a/examples/paradedb-demo/src/queries/bm25-proximity.ts b/examples/paradedb-demo/src/queries/bm25-proximity.ts new file mode 100644 index 0000000000..b8962678dc --- /dev/null +++ b/examples/paradedb-demo/src/queries/bm25-proximity.ts @@ -0,0 +1,14 @@ +import { db } from '../prisma/db'; + +export async function bm25Proximity(term1: string, term2: string, distance: number, limit = 20) { + const plan = db.sql.item + .select('id', 'description', 'category', 'rating') + .select('score', (f, fns) => fns.paradeDbScore(f.id)) + .where((f, fns) => + fns.paradeDbMatch(f.description, fns.paradeDbProximity(term1).within(distance, term2)), + ) + .orderBy((f, fns) => fns.paradeDbScore(f.id), { direction: 'desc' }) + .limit(limit) + .build(); + return db.runtime().execute(plan); +} diff --git a/examples/paradedb-demo/src/queries/bm25-top-by-score.ts b/examples/paradedb-demo/src/queries/bm25-top-by-score.ts new file mode 100644 index 0000000000..931f3a54b5 --- /dev/null +++ b/examples/paradedb-demo/src/queries/bm25-top-by-score.ts @@ -0,0 +1,12 @@ +import { db } from '../prisma/db'; + +export async function bm25TopByScore(query: string, limit = 10) { + const plan = db.sql.item + .select('id', 'description', 'category', 'rating') + .select('score', (f, fns) => fns.paradeDbScore(f.id)) + .where((f, fns) => fns.paradeDbMatch(f.description, query)) + .orderBy((f, fns) => fns.paradeDbScore(f.id), { direction: 'desc' }) + .limit(limit) + .build(); + return db.runtime().execute(plan); +} diff --git a/examples/paradedb-demo/test/bm25.integration.test.ts b/examples/paradedb-demo/test/bm25.integration.test.ts new file mode 100644 index 0000000000..b5018244fd --- /dev/null +++ b/examples/paradedb-demo/test/bm25.integration.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { loadAppConfig } from '../src/app-config'; +import { db } from '../src/prisma/db'; +import { bm25Match } from '../src/queries/bm25-match'; +import { bm25TopByScore } from '../src/queries/bm25-top-by-score'; + +const SKIP = process.env['DATABASE_URL'] === undefined; + +describe.skipIf(SKIP)('paradedb BM25 integration', () => { + it('matchBm25 returns rows whose description matches the query', async () => { + const { databaseUrl } = loadAppConfig(); + const runtime = await db.connect({ url: databaseUrl }); + try { + const rows = await bm25Match('headphones'); + expect(rows.length).toBeGreaterThan(0); + expect(rows.some((r) => r.description.toLowerCase().includes('headphones'))).toBe(true); + } finally { + await runtime.close(); + } + }); + + it('bm25Score orders matching rows by descending relevance', async () => { + const { databaseUrl } = loadAppConfig(); + const runtime = await db.connect({ url: databaseUrl }); + try { + const rows = await bm25TopByScore('laptop'); + expect(rows.length).toBeGreaterThan(0); + for (let i = 1; i < rows.length; i++) { + expect(rows[i - 1]!.score).toBeGreaterThanOrEqual(rows[i]!.score); + } + } finally { + await runtime.close(); + } + }); +}); diff --git a/examples/paradedb-demo/tsconfig.json b/examples/paradedb-demo/tsconfig.json new file mode 100644 index 0000000000..ccdf9f30c9 --- /dev/null +++ b/examples/paradedb-demo/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": ["@prisma-next/tsconfig/base"], + "compilerOptions": { + "outDir": "dist", + "lib": ["ES2022"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "scripts/**/*.ts", + "prisma/**/*.ts", + "prisma-next.config.ts" + ], + "exclude": ["dist"] +} diff --git a/examples/paradedb-demo/vitest.config.ts b/examples/paradedb-demo/vitest.config.ts new file mode 100644 index 0000000000..5c0ed4e148 --- /dev/null +++ b/examples/paradedb-demo/vitest.config.ts @@ -0,0 +1,13 @@ +import { timeouts } from '@prisma-next/test-utils'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + pool: 'threads', + maxWorkers: 1, + isolate: false, + testTimeout: timeouts.default, + hookTimeout: timeouts.default, + }, +}); diff --git a/packages/1-framework/2-authoring/contract/src/descriptors.ts b/packages/1-framework/2-authoring/contract/src/descriptors.ts index 9f7d624484..e5b3d708d8 100644 --- a/packages/1-framework/2-authoring/contract/src/descriptors.ts +++ b/packages/1-framework/2-authoring/contract/src/descriptors.ts @@ -8,8 +8,8 @@ export type ColumnTypeDescriptor = { export interface IndexDef { readonly columns: readonly string[]; readonly name?: string; - readonly using?: string; - readonly config?: Record; + readonly type?: string; + readonly options?: Record; } export interface ForeignKeyDefaultsState { diff --git a/packages/1-framework/2-authoring/contract/test/descriptors.test.ts b/packages/1-framework/2-authoring/contract/test/descriptors.test.ts index 80a181d265..f76f3f4278 100644 --- a/packages/1-framework/2-authoring/contract/test/descriptors.test.ts +++ b/packages/1-framework/2-authoring/contract/test/descriptors.test.ts @@ -30,15 +30,15 @@ describe('descriptor exports', () => { const index: IndexDef = { columns: ['email'], name: 'user_email_idx', - using: 'btree', - config: { fillfactor: 90 }, + type: 'btree', + options: { fillfactor: 90 }, }; expect(index).toEqual({ columns: ['email'], name: 'user_email_idx', - using: 'btree', - config: { fillfactor: 90 }, + type: 'btree', + options: { fillfactor: 90 }, }); }); }); diff --git a/packages/2-sql/1-core/contract/package.json b/packages/2-sql/1-core/contract/package.json index ef8b84c9aa..1c3b8a0f05 100644 --- a/packages/2-sql/1-core/contract/package.json +++ b/packages/2-sql/1-core/contract/package.json @@ -33,6 +33,7 @@ ], "exports": { "./factories": "./dist/factories.mjs", + "./index-types": "./dist/index-types.mjs", "./pack-types": "./dist/pack-types.mjs", "./types": "./dist/types.mjs", "./validate": "./dist/validate.mjs", diff --git a/packages/2-sql/1-core/contract/src/exports/index-types.ts b/packages/2-sql/1-core/contract/src/exports/index-types.ts new file mode 100644 index 0000000000..5157bc9021 --- /dev/null +++ b/packages/2-sql/1-core/contract/src/exports/index-types.ts @@ -0,0 +1,9 @@ +export { + createIndexTypeRegistry, + defineIndexTypes, + type IndexTypeBuilder, + type IndexTypeEntry, + type IndexTypeMap, + type IndexTypeRegistration, + type IndexTypeRegistry, +} from '../index-types'; diff --git a/packages/2-sql/1-core/contract/src/index-types.ts b/packages/2-sql/1-core/contract/src/index-types.ts new file mode 100644 index 0000000000..20b3030d65 --- /dev/null +++ b/packages/2-sql/1-core/contract/src/index-types.ts @@ -0,0 +1,77 @@ +import type { Type } from 'arktype'; + +export interface IndexTypeEntry { + readonly type: string; + readonly options: Type; +} + +export type IndexTypeMap = { readonly [K in string]: { readonly options: unknown } }; + +export interface IndexTypeRegistration> { + readonly IndexTypes: TMap; + readonly entries: ReadonlyArray; +} + +export interface IndexTypeBuilder> + extends IndexTypeRegistration { + add( + typeLiteral: TLit, + entry: { readonly options: Type }, + ): IndexTypeBuilder>; +} + +class IndexTypeBuilderImpl implements IndexTypeBuilder { + readonly entries: ReadonlyArray; + readonly IndexTypes: TMap; + + constructor(entries: ReadonlyArray) { + this.entries = entries; + this.IndexTypes = {} as TMap; + } + + add( + typeLiteral: TLit, + entry: { readonly options: Type }, + ): IndexTypeBuilder> { + if (this.entries.some((e) => e.type === typeLiteral)) { + throw new Error(`Index type "${typeLiteral}" is already declared in this builder`); + } + return new IndexTypeBuilderImpl>([ + ...this.entries, + { type: typeLiteral, options: entry.options as Type }, + ]); + } +} + +export function defineIndexTypes(): IndexTypeBuilder> { + return new IndexTypeBuilderImpl([]); +} + +export interface IndexTypeRegistry { + register(entry: IndexTypeEntry): void; + get(typeLiteral: string): IndexTypeEntry | undefined; + has(typeLiteral: string): boolean; +} + +class IndexTypeRegistryImpl implements IndexTypeRegistry { + private readonly entries = new Map(); + + register(entry: IndexTypeEntry): void { + if (this.entries.has(entry.type)) { + throw new Error(`Index type "${entry.type}" is already registered`); + } + this.entries.set(entry.type, entry); + } + + get(typeLiteral: string): IndexTypeEntry | undefined { + return this.entries.get(typeLiteral); + } + + has(typeLiteral: string): boolean { + return this.entries.has(typeLiteral); + } +} + +export function createIndexTypeRegistry(): IndexTypeRegistry { + return new IndexTypeRegistryImpl(); +} diff --git a/packages/2-sql/1-core/contract/src/index.ts b/packages/2-sql/1-core/contract/src/index.ts index a9ab36142b..2c7ba4e901 100644 --- a/packages/2-sql/1-core/contract/src/index.ts +++ b/packages/2-sql/1-core/contract/src/index.ts @@ -1,4 +1,5 @@ export * from './exports/factories'; +export * from './exports/index-types'; export * from './exports/types'; export * from './exports/validate'; export * from './exports/validators'; diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index 27d80bcc6a..eb3229cc7b 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -43,16 +43,8 @@ export type UniqueConstraint = { export type Index = { readonly columns: readonly string[]; readonly name?: string; - /** - * Optional access method identifier. - * Extension-specific methods are represented as strings and interpreted - * by the owning extension package. - */ - readonly using?: string; - /** - * Optional extension-owned index configuration payload. - */ - readonly config?: Record; + readonly type?: string; + readonly options?: Record; }; export type ForeignKeyReferences = { diff --git a/packages/2-sql/1-core/contract/src/validators.ts b/packages/2-sql/1-core/contract/src/validators.ts index d64a6f307e..16b465e0a1 100644 --- a/packages/2-sql/1-core/contract/src/validators.ts +++ b/packages/2-sql/1-core/contract/src/validators.ts @@ -1,6 +1,7 @@ import type { Contract } from '@prisma-next/contract/types'; import { ContractValidationError } from '@prisma-next/contract/validate-contract'; import { type } from 'arktype'; +import type { IndexTypeRegistry } from './index-types'; import type { ForeignKey, ForeignKeyReferences, @@ -96,8 +97,8 @@ const UniqueConstraintSchema = type.declare().type({ export const IndexSchema = type({ columns: type.string.array().readonly(), 'name?': 'string', - 'using?': 'string', - 'config?': 'Record', + 'type?': 'string', + 'options?': 'Record', }); export const ForeignKeyReferencesSchema = type.declare().type({ @@ -341,8 +342,8 @@ export function validateStorageSemantics(storage: SqlStorage): string[] { for (const index of table.indexes) { const signature = JSON.stringify({ columns: index.columns, - using: index.using ?? null, - config: index.config ?? null, + type: index.type ?? null, + options: index.options ?? null, }); if (seenIndexDefinitions.has(signature)) { errors.push( @@ -403,3 +404,35 @@ export function validateStorageSemantics(storage: SqlStorage): string[] { return errors; } + +export function validateIndexTypes( + contract: Contract, + indexTypeRegistry: IndexTypeRegistry, +): void { + for (const [tableName, table] of Object.entries(contract.storage.tables)) { + for (const index of table.indexes) { + if (index.type === undefined && index.options !== undefined) { + throw new ContractValidationError( + `Table "${tableName}" index on columns [${index.columns.join(', ')}] has options without a type`, + 'storage', + ); + } + if (index.type === undefined) continue; + const entry = indexTypeRegistry.get(index.type); + if (entry === undefined) { + throw new ContractValidationError( + `Table "${tableName}" index on columns [${index.columns.join(', ')}] uses unregistered index type "${index.type}"`, + 'storage', + ); + } + const optionsValue = index.options ?? {}; + const result = entry.options(optionsValue); + if (result instanceof type.errors) { + throw new ContractValidationError( + `Table "${tableName}" index on columns [${index.columns.join(', ')}] has invalid options for type "${index.type}": ${result.summary}`, + 'storage', + ); + } + } + } +} diff --git a/packages/2-sql/1-core/contract/test/index-types.test.ts b/packages/2-sql/1-core/contract/test/index-types.test.ts new file mode 100644 index 0000000000..f756bfc4d5 --- /dev/null +++ b/packages/2-sql/1-core/contract/test/index-types.test.ts @@ -0,0 +1,83 @@ +import { type } from 'arktype'; +import { describe, expect, it } from 'vitest'; +import { createIndexTypeRegistry, defineIndexTypes } from '../src/index-types'; + +describe('defineIndexTypes builder', () => { + it('starts empty', () => { + const builder = defineIndexTypes(); + expect(builder.entries).toEqual([]); + }); + + it('add() yields a new builder with the entry appended', () => { + const optionsValidator = type({ key_field: 'string' }); + const builder = defineIndexTypes().add('bm25', { options: optionsValidator }); + expect(builder.entries).toHaveLength(1); + expect(builder.entries[0]?.type).toBe('bm25'); + expect(builder.entries[0]?.options).toBe(optionsValidator); + }); + + it('add() composes multiple distinct entries in order', () => { + const a = type({ a: 'string' }); + const b = type({ b: 'string' }); + const builder = defineIndexTypes().add('alpha', { options: a }).add('beta', { options: b }); + expect(builder.entries.map((e) => e.type)).toEqual(['alpha', 'beta']); + }); + + it('add() does not mutate the prior builder', () => { + const opts = type({ x: 'string' }); + const a = defineIndexTypes(); + const b = a.add('alpha', { options: opts }); + expect(a.entries).toEqual([]); + expect(b.entries).toHaveLength(1); + }); + + it('add() throws on duplicate type literal in the same builder', () => { + const opts = type({ x: 'string' }); + const builder = defineIndexTypes().add('dup', { options: opts }); + expect(() => builder.add('dup', { options: opts })).toThrow(/already declared/); + }); +}); + +describe('createIndexTypeRegistry', () => { + it('register stores an entry; get returns it', () => { + const registry = createIndexTypeRegistry(); + const entry = { type: 'demo', options: type({ fillfactor: 'number' }) }; + registry.register(entry); + expect(registry.get('demo')).toBe(entry); + }); + + it('has reports presence', () => { + const registry = createIndexTypeRegistry(); + expect(registry.has('absent')).toBe(false); + registry.register({ type: 'present', options: type({ k: 'string' }) }); + expect(registry.has('present')).toBe(true); + }); + + it('get returns undefined for unknown types', () => { + const registry = createIndexTypeRegistry(); + expect(registry.get('nonesuch')).toBeUndefined(); + }); + + it('register throws on duplicate type', () => { + const registry = createIndexTypeRegistry(); + const opts = type({ key: 'string' }); + registry.register({ type: 'gin', options: opts }); + expect(() => registry.register({ type: 'gin', options: opts })).toThrow(/already registered/); + }); + + it('error message names the offending type', () => { + const registry = createIndexTypeRegistry(); + registry.register({ type: 'gist', options: type({ k: 'string' }) }); + expect(() => registry.register({ type: 'gist', options: type({ k: 'string' }) })).toThrow( + /gist/, + ); + }); + + it('two registries are independent', () => { + const a = createIndexTypeRegistry(); + const b = createIndexTypeRegistry(); + a.register({ type: 'shared', options: type({ k: 'string' }) }); + expect(a.has('shared')).toBe(true); + expect(b.has('shared')).toBe(false); + }); +}); diff --git a/packages/2-sql/1-core/contract/test/validate.test.ts b/packages/2-sql/1-core/contract/test/validate.test.ts index 4ec840ec0f..a04bf8b7cb 100644 --- a/packages/2-sql/1-core/contract/test/validate.test.ts +++ b/packages/2-sql/1-core/contract/test/validate.test.ts @@ -1,9 +1,12 @@ import type { Contract } from '@prisma-next/contract/types'; import { ContractValidationError } from '@prisma-next/contract/validate-contract'; import { emptyCodecLookup } from '@prisma-next/framework-components/codec'; +import { type } from 'arktype'; import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createIndexTypeRegistry } from '../src/index-types'; import type { SqlStorage } from '../src/types'; import { validateContract } from '../src/validate'; +import { validateIndexTypes } from '../src/validators'; const baseContract = { target: 'postgres', @@ -892,4 +895,92 @@ describe('validateContract', () => { >(); }); }); + + describe('validateIndexTypes', () => { + function makeContractWithIndex(index: Record) { + return makeContract({ + User: { + columns: { + id: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, + body: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [index], + foreignKeys: [], + }, + }) as Contract; + } + + it('accepts a contract whose index references a registered type with valid options', () => { + const registry = createIndexTypeRegistry(); + registry.register({ + type: 'demo', + options: type({ fillfactor: 'number' }), + }); + const contract = makeContractWithIndex({ + columns: ['body'], + type: 'demo', + options: { fillfactor: 70 }, + }); + expect(() => validateIndexTypes(contract, registry)).not.toThrow(); + }); + + it('rejects an index whose type is not registered', () => { + const registry = createIndexTypeRegistry(); + const contract = makeContractWithIndex({ columns: ['body'], type: 'made-up' }); + expect(() => validateIndexTypes(contract, registry)).toThrow(/made-up/); + }); + + it('rejects an index whose options fail the registered validator', () => { + const registry = createIndexTypeRegistry(); + registry.register({ + type: 'demo', + options: type({ fillfactor: 'number' }), + }); + const contract = makeContractWithIndex({ + columns: ['body'], + type: 'demo', + options: { fillfactor: 'not-a-number' }, + }); + expect(() => validateIndexTypes(contract, registry)).toThrow(/fillfactor/); + }); + + it('rejects extra option keys in strict mode', () => { + const registry = createIndexTypeRegistry(); + registry.register({ + type: 'demo', + options: type({ '+': 'reject', fillfactor: 'number' }), + }); + const contract = makeContractWithIndex({ + columns: ['body'], + type: 'demo', + options: { fillfactor: 70, unknown: 1 }, + }); + expect(() => validateIndexTypes(contract, registry)).toThrow(/unknown/); + }); + + it('rejects an index that has options without a type', () => { + const registry = createIndexTypeRegistry(); + const contract = makeContractWithIndex({ + columns: ['body'], + options: { fillfactor: 70 }, + }); + expect(() => validateIndexTypes(contract, registry)).toThrow(/options/); + }); + + it('accepts an index without type or options', () => { + const registry = createIndexTypeRegistry(); + const contract = makeContractWithIndex({ columns: ['body'] }); + expect(() => validateIndexTypes(contract, registry)).not.toThrow(); + }); + + it('rejects a typed index against an empty registry', () => { + const registry = createIndexTypeRegistry(); + const contract = makeContractWithIndex({ columns: ['body'], type: 'made-up' }); + expect(() => validateIndexTypes(contract, registry)).toThrow( + /unregistered index type "made-up"/, + ); + }); + }); }); diff --git a/packages/2-sql/1-core/contract/tsdown.config.ts b/packages/2-sql/1-core/contract/tsdown.config.ts index 859a81b68b..1a8b300f14 100644 --- a/packages/2-sql/1-core/contract/tsdown.config.ts +++ b/packages/2-sql/1-core/contract/tsdown.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ 'src/exports/validate.ts', 'src/exports/factories.ts', 'src/exports/pack-types.ts', + 'src/exports/index-types.ts', ], }); diff --git a/packages/2-sql/1-core/schema-ir/src/types.ts b/packages/2-sql/1-core/schema-ir/src/types.ts index 4b754a60f8..85c522cfe0 100644 --- a/packages/2-sql/1-core/schema-ir/src/types.ts +++ b/packages/2-sql/1-core/schema-ir/src/types.ts @@ -68,6 +68,8 @@ export type SqlIndexIR = { readonly columns: readonly string[]; readonly name?: string; readonly unique: boolean; + readonly type?: string; + readonly options?: Record; readonly annotations?: SqlAnnotations; }; diff --git a/packages/2-sql/2-authoring/contract-psl/package.json b/packages/2-sql/2-authoring/contract-psl/package.json index 58e44f2227..bcdd7065d9 100644 --- a/packages/2-sql/2-authoring/contract-psl/package.json +++ b/packages/2-sql/2-authoring/contract-psl/package.json @@ -29,6 +29,7 @@ "@prisma-next/test-utils": "workspace:*", "@prisma-next/tsconfig": "workspace:*", "@prisma-next/tsdown": "workspace:*", + "arktype": "^2.1.25", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 5dcb79ada2..e8d0644e66 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -41,11 +41,13 @@ import { ifDefined } from '@prisma-next/utils/defined'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; import { getAttribute, + getNamedArgument, getPositionalArgument, mapFieldNamesToColumns, parseAttributeFieldList, parseConstraintMapArgument, parseMapName, + parseObjectLiteralStringMap, parseQuotedStringLiteral, } from './psl-attribute-parsing'; import type { ColumnDescriptor } from './psl-column-resolution'; @@ -594,9 +596,51 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult ...ifDefined('name', constraintName), }); } else { + const indexEntityLabel = `Model "${model.name}" @@index`; + const rawTypeArg = getNamedArgument(modelAttribute, 'type'); + let indexType: string | undefined; + if (rawTypeArg !== undefined) { + const parsed = parseQuotedStringLiteral(rawTypeArg); + if (parsed === undefined) { + diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `${indexEntityLabel} type argument must be a quoted string literal`, + sourceId, + span: modelAttribute.span, + }); + continue; + } + indexType = parsed; + } + const rawOptionsArg = getNamedArgument(modelAttribute, 'options'); + let indexOptions: Record | undefined; + if (rawOptionsArg !== undefined) { + if (indexType === undefined) { + diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `${indexEntityLabel} options argument requires a type argument`, + sourceId, + span: modelAttribute.span, + }); + continue; + } + const parsed = parseObjectLiteralStringMap({ + raw: rawOptionsArg, + diagnostics, + sourceId, + span: modelAttribute.span, + entityLabel: indexEntityLabel, + }); + if (parsed === undefined) { + continue; + } + indexOptions = parsed; + } indexNodes.push({ columns: columnNames, ...ifDefined('name', constraintName), + ...ifDefined('type', indexType), + ...ifDefined('options', indexOptions), }); } continue; diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts index 07451d7ea4..4e4cc4224e 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts @@ -133,6 +133,132 @@ export function getPositionalArguments(attribute: PslAttribute): readonly string .map((arg) => (arg.kind === 'positional' ? arg.value : '')); } +/** + * Parses a PSL object-literal attribute argument value of the form + * `{ key1: "value1", key2: "value2" }` into a `Record`. + * + * V1 admits string literals only as leaf values. Boolean and number + * literals are rejected. Trailing commas are allowed. + * + * Returns the parsed record, or pushes a diagnostic and returns undefined + * on malformed input or non-string leaves. + */ +export function parseObjectLiteralStringMap(input: { + readonly raw: string; + readonly diagnostics: ContractSourceDiagnostic[]; + readonly sourceId: string; + readonly span: PslSpan; + readonly entityLabel: string; +}): Record | undefined { + const trimmed = input.raw.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return pushInvalidAttributeArgument({ + diagnostics: input.diagnostics, + sourceId: input.sourceId, + span: input.span, + message: `${input.entityLabel} expected an object literal value of the form { key: "value", ... }`, + }); + } + const body = trimmed.slice(1, -1).trim(); + if (body.length === 0) { + return {}; + } + const result: Record = {}; + for (const part of splitObjectLiteralEntries(body)) { + const colonAt = findTopLevelColon(part); + if (colonAt === -1) { + return pushInvalidAttributeArgument({ + diagnostics: input.diagnostics, + sourceId: input.sourceId, + span: input.span, + message: `${input.entityLabel} object-literal entry "${part}" is missing a "key: value" colon`, + }); + } + const key = part.slice(0, colonAt).trim(); + const rawValue = part.slice(colonAt + 1).trim(); + if (key.length === 0 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return pushInvalidAttributeArgument({ + diagnostics: input.diagnostics, + sourceId: input.sourceId, + span: input.span, + message: `${input.entityLabel} object-literal key "${key}" must be a bare identifier`, + }); + } + const parsedString = parseQuotedStringLiteral(rawValue); + if (parsedString === undefined) { + return pushInvalidAttributeArgument({ + diagnostics: input.diagnostics, + sourceId: input.sourceId, + span: input.span, + message: `${input.entityLabel} object-literal value for "${key}" must be a quoted string literal`, + }); + } + if (Object.hasOwn(result, key)) { + return pushInvalidAttributeArgument({ + diagnostics: input.diagnostics, + sourceId: input.sourceId, + span: input.span, + message: `${input.entityLabel} object-literal key "${key}" appears more than once`, + }); + } + result[key] = parsedString; + } + return result; +} + +function splitObjectLiteralEntries(body: string): readonly string[] { + const parts: string[] = []; + let depthBrace = 0; + let depthBracket = 0; + let depthParen = 0; + let quote: '"' | "'" | null = null; + let start = 0; + for (let index = 0; index < body.length; index += 1) { + const ch = body[index] ?? ''; + if (quote) { + if (ch === quote && body[index - 1] !== '\\') { + quote = null; + } + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '{') depthBrace += 1; + else if (ch === '}') depthBrace = Math.max(0, depthBrace - 1); + else if (ch === '[') depthBracket += 1; + else if (ch === ']') depthBracket = Math.max(0, depthBracket - 1); + else if (ch === '(') depthParen += 1; + else if (ch === ')') depthParen = Math.max(0, depthParen - 1); + else if (ch === ',' && depthBrace === 0 && depthBracket === 0 && depthParen === 0) { + const segment = body.slice(start, index).trim(); + if (segment.length > 0) parts.push(segment); + start = index + 1; + } + } + const tail = body.slice(start).trim(); + if (tail.length > 0) parts.push(tail); + return parts; +} + +function findTopLevelColon(entry: string): number { + let quote: '"' | "'" | null = null; + for (let index = 0; index < entry.length; index += 1) { + const ch = entry[index] ?? ''; + if (quote) { + if (ch === quote && entry[index - 1] !== '\\') quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === ':') return index; + } + return -1; +} + export function pushInvalidAttributeArgument(input: { readonly diagnostics: ContractSourceDiagnostic[]; readonly sourceId: string; diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts index 608b7fe373..9d37c72fbb 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts @@ -1,4 +1,6 @@ import { parsePslDocument } from '@prisma-next/psl-parser'; +import { defineIndexTypes } from '@prisma-next/sql-contract/index-types'; +import { type } from 'arktype'; import { describe, expect, it } from 'vitest'; import { type InterpretPslDocumentToSqlContractInput, @@ -10,6 +12,15 @@ import { postgresTarget, } from './fixtures'; +const testIndexPack = { + kind: 'extension', + id: 'test-index-pack', + familyId: 'sql', + targetId: 'postgres', + version: '0.0.1', + indexTypes: defineIndexTypes().add('bm25', { options: type('object') }), +} as const; + describe('interpretPslDocumentToSqlContract', () => { const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); const interpretPslDocumentToSqlContract = ( @@ -354,4 +365,176 @@ model Member { }, }); }); + + describe('@@index type and options', () => { + it('lowers @@index([body], type: "bm25", options: { key_field: "id" }) to an IR index node with type and options', () => { + const document = parsePslDocument({ + schema: `model Doc { + id Int @id + body String + @@index([body], type: "bm25", options: { key_field: "id" }, map: "doc_body_bm25_idx") +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + composedExtensionPacks: [testIndexPack.id], + composedExtensionPackRefs: [testIndexPack], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.storage).toMatchObject({ + tables: { + doc: { + indexes: [ + { + columns: ['body'], + name: 'doc_body_bm25_idx', + type: 'bm25', + options: { key_field: 'id' }, + }, + ], + }, + }, + }); + }); + + it('accepts a multi-key options object with string-literal leaves', () => { + const document = parsePslDocument({ + schema: `model Doc { + id Int @id + body String + @@index([body], type: "bm25", options: { key_field: "id", language: "en" }) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + composedExtensionPacks: [testIndexPack.id], + composedExtensionPackRefs: [testIndexPack], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.storage).toMatchObject({ + tables: { + doc: { + indexes: [{ type: 'bm25', options: { key_field: 'id', language: 'en' } }], + }, + }, + }); + }); + + it('rejects a non-string-literal leaf in options (boolean)', () => { + const document = parsePslDocument({ + schema: `model Doc { + id Int @id + body String + @@index([body], type: "bm25", options: { key_field: "id", fastupdate: false }) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect( + result.failure.diagnostics.some((d) => /must be a quoted string literal/.test(d.message)), + ).toBe(true); + }); + + it('rejects a non-string-literal leaf in options (number)', () => { + const document = parsePslDocument({ + schema: `model Doc { + id Int @id + body String + @@index([body], type: "bm25", options: { fillfactor: 70 }) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect( + result.failure.diagnostics.some((d) => /must be a quoted string literal/.test(d.message)), + ).toBe(true); + }); + + it('rejects an options argument with no surrounding type argument', () => { + const document = parsePslDocument({ + schema: `model Doc { + id Int @id + body String + @@index([body], options: { key_field: "id" }) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect( + result.failure.diagnostics.some((d) => + /options argument requires a type argument/.test(d.message), + ), + ).toBe(true); + }); + + it('rejects a malformed options object literal', () => { + const document = parsePslDocument({ + schema: `model Doc { + id Int @id + body String + @@index([body], type: "bm25", options: { not_an_assignment }) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect( + result.failure.diagnostics.some((d) => /missing a "key: value" colon/.test(d.message)), + ).toBe(true); + }); + + it('accepts @@index without type or options (existing behaviour unchanged)', () => { + const document = parsePslDocument({ + schema: `model Doc { + id Int @id + body String + @@index([body]) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.storage).toMatchObject({ + tables: { doc: { indexes: [{ columns: ['body'] }] } }, + }); + }); + }); }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index 1fb7a73ef6..bc50239d6f 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -162,7 +162,7 @@ const representativeTsAuthoring = `defineContract( relations: { author: rel.belongsTo(User, { from: 'authorId', to: 'id' }) }, }).sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.authorId, { name: 'post_author_id_idx' })], + indexes: [constraints.index([cols.authorId], { name: 'post_author_id_idx' })], foreignKeys: [constraints.foreignKey(cols.authorId, User.refs.id, { name: 'post_author_id_fkey', onDelete: 'cascade' })], })); return { types, models: { User, Post } }; @@ -203,7 +203,7 @@ function buildTsContract() { }, }).sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.authorId, { name: 'post_author_id_idx' })], + indexes: [constraints.index([cols.authorId], { name: 'post_author_id_idx' })], foreignKeys: [ constraints.foreignKey(cols.authorId, UserBase.refs.id, { name: 'post_author_id_fkey', diff --git a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index 6d622aa1bd..46de990ca6 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts @@ -18,6 +18,11 @@ import { type StorageHashBase, } from '@prisma-next/contract/types'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import { + createIndexTypeRegistry, + type IndexTypeMap, + type IndexTypeRegistration, +} from '@prisma-next/sql-contract/index-types'; import { applyFkDefaults, type SqlStorage, @@ -25,7 +30,7 @@ import { type StorageTable, type StorageTypeInstance, } from '@prisma-next/sql-contract/types'; -import { validateStorageSemantics } from '@prisma-next/sql-contract/validators'; +import { validateIndexTypes, validateStorageSemantics } from '@prisma-next/sql-contract/validators'; import { ifDefined } from '@prisma-next/utils/defined'; import type { ContractDefinition, @@ -64,11 +69,25 @@ function encodeColumnDefault( }; } -function assertStorageSemantics(storage: SqlStorage): void { - const semanticErrors = validateStorageSemantics(storage); +function assertStorageSemantics( + definition: ContractDefinition, + contract: Contract, +): void { + const semanticErrors = validateStorageSemantics(contract.storage); if (semanticErrors.length > 0) { throw new Error(`Contract semantic validation failed: ${semanticErrors.join('; ')}`); } + + const indexTypeRegistry = createIndexTypeRegistry(); + for (const pack of [definition.target, ...Object.values(definition.extensionPacks ?? {})]) { + const registration = (pack as { readonly indexTypes?: IndexTypeRegistration }) + .indexTypes; + if (!registration) continue; + for (const entry of registration.entries) { + indexTypeRegistry.register(entry); + } + } + validateIndexTypes(contract, indexTypeRegistry); } function assertKnownTargetModel( @@ -287,8 +306,8 @@ export function buildSqlContractFromDefinition( indexes: (semanticModel.indexes ?? []).map((i) => ({ columns: i.columns, ...ifDefined('name', i.name), - ...ifDefined('using', i.using), - ...ifDefined('config', i.config), + ...ifDefined('type', i.type), + ...ifDefined('options', i.options), })), foreignKeys, ...(semanticModel.id @@ -457,7 +476,7 @@ export function buildSqlContractFromDefinition( meta: {}, }; - assertStorageSemantics(contract.storage); + assertStorageSemantics(definition, contract); return contract; } diff --git a/packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts b/packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts index 3f438978fb..f7b344fbca 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts @@ -24,7 +24,14 @@ import type { TupleFromArgumentDescriptors, UnionToIntersection, } from './authoring-type-utils'; +import type { + AnyRelationBuilder, + ContractModelBuilder, + IndexTypeMap, + ScalarFieldBuilder, +} from './contract-dsl'; import { buildFieldPreset, field, model, rel } from './contract-dsl'; +import type { MergeExtensionIndexTypes } from './contract-types'; type ExtractTypeNamespaceFromPack = Pack extends { readonly authoring?: { readonly type?: infer Namespace extends AuthoringTypeNamespace }; @@ -91,6 +98,33 @@ type TypeHelpersFromNamespace = { type CoreFieldHelpers = Pick; +type MergeAllPackIndexTypes = MergeExtensionIndexTypes< + { readonly __family: Family; readonly __target: Target } & (ExtensionPacks extends Record< + string, + unknown + > + ? ExtensionPacks + : Record) +>; + +type PackAwareModel = { + < + const ModelName extends string, + Fields extends Record, + Relations extends Record = Record, + >( + modelName: ModelName, + input: { readonly fields: Fields; readonly relations?: Relations }, + ): ContractModelBuilder; + < + Fields extends Record, + Relations extends Record = Record, + >(input: { + readonly fields: Fields; + readonly relations?: Relations; + }): ContractModelBuilder; +}; + export type ComposedAuthoringHelpers< Family extends FamilyPackRef, Target extends TargetPackRef<'sql', string>, @@ -102,7 +136,7 @@ export type ComposedAuthoringHelpers< ExtractFieldNamespaceFromPack & MergeExtensionFieldNamespaces >; - readonly model: typeof model; + readonly model: PackAwareModel>; readonly rel: typeof rel; readonly type: TypeHelpersFromNamespace< ExtractTypeNamespaceFromPack & diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts index c3379e3ed9..c9da6d9f99 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts @@ -29,8 +29,8 @@ export interface UniqueConstraintNode { export interface IndexNode { readonly columns: readonly string[]; readonly name?: string; - readonly using?: string; - readonly config?: Record; + readonly type?: string; + readonly options?: Record; } export interface ForeignKeyNode { diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts index 5046553e1b..acb0ec9158 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts @@ -478,7 +478,7 @@ export type RelationState = | ManyToManyRelation; type AnyRelationState = RelationState; -type AnyRelationBuilder = RelationBuilder; +export type AnyRelationBuilder = RelationBuilder; type ApplyBelongsToRelationSqlSpec< State extends RelationState, @@ -550,11 +550,21 @@ type ConstraintOptions = { readonly name?: Name; }; -type IndexOptions = - ConstraintOptions & { - readonly using?: string; - readonly config?: Record; - }; +export type IndexTypeMap = Record; + +type IndexInput< + Name extends string | undefined, + IndexTypes extends IndexTypeMap, +> = keyof IndexTypes extends never + ? ConstraintOptions + : + | (ConstraintOptions & { readonly type?: never; readonly options?: never }) + | { + readonly [K in keyof IndexTypes & string]: ConstraintOptions & { + readonly type: K; + readonly options: IndexTypes[K]['options']; + }; + }[keyof IndexTypes & string]; type ForeignKeyOptions = ConstraintOptions & { @@ -590,8 +600,8 @@ export type IndexConstraint< readonly kind: 'index'; readonly fields: FieldNames; readonly name?: Name; - readonly using?: string; - readonly config?: Record; + readonly type?: string; + readonly options?: Record; }; export type ForeignKeyConstraint< @@ -636,7 +646,7 @@ function normalizeTargetFieldRefInput(input: TargetFieldRef | readonly TargetFie }; } -function createConstraintsDsl() { +function createConstraintsDsl>() { function ref( modelName: ModelName, fieldName: FieldName, @@ -687,24 +697,26 @@ function createConstraintsDsl() { }; } - function index( - field: ColumnRef, - options?: IndexOptions, - ): IndexConstraint; function index( fields: { readonly [K in keyof FieldNames]: ColumnRef }, - options?: IndexOptions, + options?: IndexInput, ): IndexConstraint; function index( - fieldOrFields: ColumnRef | readonly ColumnRef[], - options?: IndexOptions, + fields: readonly ColumnRef[], + options?: { + readonly name?: string; + readonly type?: string; + readonly options?: unknown; + }, ): IndexConstraint { return { kind: 'index', - fields: normalizeFieldRefInput(fieldOrFields), - ...(options?.name ? { name: options.name } : {}), - ...(options?.using ? { using: options.using } : {}), - ...(options?.config ? { config: options.config } : {}), + fields: normalizeFieldRefInput(fields), + ...(options?.name !== undefined ? { name: options.name } : {}), + ...(options?.type !== undefined ? { type: options.type } : {}), + ...(options?.options !== undefined + ? { options: options.options as Record } + : {}), }; } @@ -789,9 +801,26 @@ type AttributeContext> = { readonly constraints: Pick; }; -type SqlContext> = { +type PackAwareIndex = < + FieldNames extends readonly string[], + Name extends string | undefined = undefined, +>( + fields: { readonly [K in keyof FieldNames]: ColumnRef }, + options?: IndexInput, +) => IndexConstraint; + +type PackAwareSqlConstraints = { + readonly foreignKey: ConstraintsDsl['foreignKey']; + readonly ref: ConstraintsDsl['ref']; + readonly index: PackAwareIndex; +}; + +export type SqlContext< + Fields extends Record, + IndexTypes extends IndexTypeMap = Record, +> = { readonly cols: FieldRefs; - readonly constraints: Pick; + readonly constraints: PackAwareSqlConstraints; }; function createFieldRefs>( @@ -842,8 +871,10 @@ function createAttributeConstraintsDsl(): AttributeContext< }; } -function createSqlConstraintsDsl(): SqlContext>['constraints'] { - const constraints = createConstraintsDsl(); +function createSqlConstraintsDsl< + IndexTypes extends IndexTypeMap = Record, +>(): SqlContext, IndexTypes>['constraints'] { + const constraints = createConstraintsDsl(); return { index: constraints.index, foreignKey: constraints.foreignKey, @@ -959,12 +990,14 @@ export class ContractModelBuilder< Relations extends Record = Record, AttributesSpec extends ModelAttributesSpec | undefined = undefined, SqlSpec extends SqlStageSpec | undefined = undefined, + IndexTypes extends IndexTypeMap = Record, > { declare readonly __name: ModelName; declare readonly __fields: Fields; declare readonly __relations: Relations; declare readonly __attributes: AttributesSpec; declare readonly __sql: SqlSpec; + declare readonly __indexTypes: IndexTypes; readonly refs: ModelName extends string ? ModelTokenRefs : never; constructor( @@ -974,7 +1007,7 @@ export class ContractModelBuilder< readonly relations: Relations; }, readonly attributesFactory?: StageInput, AttributesSpec>, - readonly sqlFactory?: StageInput, SqlSpec>, + readonly sqlFactory?: StageInput, SqlSpec>, ) { this.refs = ( stageOne.modelName ? createModelTokenRefs(stageOne.modelName, stageOne.fields) : undefined @@ -983,7 +1016,7 @@ export class ContractModelBuilder< ref( this: ModelName extends string - ? ContractModelBuilder + ? ContractModelBuilder : never, fieldName: FieldName, ): TargetFieldRef { @@ -1002,7 +1035,14 @@ export class ContractModelBuilder< relations>( relations: NextRelations, - ): ContractModelBuilder { + ): ContractModelBuilder< + ModelName, + Fields, + Relations & NextRelations, + AttributesSpec, + SqlSpec, + IndexTypes + > { const duplicateRelationName = findDuplicateRelationName(this.stageOne.relations, relations); if (duplicateRelationName) { throw new Error( @@ -1028,15 +1068,15 @@ export class ContractModelBuilder< AttributeContext, ValidateAttributesStageSpec >, - ): ContractModelBuilder { + ): ContractModelBuilder { return new ContractModelBuilder(this.stageOne, specOrFactory, this.sqlFactory); } sql( - specOrFactory: StageInput, NextSqlSpec>, + specOrFactory: StageInput, NextSqlSpec>, ): [ValidateSqlStageSpec] extends [never] - ? ContractModelBuilder - : ContractModelBuilder { + ? ContractModelBuilder + : ContractModelBuilder { // Conditional return type cannot be verified by the implementation; the // runtime value is always a valid ContractModelBuilder regardless of the // validation outcome (validation is type-level only). @@ -1060,7 +1100,7 @@ export class ContractModelBuilder< } return buildStageSpec(this.sqlFactory, { cols: createColumnRefs(this.stageOne.fields), - constraints: createSqlConstraintsDsl() as SqlContext['constraints'], + constraints: createSqlConstraintsDsl(), }); } } diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts index d5d0ddecc5..3be2327a20 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts @@ -574,8 +574,8 @@ function resolveModelNode( const indexes = (spec.sqlSpec?.indexes ?? []).map((index) => ({ columns: mapFieldNamesToColumnNames(spec.modelName, index.fields, spec.fieldToColumn), ...(index.name ? { name: index.name } : {}), - ...(index.using ? { using: index.using } : {}), - ...(index.config ? { config: index.config } : {}), + ...(index.type ? { type: index.type } : {}), + ...(index.options ? { options: index.options } : {}), })) satisfies readonly IndexNode[]; const foreignKeys = resolveForeignKeyNodes(spec, allSpecs); const relations = Object.entries(spec.relations).map(([relationName, relationBuilder]) => diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts index 0043f06bfc..ff4415f7af 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts @@ -5,6 +5,7 @@ import type { StorageHashBase, } from '@prisma-next/contract/types'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; +import type { IndexTypeRegistration } from '@prisma-next/sql-contract/index-types'; import type { ContractWithTypeMaps, Index, @@ -34,6 +35,28 @@ type MergeExtensionCodecTypesSafe = : MergeExtensionCodecTypes : Record; +export type ExtractIndexTypesFromPack

= P extends { + readonly indexTypes: IndexTypeRegistration; +} + ? M + : Record; + +type AllIndexTypeLiterals = + Packs extends Record + ? { [K in keyof Packs]: keyof ExtractIndexTypesFromPack }[keyof Packs] & string + : never; + +export type MergeExtensionIndexTypes> = { + readonly [Lit in AllIndexTypeLiterals]: Extract< + { + [K in keyof Packs]: Lit extends keyof ExtractIndexTypesFromPack + ? ExtractIndexTypesFromPack[Lit] + : never; + }[keyof Packs], + { readonly options: unknown } + >; +}; + export type MergeExtensionPackRefs< Existing extends Record | undefined, Added extends Record>, @@ -64,6 +87,16 @@ type CodecTypesFromDefinition = ExtractCodecTypesFromPack< > & MergeExtensionCodecTypesSafe>; +type DefinitionTarget = Definition extends { readonly target: infer Target } + ? Target + : never; + +type AllPacks = DefinitionExtensionPacks & { + readonly __target: DefinitionTarget; +}; + +export type IndexTypesFromDefinition = MergeExtensionIndexTypes>; + type DefinitionModels = Definition extends { readonly models?: unknown; } diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.constraints.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.constraints.test.ts index 3842c30833..177da4a70b 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.constraints.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.constraints.test.ts @@ -2,6 +2,7 @@ import type { FamilyPackRef, TargetPackRef } from '@prisma-next/framework-compon import { describe, expect, it } from 'vitest'; import { type ContractInput, defineContract, field, model, rel } from '../src/contract-builder'; import { columnDescriptor } from './helpers/column-descriptor'; +import { testIndexPack } from './helpers/test-index-pack'; const int4Column = columnDescriptor('pg/int4@1'); const textColumn = columnDescriptor('pg/text@1'); @@ -122,7 +123,7 @@ describe('contract definition constraint support', () => { }, }).sql(({ cols, constraints }) => ({ table: 'user', - indexes: [constraints.index(cols.email)], + indexes: [constraints.index([cols.email])], })), }, }); @@ -141,7 +142,7 @@ describe('contract definition constraint support', () => { }, }).sql(({ cols, constraints }) => ({ table: 'user', - indexes: [constraints.index(cols.email, { name: 'user_email_idx' })], + indexes: [constraints.index([cols.email], { name: 'user_email_idx' })], })), }, }); @@ -230,13 +231,44 @@ describe('contract definition constraint support', () => { }, }).sql(({ cols, constraints }) => ({ table: 'user', - indexes: [constraints.index(cols.id, { name: 'user_pkey' })], + indexes: [constraints.index([cols.id], { name: 'user_pkey' })], })), }, }), ).toThrow(/Contract semantic validation failed:.*user_pkey/); }); + it('throws at authoring time when an index uses an unregistered type', () => { + expect(() => + defineContract( + { + family: bareFamilyPack, + target: postgresTargetPack, + extensionPacks: { testIndexes: testIndexPack }, + }, + ({ model: helperModel, field: helperField }) => ({ + models: { + Doc: helperModel('Doc', { + fields: { + id: helperField.column(int4Column).id(), + body: helperField.column(textColumn), + }, + }).sql(({ cols, constraints }) => ({ + table: 'doc', + indexes: [ + constraints.index([cols.body], { + // @ts-expect-error - exercise the authoring-time runtime validator on an unregistered type literal. + type: 'made-up', + options: {}, + }), + ], + })), + }, + }), + ), + ).toThrow(/unregistered index type "made-up"/); + }); + it('supports multiple constraints on the same table', () => { const contract = defineTestContract({ models: { @@ -248,7 +280,7 @@ describe('contract definition constraint support', () => { }, }).sql(({ cols, constraints }) => ({ table: 'user', - indexes: [constraints.index(cols.email), constraints.index(cols.username)], + indexes: [constraints.index([cols.email]), constraints.index([cols.username])], })), }, }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts index 90d89294d7..c7b3ec1e6b 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts @@ -123,7 +123,7 @@ describe('contract DSL authoring surface', () => { }, }).sql(({ cols, constraints }) => ({ table: 'blog_post', - indexes: [constraints.index(cols.userId, { name: 'blog_post_user_id_idx' })], + indexes: [constraints.index([cols.userId], { name: 'blog_post_user_id_idx' })], foreignKeys: [ constraints.foreignKey([cols.userId], [UserBase.refs['id']!], { name: 'blog_post_user_id_fkey', @@ -396,7 +396,7 @@ describe('contract DSL authoring surface', () => { }, }).sql(({ cols, constraints }) => ({ table: 'app_user', - indexes: [constraints.index(cols.id, { name: 'app_user_pkey' })], + indexes: [constraints.index([cols.id], { name: 'app_user_pkey' })], })); expect(() => @@ -514,7 +514,7 @@ describe('contract DSL authoring surface', () => { authorId: field.column(textColumn).column('author_identifier'), }, }).sql(({ cols, constraints }) => ({ - indexes: [constraints.index(cols.authorId, { name: 'blog_post_author_identifier_idx' })], + indexes: [constraints.index([cols.authorId], { name: 'blog_post_author_identifier_idx' })], })); const contract = defineTestContract({ @@ -753,7 +753,7 @@ describe('contract DSL authoring surface', () => { User.ref('posts'); return { - indexes: [constraints.index(cols.email)], + indexes: [constraints.index([cols.email])], }; }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.index-types.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.index-types.test.ts new file mode 100644 index 0000000000..7d829c86e1 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.index-types.test.ts @@ -0,0 +1,68 @@ +import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; +import type { IndexTypeRegistration } from '@prisma-next/sql-contract/index-types'; +import { describe, expectTypeOf, it } from 'vitest'; +import type { + ExtractIndexTypesFromPack, + IndexTypesFromDefinition, + MergeExtensionIndexTypes, +} from '../src/contract-types'; + +type DemoIndexTypes = { + readonly demo: { readonly options: { readonly fillfactor: number } }; +}; + +type AnalyticsIndexTypes = { + readonly analytics: { readonly options: { readonly bucket: string } }; +}; + +type DemoPack = ExtensionPackRef<'sql', 'postgres'> & { + readonly indexTypes: IndexTypeRegistration; +}; + +type AnalyticsPack = ExtensionPackRef<'sql', 'postgres'> & { + readonly indexTypes: IndexTypeRegistration; +}; + +describe('index-type pack threading', () => { + it("ExtractIndexTypesFromPack pulls the registration's IndexTypes off a pack", () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('ExtractIndexTypesFromPack returns an empty record for packs without indexTypes', () => { + type PlainPack = ExtensionPackRef<'sql', 'postgres'>; + expectTypeOf>().toEqualTypeOf>(); + }); + + it('MergeExtensionIndexTypes intersects across multiple packs', () => { + type ExtractedDemo = ExtractIndexTypesFromPack; + type ExtractedAnalytics = ExtractIndexTypesFromPack; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + type Merged = MergeExtensionIndexTypes<{ + demo: DemoPack; + analytics: AnalyticsPack; + }>; + type DemoOptions = Merged['demo']['options']; + type AnalyticsOptions = Merged['analytics']['options']; + expectTypeOf().toEqualTypeOf<{ readonly fillfactor: number }>(); + expectTypeOf().toEqualTypeOf<{ readonly bucket: string }>(); + }); + + it('IndexTypesFromDefinition merges target + extension packs', () => { + type Definition = { + readonly target: TargetPackRef<'sql', 'postgres'>; + readonly extensionPacks: { readonly demo: DemoPack; readonly analytics: AnalyticsPack }; + }; + type Resolved = IndexTypesFromDefinition; + type DemoOptions = Resolved['demo']['options']; + type AnalyticsOptions = Resolved['analytics']['options']; + expectTypeOf().toEqualTypeOf<{ readonly fillfactor: number }>(); + expectTypeOf().toEqualTypeOf<{ readonly bucket: string }>(); + }); + + it('IndexTypesFromDefinition is an empty record when no packs contribute', () => { + type Definition = { readonly target: TargetPackRef<'sql', 'postgres'> }; + type Resolved = IndexTypesFromDefinition; + expectTypeOf().toEqualTypeOf>(); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.normalization.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.normalization.test.ts index cc1f74b0c4..063e57a0b5 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.normalization.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.normalization.test.ts @@ -2,6 +2,7 @@ import type { FamilyPackRef, TargetPackRef } from '@prisma-next/framework-compon import { describe, expect, it } from 'vitest'; import { defineContract, field, model } from '../src/contract-builder'; import { columnDescriptor } from './helpers/column-descriptor'; +import { testIndexPack } from './helpers/test-index-pack'; const int4Column = columnDescriptor('pg/int4@1'); const textColumn = columnDescriptor('pg/text@1'); @@ -166,83 +167,41 @@ describe('contract builder normalization', () => { expect(contract.storage.tables.user.columns.email.nullable).toBe(false); }); - it('passes through extension-owned index fields (using, config)', () => { - const contract = defineContract({ - family: bareFamilyPack, - target: postgresTargetPack, - models: { - Item: model('Item', { - fields: { - id: field.column(int4Column).id(), - description: field.column(textColumn), - }, - }).sql(({ cols, constraints }) => ({ - table: 'items', - indexes: [ - constraints.index(cols.description, { - name: 'search_idx', - using: 'bm25', - config: { - keyField: 'id', - fields: [{ column: 'description', tokenizer: 'simple' }], - }, - }), - ], - })), + it('passes type and options on indexes through to storage IR', () => { + const contract = defineContract( + { + family: bareFamilyPack, + target: postgresTargetPack, + extensionPacks: { testIndexes: testIndexPack }, }, - }); + ({ model, field }) => ({ + models: { + Item: model('Item', { + fields: { + id: field.column(int4Column).id(), + description: field.column(textColumn), + }, + }).sql(({ cols, constraints }) => ({ + table: 'items', + indexes: [ + constraints.index([cols.description], { + name: 'search_idx', + type: 'bm25', + options: { key_field: 'id' }, + }), + ], + })), + }, + }), + ); const indexes = contract.storage.tables.items.indexes; expect(indexes).toHaveLength(1); - expect(indexes[0]).toMatchObject({ + expect(indexes[0]).toEqual({ columns: ['description'], - using: 'bm25', + type: 'bm25', name: 'search_idx', - config: { - keyField: 'id', - fields: [{ column: 'description', tokenizer: 'simple' }], - }, - }); - }); - - it('passes through extension index config with expression fields', () => { - const contract = defineContract({ - family: bareFamilyPack, - target: postgresTargetPack, - models: { - Item: model('Item', { - fields: { - id: field.column(int4Column).id(), - description: field.column(textColumn), - }, - }).sql(({ cols, constraints }) => ({ - table: 'items', - indexes: [ - constraints.index(cols.description, { - using: 'bm25', - config: { - keyField: 'id', - fields: [ - { column: 'description' }, - { - expression: "description || ' ' || category", - alias: 'concat', - tokenizer: 'simple', - }, - ], - }, - }), - ], - })), - }, - }); - - const idx = contract.storage.tables.items.indexes[0]!; - expect(idx.using).toBe('bm25'); - expect((idx.config as { fields: readonly unknown[] }).fields).toHaveLength(2); - expect((idx.config as { fields: readonly unknown[] }).fields[1]).toMatchObject({ - expression: "description || ' ' || category", - alias: 'concat', + options: { key_field: 'id' }, }); }); @@ -258,7 +217,7 @@ describe('contract builder normalization', () => { }, }).sql(({ cols, constraints }) => ({ table: 'user', - indexes: [constraints.index(cols.email)], + indexes: [constraints.index([cols.email])], })), }, }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts index 53e58adb3c..d66a48abac 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts @@ -1,16 +1,24 @@ -import type { TargetPackRef } from '@prisma-next/framework-components/components'; +import type { FamilyPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import { describe, expect, it } from 'vitest'; +import { createComposedAuthoringHelpers } from '../src/composed-authoring-helpers'; import { applyNaming, field, isContractInput, - model, normalizeRelationFieldNames, rel, resolveRelationModelName, type TargetFieldRef, } from '../src/contract-dsl'; import { columnDescriptor } from './helpers/column-descriptor'; +import { testIndexPack } from './helpers/test-index-pack'; + +const bareFamilyPack: FamilyPackRef<'sql'> = { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '0.0.1', +}; const postgresTargetPack: TargetPackRef<'sql', 'postgres'> = { kind: 'target', @@ -20,6 +28,12 @@ const postgresTargetPack: TargetPackRef<'sql', 'postgres'> = { version: '0.0.1', }; +const { model } = createComposedAuthoringHelpers({ + family: bareFamilyPack, + target: postgresTargetPack, + extensionPacks: { testIndexes: testIndexPack }, +}); + const int4Column = columnDescriptor('pg/int4@1'); const textColumn = columnDescriptor('pg/text@1'); const charColumn = columnDescriptor('sql/char@1', 'character'); @@ -123,10 +137,10 @@ describe('contract DSL runtime helpers', () => { }, }).sql(({ cols, constraints }) => ({ indexes: [ - constraints.index(cols.teamId, { + constraints.index([cols.teamId], { name: 'audit_log_team_id_idx', - using: 'hash', - config: { fillfactor: 70 }, + type: 'hash', + options: { fillfactor: 70 }, }), ], foreignKeys: [ @@ -146,8 +160,8 @@ describe('contract DSL runtime helpers', () => { kind: 'index', fields: ['teamId'], name: 'audit_log_team_id_idx', - using: 'hash', - config: { fillfactor: 70 }, + type: 'hash', + options: { fillfactor: 70 }, }, ], foreignKeys: [ diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-lowering.runtime.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-lowering.runtime.test.ts index f3bcfb55c3..47d691d8fe 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-lowering.runtime.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-lowering.runtime.test.ts @@ -1,9 +1,11 @@ import type { FamilyPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import { describe, expect, it } from 'vitest'; -import { field, model, rel } from '../src/contract-builder'; +import { createComposedAuthoringHelpers } from '../src/composed-authoring-helpers'; +import { field, rel } from '../src/contract-builder'; import { ContractModelBuilder, ScalarFieldBuilder } from '../src/contract-dsl'; import { buildContractDefinition } from '../src/contract-lowering'; import { columnDescriptor } from './helpers/column-descriptor'; +import { testIndexPack } from './helpers/test-index-pack'; const bareFamilyPack: FamilyPackRef<'sql'> = { kind: 'family', @@ -23,6 +25,12 @@ const postgresTargetPack: TargetPackRef<'sql', 'postgres'> = { const int4Column = columnDescriptor('pg/int4@1'); const textColumn = columnDescriptor('pg/text@1'); +const { model } = createComposedAuthoringHelpers({ + family: bareFamilyPack, + target: postgresTargetPack, + extensionPacks: { testIndexes: testIndexPack }, +}); + function buildDefinition( definition: Omit[0], 'target' | 'family'>, ) { @@ -409,10 +417,10 @@ describe('contract definition lowering runtime checks', () => { }, }).sql(({ cols, constraints }) => ({ indexes: [ - constraints.index(cols.userId, { + constraints.index([cols.userId], { name: 'membership_user_id_idx', - using: 'hash', - config: { fillfactor: 70 }, + type: 'hash', + options: { fillfactor: 70 }, }), ], })); @@ -432,8 +440,8 @@ describe('contract definition lowering runtime checks', () => { { columns: ['userId'], name: 'membership_user_id_idx', - using: 'hash', - config: { fillfactor: 70 }, + type: 'hash', + options: { fillfactor: 70 }, }, ]); expect(membership?.foreignKeys).toEqual([ diff --git a/packages/2-sql/2-authoring/contract-ts/test/helpers/test-index-pack.ts b/packages/2-sql/2-authoring/contract-ts/test/helpers/test-index-pack.ts new file mode 100644 index 0000000000..ff13b10e05 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/test/helpers/test-index-pack.ts @@ -0,0 +1,15 @@ +import { defineIndexTypes } from '@prisma-next/sql-contract/index-types'; +import { type } from 'arktype'; + +const testIndexTypes = defineIndexTypes() + .add('bm25', { options: type('object') }) + .add('hash', { options: type('object') }); + +export const testIndexPack = { + kind: 'extension', + id: 'test-index-pack', + familyId: 'sql', + targetId: 'postgres', + version: '0.0.1', + indexTypes: testIndexTypes, +} as const; diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index d0db6a81d1..7bc7843ded 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -248,11 +248,12 @@ export const sqlEmission = { const indexes = table.indexes .map((i) => { const cols = i.columns.map((c: string) => serializeValue(c)).join(', '); - const name = i.name ? `; readonly name: ${serializeValue(i.name)}` : ''; - const using = i.using !== undefined ? `; readonly using: ${serializeValue(i.using)}` : ''; - const config = - i.config !== undefined ? `; readonly config: ${serializeValue(i.config)}` : ''; - return `{ readonly columns: readonly [${cols}]${name}${using}${config} }`; + const name = i.name !== undefined ? `; readonly name: ${serializeValue(i.name)}` : ''; + const indexType = + i.type !== undefined ? `; readonly type: ${serializeValue(i.type)}` : ''; + const indexOptions = + i.options !== undefined ? `; readonly options: ${serializeValue(i.options)}` : ''; + return `{ readonly columns: readonly [${cols}]${name}${indexType}${indexOptions} }`; }) .join(', '); tableParts.push(`indexes: readonly [${indexes}]`); diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts index 7c729723f3..9f4c03bc3b 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts @@ -873,9 +873,9 @@ describe('sql-target-family-hook', () => { indexes: [ { columns: ['description'], - using: 'bm25', + type: 'bm25', name: 'search_idx', - config: { + options: { keyField: 'id', fields: [ { @@ -894,8 +894,8 @@ describe('sql-target-family-hook', () => { }); const types = generateContractDts(ir, sqlEmission, [], [], testHashes); - expect(types).toContain("readonly using: 'bm25'"); - expect(types).toContain("readonly config: { readonly keyField: 'id'"); + expect(types).toContain("readonly type: 'bm25'"); + expect(types).toContain("readonly options: { readonly keyField: 'id'"); expect(types).toContain("readonly name: 'search_idx'"); expect(types).toContain("readonly column: 'description'"); expect(types).toContain("readonly tokenizer: 'simple'"); @@ -918,8 +918,8 @@ describe('sql-target-family-hook', () => { indexes: [ { columns: ['description'], - using: 'bm25', - config: { + type: 'bm25', + options: { keyField: 'id', fields: [ { @@ -958,8 +958,8 @@ describe('sql-target-family-hook', () => { indexes: [ { columns: ['description'], - using: 'bm25', - config: { + type: 'bm25', + options: { keyField: 'id', 'min-token-size': 2, fields: [{ column: 'description', tokenizerParams: { 'max-ngram': 5 } }], diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.structure.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.structure.test.ts index 07f7dae8b3..ea55b67406 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.structure.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.structure.test.ts @@ -819,8 +819,8 @@ describe('sql-target-family-hook', () => { indexes: [ { columns: ['description'], - using: 'bm25', - config: { + type: 'bm25', + options: { keyField: 'id', fields: [{ column: 'description', tokenizer: 'simple' }], }, @@ -837,7 +837,7 @@ describe('sql-target-family-hook', () => { }).not.toThrow(); }); - it('still validates index column references independent of extension config', () => { + it('still validates index column references independent of extension options', () => { const ir = createContract({ storage: { tables: { @@ -851,8 +851,8 @@ describe('sql-target-family-hook', () => { indexes: [ { columns: ['nonexistent'], - using: 'bm25', - config: { + type: 'bm25', + options: { keyField: 'id', fields: [{ expression: "description || ' ' || category", tokenizer: 'simple' }], }, diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts b/packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts index 8ef469b890..a7071e357f 100644 --- a/packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts +++ b/packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts @@ -21,6 +21,39 @@ import type { } from '@prisma-next/sql-schema-ir/types'; import type { ComponentDatabaseDependency } from '../migrations/types'; +function indexOptionsLooselyEqual( + a: Record | undefined, + b: Record | undefined, +): boolean { + const aKeys = a ? Object.keys(a).sort() : []; + const bKeys = b ? Object.keys(b).sort() : []; + if (aKeys.length !== bKeys.length) return false; + for (let i = 0; i < aKeys.length; i += 1) { + if (aKeys[i] !== bKeys[i]) return false; + } + if (aKeys.length === 0) return true; + for (const key of aKeys) { + // Postgres introspection returns reloptions values as raw strings (e.g. + // `'70'`, `'false'`), while contract option leaves are typed (number, + // boolean, string). Compare via String() so a contract `fillfactor: 70` + // matches an introspected `fillfactor: '70'` without a spurious mismatch. + if ( + String((a as Record)[key]) !== String((b as Record)[key]) + ) { + return false; + } + } + return true; +} + +function indexExtrasMatch( + contractIndex: Index, + schemaIndex: { readonly type?: string; readonly options?: Record }, +): boolean { + if ((contractIndex.type ?? null) !== (schemaIndex.type ?? null)) return false; + return indexOptionsLooselyEqual(contractIndex.options, schemaIndex.options); +} + /** * Compares two arrays of strings for equality (order-sensitive). */ @@ -391,13 +424,19 @@ export function verifyIndexes( // Check for any matching index (unique or non-unique) // A unique index can satisfy a non-unique index requirement (stronger satisfies weaker) - const matchingIndex = schemaIndexes.find((idx) => - arraysEqual(idx.columns, contractIndex.columns), + const matchingIndex = schemaIndexes.find( + (idx) => + arraysEqual(idx.columns, contractIndex.columns) && indexExtrasMatch(contractIndex, idx), ); - // Also check if a unique constraint satisfies the index requirement + // Also check if a unique constraint satisfies the index requirement. + // Unique constraints carry no type/options of their own, so they can only + // satisfy a contract index that doesn't request a specific type/options. const matchingUniqueConstraint = - !matchingIndex && schemaUniques.find((u) => arraysEqual(u.columns, contractIndex.columns)); + !matchingIndex && + contractIndex.type === undefined && + contractIndex.options === undefined && + schemaUniques.find((u) => arraysEqual(u.columns, contractIndex.columns)); if (!matchingIndex && !matchingUniqueConstraint) { issues.push({ @@ -442,8 +481,9 @@ export function verifyIndexes( continue; } - const matchingIndex = contractIndexes.find((idx) => - arraysEqual(idx.columns, schemaIndex.columns), + const matchingIndex = contractIndexes.find( + (idx) => + arraysEqual(idx.columns, schemaIndex.columns) && indexExtrasMatch(idx, schemaIndex), ); if (!matchingIndex) { diff --git a/packages/2-sql/9-family/test/schema-verify.helpers.ts b/packages/2-sql/9-family/test/schema-verify.helpers.ts index 6c8c4e236b..dc403bf322 100644 --- a/packages/2-sql/9-family/test/schema-verify.helpers.ts +++ b/packages/2-sql/9-family/test/schema-verify.helpers.ts @@ -84,7 +84,12 @@ export function createContractTable( index?: boolean; }>; uniques?: ReadonlyArray<{ columns: readonly string[]; name?: string }>; - indexes?: ReadonlyArray<{ columns: readonly string[]; name?: string }>; + indexes?: ReadonlyArray<{ + columns: readonly string[]; + name?: string; + type?: string; + options?: Record; + }>; }, ): StorageTable { const result: StorageTable = { @@ -130,7 +135,13 @@ export function createSchemaTable( onUpdate?: SqlReferentialAction; }>; uniques?: ReadonlyArray<{ columns: readonly string[]; name?: string }>; - indexes?: ReadonlyArray<{ columns: readonly string[]; unique: boolean; name?: string }>; + indexes?: ReadonlyArray<{ + columns: readonly string[]; + unique: boolean; + name?: string; + type?: string; + options?: Record; + }>; }, ): SqlTableIR { const result: SqlTableIR = { diff --git a/packages/2-sql/9-family/test/schema-verify.semantic-satisfaction.test.ts b/packages/2-sql/9-family/test/schema-verify.semantic-satisfaction.test.ts index df79f400f7..3932ac6ab4 100644 --- a/packages/2-sql/9-family/test/schema-verify.semantic-satisfaction.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.semantic-satisfaction.test.ts @@ -334,6 +334,142 @@ describe('verifySqlSchema - semantic satisfaction', () => { }), ); }); + + it('fails when contract index type differs from schema index type (same columns)', () => { + const contract = createTestContract({ + doc: createContractTable( + { + id: { nativeType: 'int4', nullable: false }, + body: { nativeType: 'text', nullable: false }, + }, + { indexes: [{ columns: ['body'], type: 'gin' }] }, + ), + }); + + const schema = createTestSchemaIR({ + doc: createSchemaTable( + 'doc', + { + id: { nativeType: 'int4', nullable: false }, + body: { nativeType: 'text', nullable: false }, + }, + { indexes: [{ columns: ['body'], unique: false, name: 'doc_body_idx' }] }, + ), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: true, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + }); + + expect(result.ok).toBe(false); + expect(result.schema.issues).toContainEqual( + expect.objectContaining({ kind: 'index_mismatch', table: 'doc' }), + ); + expect(result.schema.issues).toContainEqual( + expect.objectContaining({ kind: 'extra_index', table: 'doc' }), + ); + }); + + it('fails when contract index options differ from schema index options', () => { + const contract = createTestContract({ + doc: createContractTable( + { + id: { nativeType: 'int4', nullable: false }, + body: { nativeType: 'text', nullable: false }, + }, + { + indexes: [{ columns: ['body'], type: 'gin', options: { fastupdate: false } }], + }, + ), + }); + + const schema = createTestSchemaIR({ + doc: createSchemaTable( + 'doc', + { + id: { nativeType: 'int4', nullable: false }, + body: { nativeType: 'text', nullable: false }, + }, + { + indexes: [ + { + columns: ['body'], + unique: false, + name: 'doc_body_idx', + type: 'gin', + options: { fastupdate: true }, + }, + ], + }, + ), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: true, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + }); + + expect(result.ok).toBe(false); + expect(result.schema.issues).toContainEqual( + expect.objectContaining({ kind: 'index_mismatch', table: 'doc' }), + ); + expect(result.schema.issues).toContainEqual( + expect.objectContaining({ kind: 'extra_index', table: 'doc' }), + ); + }); + + it('passes when contract type/options match schema type/options exactly', () => { + const contract = createTestContract({ + doc: createContractTable( + { + id: { nativeType: 'int4', nullable: false }, + body: { nativeType: 'text', nullable: false }, + }, + { + indexes: [{ columns: ['body'], type: 'gin', options: { fastupdate: false } }], + }, + ), + }); + + const schema = createTestSchemaIR({ + doc: createSchemaTable( + 'doc', + { + id: { nativeType: 'int4', nullable: false }, + body: { nativeType: 'text', nullable: false }, + }, + { + indexes: [ + { + columns: ['body'], + unique: false, + name: 'doc_body_idx', + type: 'gin', + options: { fastupdate: false }, + }, + ], + }, + ), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: true, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + }); + + expect(result.ok).toBe(true); + expect(result.schema.issues).toHaveLength(0); + }); }); describe('foreign key', () => { diff --git a/packages/3-extensions/paradedb/README.md b/packages/3-extensions/paradedb/README.md index fadfcb2c1b..104bbd96cf 100644 --- a/packages/3-extensions/paradedb/README.md +++ b/packages/3-extensions/paradedb/README.md @@ -4,21 +4,21 @@ ParadeDB full-text search extension pack for Prisma Next. ## Overview -This extension pack adds support for [ParadeDB](https://docs.paradedb.com/) BM25 full-text search indexes in the contract authoring layer. It keeps BM25 metadata inside extension-owned index `config` payloads, so `contract.json` and `contract.d.ts` carry the full search schema without hard-coding ParadeDB types into core SQL index IR. +This extension pack registers a `'bm25'` index type with the SQL family's index-type registry, so contracts can author BM25 full-text search indexes via the standard `constraints.index(...)` surface and the Postgres adapter emits `CREATE INDEX ... USING bm25 WITH (...)` DDL. -This is the **contract-only foundation**. Query-plane support (`@@@` operator, `pdb.*` functions) and migration-plane support (`CREATE INDEX ... USING bm25` DDL generation) are planned as follow-up work. +The v1 surface covers the `key_field` storage parameter only. Per-field tokenizer and column configuration is deferred to expression-index support. ## Responsibilities -- **BM25 Index Authoring**: Typed `bm25.*` field builders for defining BM25 indexes in `contract.ts` -- **Tokenizer Catalog**: `TokenizerId` type union covering all 12 built-in ParadeDB tokenizers -- **Extension Descriptor**: Declares `paradedb/bm25` capability for contract-level feature detection -- **Pack Ref Export**: Ships a pure `/pack` entrypoint for TypeScript contract authoring +- **bm25 index registration**: declares a `'bm25'` entry via `defineIndexTypes()` carrying an arktype validator for the bm25 options shape +- **Extension descriptor**: declares the `paradedb/bm25` capability for contract-level feature detection +- **Pack ref export**: ships a pure `/pack` entrypoint for TypeScript contract authoring ## Dependencies -- **`@prisma-next/contract`**: Core contract types -- **`@prisma-next/contract-authoring`**: Column type descriptor interface +- **`@prisma-next/sql-contract`**: index-type registry primitive +- **`@prisma-next/contract`** / **`@prisma-next/contract-authoring`**: core contract types +- **`arktype`**: option-shape validation ## Installation @@ -28,15 +28,14 @@ pnpm add @prisma-next/extension-paradedb ## Usage -### Contract Definition +### Contract definition -Define BM25 indexes on your tables using `bm25` field builders plus `bm25Index()`: +Author bm25 indexes via the standard `constraints.index(...)` surface; the registered `'bm25'` entry narrows `options` per-`type`: ```typescript -import { int4Column, textColumn, jsonbColumn } from '@prisma-next/adapter-postgres/column-types'; +import { int4Column, textColumn } from '@prisma-next/adapter-postgres/column-types'; import sqlFamily from '@prisma-next/family-sql/pack'; import { defineContract, field, model } from '@prisma-next/sql-contract-ts/contract-builder'; -import { bm25, bm25Index } from '@prisma-next/extension-paradedb/index-types'; import paradedb from '@prisma-next/extension-paradedb/pack'; import postgres from '@prisma-next/target-postgres/pack'; @@ -48,121 +47,40 @@ export const contract = defineContract({ Item: model('Item', { fields: { id: field.column(int4Column).id(), - description: field.column(textColumn), - category: field.column(textColumn), - rating: field.column(int4Column), - metadata: field.column(jsonbColumn), + body: field.column(textColumn), }, - }).sql({ + }).sql(({ cols, constraints }) => ({ table: 'items', indexes: [ - bm25Index({ - keyField: 'id', - fields: [ - bm25.text('description', { tokenizer: 'simple', stemmer: 'english' }), - bm25.text('category'), - bm25.numeric('rating'), - bm25.json('metadata', { tokenizer: 'ngram', min: 2, max: 3 }), - ], - name: 'search_idx', + constraints.index(cols.body, { + name: 'item_body_bm25_idx', + type: 'bm25', + options: { key_field: 'id' }, }), ], - }), + })), }, }); ``` -### Field Builders +### key_field -The `bm25` namespace provides typed field builders that produce `Bm25FieldConfig` objects: - -| Builder | Description | Tokenizer support | -|---------|-------------|-------------------| -| `bm25.text(column, opts?)` | Text field (`text`, `varchar`) | Yes — any tokenizer + stemmer, remove_emojis | -| `bm25.numeric(column)` | Numeric field (filterable, sortable) | No | -| `bm25.boolean(column)` | Boolean field | No | -| `bm25.json(column, opts?)` | JSON/JSONB field | Yes — tokenizer + ngram params | -| `bm25.datetime(column)` | Timestamp/date field | No | -| `bm25.range(column)` | Range field | No | -| `bm25.expression(sql, opts)` | Raw SQL expression | Yes — `alias` required | - -### Expression-Based Fields - -For computed or JSON sub-field indexing, use `bm25.expression()` with a raw SQL string: - -```typescript -.index( - bm25Index({ - keyField: 'id', - fields: [ - bm25.text('description'), - bm25.expression("description || ' ' || category", { - alias: 'concat', - tokenizer: 'simple', - }), - bm25.expression("(metadata->>'color')", { - alias: 'meta_color', - tokenizer: 'ngram', - min: 2, - max: 3, - }), - ], - }), -) -``` - -### keyField Behavior - -ParadeDB BM25 indexes require a `key_field` — a unique column that identifies each document: - -- **Required**: Set `keyField` explicitly in `bm25Index(...)`. -- **Recommended**: Use the table primary key in most cases. -- **Override**: You can choose another unique column when needed. - -```typescript -.index(bm25Index({ keyField: 'id', fields: [bm25.text('body')] })) -.index(bm25Index({ keyField: 'uuid', fields: [bm25.text('body')] })) -``` - -## Tokenizers - -All 12 built-in ParadeDB tokenizers are available via the `TokenizerId` type: - -| Tokenizer | Description | -|-----------|-------------| -| `unicode_words` | Default. Unicode word boundaries (UAX #29). Lowercases. | -| `simple` | Splits on non-alphanumeric. Lowercases. | -| `ngram` | Character n-grams of configurable length. | -| `icu` | ICU Unicode standard segmentation. Multilingual. | -| `regex_pattern` | Regex-based tokenization. | -| `source_code` | camelCase / snake_case splitting. | -| `literal` | No splitting. Exact match, sort, aggregation. | -| `literal_normalized` | Literal + lowercase + token filters. | -| `whitespace` | Whitespace splitting + lowercase. | -| `chinese_compatible` | CJK-aware word segmentation. | -| `jieba` | Chinese segmentation via Jieba. | -| `lindera` | Japanese/Korean/Chinese via Lindera. | +ParadeDB BM25 indexes require a `key_field` — a unique column that identifies each document. It is required, must be a string, and is typically (but not always) the table's primary key. ## Capabilities -The extension declares: - -- `paradedb/bm25`: Indicates support for BM25 full-text search indexes - -## Not Yet Implemented +- `paradedb/bm25` — indicates support for BM25 full-text search indexes -The following are planned for follow-up work: +## Not yet implemented -- **Query plane**: `@@@` operator support in the sql-orm query builder -- **Query plane**: `pdb.*` query builder functions (match, term, phrase, fuzzy, etc.) -- **Migration plane**: `CREATE INDEX ... USING bm25` DDL generation from contract diffs -- **Runtime**: Scoring, aggregation, and highlight functions -- **Database dependencies**: `CREATE EXTENSION pg_search` via migration planner +- Per-column / per-expression tokenizer configuration (deferred to expression-index support) +- `@@@` operator and `pdb.*` query builder functions +- `CREATE EXTENSION pg_search` via migration planner +- Scoring, aggregation, and highlight functions ## References - [ParadeDB documentation](https://docs.paradedb.com/) - [ParadeDB CREATE INDEX](https://docs.paradedb.com/documentation/indexing/create-index) -- [ParadeDB Tokenizers](https://docs.paradedb.com/documentation/tokenizers/overview) -- [pg_search source](https://github.com/paradedb/paradedb/tree/main/pg_search) +- [ADR 210 — Index-type registry](../../../docs/architecture%20docs/adrs/ADR%20210%20-%20Index-type%20registry.md) - [Prisma Next Architecture Overview](../../../docs/Architecture%20Overview.md) diff --git a/packages/3-extensions/paradedb/package.json b/packages/3-extensions/paradedb/package.json index 7d94a3d4e6..57e18eb69e 100644 --- a/packages/3-extensions/paradedb/package.json +++ b/packages/3-extensions/paradedb/package.json @@ -15,9 +15,20 @@ }, "dependencies": { "@prisma-next/contract": "workspace:*", - "@prisma-next/contract-authoring": "workspace:*" + "@prisma-next/contract-authoring": "workspace:*", + "@prisma-next/family-sql": "workspace:*", + "@prisma-next/framework-components": "workspace:*", + "@prisma-next/sql-contract": "workspace:*", + "@prisma-next/sql-operations": "workspace:*", + "@prisma-next/sql-relational-core": "workspace:*", + "@prisma-next/sql-runtime": "workspace:*", + "arktype": "^2.1.25" }, "devDependencies": { + "@prisma-next/adapter-postgres": "workspace:*", + "@prisma-next/operations": "workspace:*", + "@prisma-next/sql-contract-ts": "workspace:*", + "@prisma-next/test-utils": "workspace:*", "@prisma-next/tsconfig": "workspace:*", "@prisma-next/tsdown": "workspace:*", "tsdown": "catalog:", @@ -31,7 +42,9 @@ "exports": { "./control": "./dist/control.mjs", "./index-types": "./dist/index-types.mjs", + "./operation-types": "./dist/operation-types.mjs", "./pack": "./dist/pack.mjs", + "./runtime": "./dist/runtime.mjs", "./package.json": "./package.json" }, "repository": { diff --git a/packages/3-extensions/paradedb/src/core/constants.ts b/packages/3-extensions/paradedb/src/core/constants.ts index d79d481b25..0ac60122d3 100644 --- a/packages/3-extensions/paradedb/src/core/constants.ts +++ b/packages/3-extensions/paradedb/src/core/constants.ts @@ -2,21 +2,3 @@ * Extension ID for ParadeDB pg_search. */ export const PARADEDB_EXTENSION_ID = 'paradedb' as const; - -/** - * Built-in ParadeDB tokenizer IDs. - * These correspond to the `pdb.*` casting syntax in `CREATE INDEX ... USING bm25`. - */ -export type TokenizerId = - | 'unicode_words' - | 'simple' - | 'ngram' - | 'icu' - | 'regex_pattern' - | 'source_code' - | 'literal' - | 'literal_normalized' - | 'whitespace' - | 'chinese_compatible' - | 'jieba' - | 'lindera'; diff --git a/packages/3-extensions/paradedb/src/core/descriptor-meta.ts b/packages/3-extensions/paradedb/src/core/descriptor-meta.ts index c4fc21ba58..7fe797ddd8 100644 --- a/packages/3-extensions/paradedb/src/core/descriptor-meta.ts +++ b/packages/3-extensions/paradedb/src/core/descriptor-meta.ts @@ -1,4 +1,218 @@ +import type { SqlOperationDescriptor } from '@prisma-next/sql-operations'; +import { LiteralExpr } from '@prisma-next/sql-relational-core/ast'; +import { buildOperation, toExpr } from '@prisma-next/sql-relational-core/expression'; +import { paradedbIndexTypes } from '../types/index-types'; +import type { QueryOperationTypes } from '../types/operation-types'; import { PARADEDB_EXTENSION_ID } from './constants'; +import { ParadeDbProximityChain } from './proximity-chain'; + +type CodecTypesBase = Record; + +const TEXT = 'pg/text@1' as const; +const BOOL = 'pg/bool@1' as const; +const FLOAT4 = 'pg/float4@1' as const; +const INT4 = 'pg/int4@1' as const; + +// `QueryOperationTypes` is the source of truth for impl signatures; this map +// projects each entry into the `{ self, impl, method }` shape that +// `SqlOperationDescriptor` expects, with `method` constrained to the literal key. +type DescriptorMap = { + readonly [M in keyof QueryOperationTypes & string]: QueryOperationTypes[M] & { + readonly method: M; + }; +}; + +export function paradedbQueryOperations< + CT extends CodecTypesBase, +>(): readonly SqlOperationDescriptor[] { + const ops: DescriptorMap = { + // `@@@` accepts both text and structured query types on its RHS. + // https://docs.paradedb.com/documentation/full-text/match + paradeDbMatch: { + method: 'paradeDbMatch', + self: { codecId: TEXT }, + impl: (self, query) => + buildOperation({ + method: 'paradeDbMatch', + args: [toExpr(self, TEXT), toExpr(query, TEXT)], + returns: { codecId: BOOL, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: '{{self}} @@@ {{arg0}}', + }, + }), + }, + paradeDbMatchAny: { + method: 'paradeDbMatchAny', + self: { codecId: TEXT }, + impl: (self, query) => + buildOperation({ + method: 'paradeDbMatchAny', + args: [toExpr(self, TEXT), toExpr(query, TEXT)], + returns: { codecId: BOOL, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: '{{self}} ||| {{arg0}}', + }, + }), + }, + paradeDbMatchAll: { + method: 'paradeDbMatchAll', + self: { codecId: TEXT }, + impl: (self, query) => + buildOperation({ + method: 'paradeDbMatchAll', + args: [toExpr(self, TEXT), toExpr(query, TEXT)], + returns: { codecId: BOOL, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: '{{self}} &&& {{arg0}}', + }, + }), + }, + // https://docs.paradedb.com/documentation/full-text/term + paradeDbTerm: { + method: 'paradeDbTerm', + self: { codecId: TEXT }, + impl: (self, query) => + buildOperation({ + method: 'paradeDbTerm', + args: [toExpr(self, TEXT), toExpr(query, TEXT)], + returns: { codecId: BOOL, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: '{{self}} === {{arg0}}', + }, + }), + }, + // https://docs.paradedb.com/documentation/full-text/phrase + paradeDbPhrase: { + method: 'paradeDbPhrase', + self: { codecId: TEXT }, + impl: (self, query) => + buildOperation({ + method: 'paradeDbPhrase', + args: [toExpr(self, TEXT), toExpr(query, TEXT)], + returns: { codecId: BOOL, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: '{{self}} ### {{arg0}}', + }, + }), + }, + // https://docs.paradedb.com/documentation/sorting/score + paradeDbScore: { + method: 'paradeDbScore', + self: { codecId: INT4 }, + impl: (self) => + buildOperation({ + method: 'paradeDbScore', + args: [toExpr(self, INT4)], + returns: { codecId: FLOAT4, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: 'pdb.score({{self}})', + }, + }), + }, + // PG rejects parameterized typmods, so the cast argument lowers to a literal. + // https://docs.paradedb.com/documentation/full-text/fuzzy + paradeDbFuzzy: { + method: 'paradeDbFuzzy', + self: { codecId: TEXT }, + impl: (self, distance) => { + if (!Number.isInteger(distance) || distance < 0 || distance > 2) { + throw new Error( + `paradeDbFuzzy: distance must be an integer in [0, 2]; got ${String(distance)}`, + ); + } + return buildOperation({ + method: 'paradeDbFuzzy', + args: [toExpr(self, TEXT), LiteralExpr.of(distance)], + returns: { codecId: TEXT, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: '{{self}}::pdb.fuzzy({{arg0}})', + }, + }); + }, + }, + // https://docs.paradedb.com/documentation/sorting/boost + paradeDbBoost: { + method: 'paradeDbBoost', + self: { codecId: TEXT }, + impl: (self, weight) => { + if (!Number.isInteger(weight) || weight < -2048 || weight > 2048) { + throw new Error( + `paradeDbBoost: boost must be an integer in [-2048, 2048]; got ${String(weight)}`, + ); + } + return buildOperation({ + method: 'paradeDbBoost', + args: [toExpr(self, TEXT), LiteralExpr.of(weight)], + returns: { codecId: TEXT, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: '{{self}}::pdb.boost({{arg0}})', + }, + }); + }, + }, + paradeDbConst: { + method: 'paradeDbConst', + self: { codecId: TEXT }, + impl: (self, value) => { + if (!Number.isInteger(value)) { + throw new Error(`paradeDbConst: value must be an integer; got ${String(value)}`); + } + return buildOperation({ + method: 'paradeDbConst', + args: [toExpr(self, TEXT), LiteralExpr.of(value)], + returns: { codecId: TEXT, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: '{{self}}::pdb.const({{arg0}})', + }, + }); + }, + }, + paradeDbSlop: { + method: 'paradeDbSlop', + self: { codecId: TEXT }, + impl: (self, slop) => { + if (!Number.isInteger(slop) || slop < 0) { + throw new Error(`paradeDbSlop: slop must be a non-negative integer; got ${String(slop)}`); + } + return buildOperation({ + method: 'paradeDbSlop', + args: [toExpr(self, TEXT), LiteralExpr.of(slop)], + returns: { codecId: TEXT, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: '{{self}}::pdb.slop({{arg0}})', + }, + }); + }, + }, + // https://docs.paradedb.com/documentation/full-text/proximity + paradeDbProximity: { + method: 'paradeDbProximity', + self: { codecId: TEXT }, + impl: (start) => new ParadeDbProximityChain(start), + }, + }; + return Object.values(ops); +} export const paradedbPackMeta = { kind: 'extension', @@ -11,4 +225,14 @@ export const paradedbPackMeta = { 'paradedb/bm25': true, }, }, + indexTypes: paradedbIndexTypes, + types: { + queryOperationTypes: { + import: { + package: '@prisma-next/extension-paradedb/operation-types', + named: 'QueryOperationTypes', + alias: 'ParadeDbQueryOperationTypes', + }, + }, + }, } as const; diff --git a/packages/3-extensions/paradedb/src/core/proximity-chain.ts b/packages/3-extensions/paradedb/src/core/proximity-chain.ts new file mode 100644 index 0000000000..4694e0e935 --- /dev/null +++ b/packages/3-extensions/paradedb/src/core/proximity-chain.ts @@ -0,0 +1,83 @@ +import { + type AnyExpression, + LiteralExpr, + OperationExpr, +} from '@prisma-next/sql-relational-core/ast'; +import { type Expression, toExpr } from '@prisma-next/sql-relational-core/expression'; + +const TEXT = 'pg/text@1' as const; + +export type ProximityTerm = unknown; + +export interface ProximityWithinOptions { + readonly ordered?: boolean; +} + +interface ProximityStep { + readonly distance: number; + readonly term: ProximityTerm; + readonly ordered: boolean; +} + +// https://docs.paradedb.com/documentation/full-text/proximity +export class ParadeDbProximityChain + implements Expression<{ codecId: 'pg/text@1'; nullable: false }> +{ + readonly returnType = { codecId: TEXT, nullable: false } as const; + + private readonly start: ProximityTerm; + private readonly steps: readonly ProximityStep[]; + + constructor(start: ProximityTerm, steps: readonly ProximityStep[] = []) { + this.start = start; + this.steps = steps; + } + + within( + distance: number, + term: ProximityTerm, + options?: ProximityWithinOptions, + ): ParadeDbProximityChain { + if (!Number.isInteger(distance) || distance < 0) { + throw new Error( + `paradeDbProximity.within: distance must be a non-negative integer; got ${String(distance)}`, + ); + } + return new ParadeDbProximityChain(this.start, [ + ...this.steps, + { distance, term, ordered: options?.ordered === true }, + ]); + } + + buildAst(): AnyExpression { + if (this.steps.length === 0) { + throw new Error( + 'paradeDbProximity: chain must have at least one .within(distance, term) step', + ); + } + const args: AnyExpression[] = [toExpr(this.start, TEXT)]; + let template = '({{self}}'; + this.steps.forEach((step, i) => { + const op = step.ordered ? '##>' : '##'; + args.push(LiteralExpr.of(step.distance)); + args.push(toExpr(step.term, TEXT)); + template += ` ${op} {{arg${2 * i}}} ${op} {{arg${2 * i + 1}}}`; + }); + template += ')'; + const [self, ...rest] = args; + if (!self) { + throw new Error('paradeDbProximity: invariant violation — empty args'); + } + return new OperationExpr({ + method: 'paradeDbProximity', + self, + args: rest.length > 0 ? rest : undefined, + returns: this.returnType, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template, + }, + }); + } +} diff --git a/packages/3-extensions/paradedb/src/exports/control.ts b/packages/3-extensions/paradedb/src/exports/control.ts index d0d49fd7e8..bf458c1690 100644 --- a/packages/3-extensions/paradedb/src/exports/control.ts +++ b/packages/3-extensions/paradedb/src/exports/control.ts @@ -1,3 +1,54 @@ -import { paradedbPackMeta } from '../core/descriptor-meta'; +import type { + ComponentDatabaseDependencies, + SqlControlExtensionDescriptor, +} from '@prisma-next/family-sql/control'; +import { paradedbPackMeta, paradedbQueryOperations } from '../core/descriptor-meta'; -export { paradedbPackMeta }; +const paradedbDatabaseDependencies: ComponentDatabaseDependencies = { + init: [ + { + id: 'postgres.extension.pg_search', + label: 'Enable pg_search extension', + install: [ + { + id: 'extension.pg_search', + label: 'Enable extension "pg_search"', + summary: 'Ensures the pg_search extension is available for ParadeDB BM25 operations', + operationClass: 'additive', + target: { id: 'postgres' }, + precheck: [ + { + description: 'verify extension "pg_search" is not already enabled', + sql: "SELECT NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_search')", + }, + ], + execute: [ + { + description: 'create extension "pg_search"', + sql: 'CREATE EXTENSION IF NOT EXISTS pg_search', + }, + ], + postcheck: [ + { + description: 'confirm extension "pg_search" is enabled', + sql: "SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_search')", + }, + ], + }, + ], + }, + ], +}; + +const paradedbExtensionDescriptor: SqlControlExtensionDescriptor<'postgres'> = { + ...paradedbPackMeta, + queryOperations: () => paradedbQueryOperations(), + databaseDependencies: paradedbDatabaseDependencies, + create: () => ({ + familyId: 'sql' as const, + targetId: 'postgres' as const, + }), +}; + +export { paradedbExtensionDescriptor, paradedbPackMeta }; +export default paradedbExtensionDescriptor; diff --git a/packages/3-extensions/paradedb/src/exports/index-types.ts b/packages/3-extensions/paradedb/src/exports/index-types.ts index a4aa25bf68..30fa5ea3fa 100644 --- a/packages/3-extensions/paradedb/src/exports/index-types.ts +++ b/packages/3-extensions/paradedb/src/exports/index-types.ts @@ -1,12 +1,2 @@ -export type { TokenizerId } from '../core/constants'; -export type { - Bm25ColumnFieldConfig, - Bm25ExpressionFieldConfig, - Bm25ExpressionFieldOptions, - Bm25FieldConfig, - Bm25IndexConfig, - Bm25IndexOptions, - Bm25JsonFieldOptions, - Bm25TextFieldOptions, -} from '../types/index-types'; -export { bm25, bm25Index } from '../types/index-types'; +export type { Bm25IndexOptions, IndexTypes } from '../types/index-types'; +export { paradedbIndexTypes } from '../types/index-types'; diff --git a/packages/3-extensions/paradedb/src/exports/operation-types.ts b/packages/3-extensions/paradedb/src/exports/operation-types.ts new file mode 100644 index 0000000000..38dc6e7229 --- /dev/null +++ b/packages/3-extensions/paradedb/src/exports/operation-types.ts @@ -0,0 +1 @@ +export type { QueryOperationTypes } from '../types/operation-types'; diff --git a/packages/3-extensions/paradedb/src/exports/runtime.ts b/packages/3-extensions/paradedb/src/exports/runtime.ts new file mode 100644 index 0000000000..3952228411 --- /dev/null +++ b/packages/3-extensions/paradedb/src/exports/runtime.ts @@ -0,0 +1,22 @@ +import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; +import type { SqlRuntimeExtensionDescriptor } from '@prisma-next/sql-runtime'; +import { paradedbPackMeta, paradedbQueryOperations } from '../core/descriptor-meta'; + +const paradedbRuntimeDescriptor: SqlRuntimeExtensionDescriptor<'postgres'> = { + kind: 'extension' as const, + id: paradedbPackMeta.id, + version: paradedbPackMeta.version, + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => createCodecRegistry(), + queryOperations: () => paradedbQueryOperations(), + parameterizedCodecs: () => [], + create() { + return { + familyId: 'sql' as const, + targetId: 'postgres' as const, + }; + }, +}; + +export default paradedbRuntimeDescriptor; diff --git a/packages/3-extensions/paradedb/src/types/index-types.ts b/packages/3-extensions/paradedb/src/types/index-types.ts index dd7c064bad..f1405dfd16 100644 --- a/packages/3-extensions/paradedb/src/types/index-types.ts +++ b/packages/3-extensions/paradedb/src/types/index-types.ts @@ -1,179 +1,12 @@ -import type { IndexDef } from '@prisma-next/contract-authoring'; -import type { TokenizerId } from '../core/constants'; - -/** - * BM25 field config for a table column. - */ -export type Bm25ColumnFieldConfig = { - readonly column: string; - readonly expression?: never; - readonly tokenizer?: string; - readonly tokenizerParams?: Record; - readonly alias?: string; -}; - -/** - * BM25 field config for a SQL expression. - */ -export type Bm25ExpressionFieldConfig = { - readonly expression: string; - readonly column?: never; - readonly alias: string; - readonly tokenizer?: string; - readonly tokenizerParams?: Record; -}; - -/** - * BM25 field config union. - */ -export type Bm25FieldConfig = Bm25ColumnFieldConfig | Bm25ExpressionFieldConfig; - -/** - * BM25 index configuration payload stored in `IndexDef.config`. - */ -export type Bm25IndexConfig = { - readonly keyField: string; - readonly fields: readonly Bm25FieldConfig[]; -}; - -/** - * Options for a BM25 text field (text, varchar columns). - */ -export type Bm25TextFieldOptions = { - readonly tokenizer?: TokenizerId | (string & {}); - readonly stemmer?: string; - readonly alias?: string; - readonly remove_emojis?: boolean; -}; - -/** - * Options for a BM25 JSON field (json, jsonb columns). - */ -export type Bm25JsonFieldOptions = { - readonly tokenizer?: TokenizerId | (string & {}); - readonly alias?: string; - /** Ngram-specific params when tokenizer is 'ngram'. */ - readonly min?: number; - readonly max?: number; -}; - -/** - * Options for a BM25 expression-based field. - */ -export type Bm25ExpressionFieldOptions = { - readonly alias: string; - readonly tokenizer?: TokenizerId | (string & {}); - readonly min?: number; - readonly max?: number; - readonly stemmer?: string; - readonly pattern?: string; -}; - -type TokenizerConfig = { - readonly tokenizer?: string; - readonly tokenizerParams?: Record; -}; - -/** - * Options for constructing a BM25 index definition. - */ -export type Bm25IndexOptions = { - readonly keyField: string; - readonly fields: readonly Bm25FieldConfig[]; - readonly name?: string; -}; - -/** - * Typed BM25 field builders. - * These produce `Bm25FieldConfig` objects for use in `bm25Index()`. - */ -export const bm25 = { - /** Text field with optional tokenizer config. */ - text(column: string, opts?: Bm25TextFieldOptions): Bm25FieldConfig { - return { - column, - ...buildTokenizerConfig(opts?.tokenizer, { - stemmer: opts?.stemmer, - remove_emojis: opts?.remove_emojis, - }), - ...(opts?.alias !== undefined && { alias: opts.alias }), - }; - }, - - /** Numeric field (filterable, sortable in BM25). */ - numeric(column: string): Bm25FieldConfig { - return { column }; - }, - - /** Boolean field. */ - boolean(column: string): Bm25FieldConfig { - return { column }; - }, - - /** JSON/JSONB field with optional tokenizer config. */ - json(column: string, opts?: Bm25JsonFieldOptions): Bm25FieldConfig { - return { - column, - ...buildTokenizerConfig(opts?.tokenizer, { min: opts?.min, max: opts?.max }), - ...(opts?.alias !== undefined && { alias: opts.alias }), - }; - }, - - /** Datetime (timestamp/date) field. */ - datetime(column: string): Bm25FieldConfig { - return { column }; - }, - - /** Range field. */ - range(column: string): Bm25FieldConfig { - return { column }; - }, - - /** Raw SQL expression field. `alias` is required. */ - expression(sql: string, opts: Bm25ExpressionFieldOptions): Bm25FieldConfig { - return { - expression: sql, - alias: opts.alias, - ...buildTokenizerConfig(opts.tokenizer, { - min: opts.min, - max: opts.max, - stemmer: opts.stemmer, - pattern: opts.pattern, - }), - }; - }, -} as const; - -/** - * Creates a generic index definition with a ParadeDB BM25 payload. - * - * `columns` only includes real table columns so core index validation remains - * target-agnostic. Expression fields stay in extension-owned `config.fields`. - */ -export function bm25Index(opts: Bm25IndexOptions): IndexDef { - return { - columns: opts.fields.flatMap((field) => ('column' in field ? [field.column] : [])), - ...(opts.name !== undefined && { name: opts.name }), - using: 'bm25', - config: { - keyField: opts.keyField, - fields: opts.fields, - } satisfies Bm25IndexConfig, - }; -} - -/** - * Builds `{ tokenizer, tokenizerParams? }` from a tokenizer ID and a bag of params. - * Filters out undefined values and omits `tokenizerParams` when empty. - */ -function buildTokenizerConfig( - tokenizer: string | undefined, - params: Record, -): TokenizerConfig { - if (!tokenizer) return {}; - const filtered = Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined)); - return { - tokenizer, - ...(Object.keys(filtered).length > 0 && { tokenizerParams: filtered }), - }; -} +import { defineIndexTypes } from '@prisma-next/sql-contract/index-types'; +import { type } from 'arktype'; + +export const paradedbIndexTypes = defineIndexTypes().add('bm25', { + options: type({ + '+': 'reject', + key_field: 'string', + }), +}); + +export type IndexTypes = typeof paradedbIndexTypes.IndexTypes; +export type Bm25IndexOptions = IndexTypes['bm25']['options']; diff --git a/packages/3-extensions/paradedb/src/types/operation-types.ts b/packages/3-extensions/paradedb/src/types/operation-types.ts new file mode 100644 index 0000000000..82cbc32bb7 --- /dev/null +++ b/packages/3-extensions/paradedb/src/types/operation-types.ts @@ -0,0 +1,84 @@ +import type { SqlQueryOperationTypes } from '@prisma-next/sql-contract/types'; +import type { CodecExpression, Expression } from '@prisma-next/sql-relational-core/expression'; +import type { ParadeDbProximityChain } from '../core/proximity-chain'; + +type CodecTypesBase = Record; + +export type QueryOperationTypes = SqlQueryOperationTypes< + CT, + { + readonly paradeDbMatch: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: ( + self: CodecExpression<'pg/text@1', boolean, CT>, + query: CodecExpression<'pg/text@1', boolean, CT>, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + }; + readonly paradeDbMatchAny: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: ( + self: CodecExpression<'pg/text@1', boolean, CT>, + query: CodecExpression<'pg/text@1', boolean, CT>, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + }; + readonly paradeDbMatchAll: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: ( + self: CodecExpression<'pg/text@1', boolean, CT>, + query: CodecExpression<'pg/text@1', boolean, CT>, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + }; + readonly paradeDbTerm: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: ( + self: CodecExpression<'pg/text@1', boolean, CT>, + query: CodecExpression<'pg/text@1', boolean, CT>, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + }; + readonly paradeDbPhrase: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: ( + self: CodecExpression<'pg/text@1', boolean, CT>, + query: CodecExpression<'pg/text@1', boolean, CT>, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + }; + readonly paradeDbScore: { + readonly self: { readonly codecId: 'pg/int4@1' }; + readonly impl: ( + self: CodecExpression<'pg/int4@1', boolean, CT>, + ) => Expression<{ codecId: 'pg/float4@1'; nullable: false }>; + }; + readonly paradeDbFuzzy: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: ( + self: CodecExpression<'pg/text@1', boolean, CT>, + distance: number, + ) => Expression<{ codecId: 'pg/text@1'; nullable: false }>; + }; + readonly paradeDbBoost: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: ( + self: CodecExpression<'pg/text@1', boolean, CT>, + weight: number, + ) => Expression<{ codecId: 'pg/text@1'; nullable: false }>; + }; + readonly paradeDbConst: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: ( + self: CodecExpression<'pg/text@1', boolean, CT>, + value: number, + ) => Expression<{ codecId: 'pg/text@1'; nullable: false }>; + }; + readonly paradeDbSlop: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: ( + self: CodecExpression<'pg/text@1', boolean, CT>, + slop: number, + ) => Expression<{ codecId: 'pg/text@1'; nullable: false }>; + }; + readonly paradeDbProximity: { + readonly self: { readonly codecId: 'pg/text@1' }; + readonly impl: (start: CodecExpression<'pg/text@1', boolean, CT>) => ParadeDbProximityChain; + }; + } +>; diff --git a/packages/3-extensions/paradedb/test/index-types.test.ts b/packages/3-extensions/paradedb/test/index-types.test.ts index ff2dc59f44..22b38f5c5e 100644 --- a/packages/3-extensions/paradedb/test/index-types.test.ts +++ b/packages/3-extensions/paradedb/test/index-types.test.ts @@ -1,6 +1,7 @@ +import { type } from 'arktype'; import { describe, expect, it } from 'vitest'; import { paradedbPackMeta } from '../src/core/descriptor-meta'; -import { bm25, bm25Index } from '../src/types/index-types'; +import { paradedbIndexTypes } from '../src/types/index-types'; describe('ParadeDB extension', () => { describe('paradedbPackMeta', () => { @@ -16,163 +17,44 @@ describe('ParadeDB extension', () => { postgres: { 'paradedb/bm25': true }, }); }); - }); - - describe('bm25 field builders', () => { - describe('bm25.text', () => { - it('creates a text field with defaults', () => { - expect(bm25.text('description')).toEqual({ column: 'description' }); - }); - - it('creates a text field with tokenizer', () => { - expect(bm25.text('description', { tokenizer: 'simple' })).toEqual({ - column: 'description', - tokenizer: 'simple', - }); - }); - - it('creates a text field with stemmer', () => { - expect(bm25.text('description', { tokenizer: 'simple', stemmer: 'english' })).toEqual({ - column: 'description', - tokenizer: 'simple', - tokenizerParams: { stemmer: 'english' }, - }); - }); - - it('creates a text field with remove_emojis', () => { - expect( - bm25.text('description', { tokenizer: 'unicode_words', remove_emojis: true }), - ).toEqual({ - column: 'description', - tokenizer: 'unicode_words', - tokenizerParams: { remove_emojis: true }, - }); - }); - - it('creates a text field with alias for multi-tokenizer', () => { - expect( - bm25.text('description', { tokenizer: 'simple', alias: 'description_simple' }), - ).toEqual({ - column: 'description', - tokenizer: 'simple', - alias: 'description_simple', - }); - }); - }); - - describe('bm25.numeric', () => { - it('creates a numeric field', () => { - expect(bm25.numeric('rating')).toEqual({ column: 'rating' }); - }); - }); - - describe('bm25.boolean', () => { - it('creates a boolean field', () => { - expect(bm25.boolean('active')).toEqual({ column: 'active' }); - }); - }); - describe('bm25.json', () => { - it('creates a json field with defaults', () => { - expect(bm25.json('metadata')).toEqual({ column: 'metadata' }); - }); - - it('creates a json field with ngram tokenizer', () => { - expect(bm25.json('metadata', { tokenizer: 'ngram', min: 2, max: 3 })).toEqual({ - column: 'metadata', - tokenizer: 'ngram', - tokenizerParams: { min: 2, max: 3 }, - }); - }); + it('exposes the bm25 entry in indexTypes', () => { + expect(paradedbPackMeta.indexTypes.entries).toHaveLength(1); + expect(paradedbPackMeta.indexTypes.entries[0]?.type).toBe('bm25'); }); + }); - describe('bm25.datetime', () => { - it('creates a datetime field', () => { - expect(bm25.datetime('created_at')).toEqual({ column: 'created_at' }); - }); + describe('paradedbIndexTypes', () => { + it('declares a single bm25 entry', () => { + expect(paradedbIndexTypes.entries.map((e) => e.type)).toEqual(['bm25']); }); - describe('bm25.range', () => { - it('creates a range field', () => { - expect(bm25.range('price_range')).toEqual({ column: 'price_range' }); - }); + it('validates bm25 options with a key_field string', () => { + const entry = paradedbIndexTypes.entries[0]; + if (!entry) throw new Error('expected bm25 entry'); + const result = entry.options({ key_field: 'id' }); + expect(result instanceof type.errors).toBe(false); }); - describe('bm25.expression', () => { - it('creates an expression field with alias', () => { - expect( - bm25.expression("description || ' ' || category", { - alias: 'concat', - tokenizer: 'simple', - }), - ).toEqual({ - expression: "description || ' ' || category", - alias: 'concat', - tokenizer: 'simple', - }); - }); - - it('creates expression field with tokenizer params', () => { - expect( - bm25.expression("(metadata->>'color')", { - alias: 'meta_color', - tokenizer: 'ngram', - min: 2, - max: 3, - }), - ).toEqual({ - expression: "(metadata->>'color')", - alias: 'meta_color', - tokenizer: 'ngram', - tokenizerParams: { min: 2, max: 3 }, - }); - }); - - it('creates expression field without tokenizer', () => { - expect(bm25.expression('rating + 1', { alias: 'rating_plus' })).toEqual({ - expression: 'rating + 1', - alias: 'rating_plus', - }); - }); + it('rejects bm25 options without key_field', () => { + const entry = paradedbIndexTypes.entries[0]; + if (!entry) throw new Error('expected bm25 entry'); + const result = entry.options({}); + expect(result instanceof type.errors).toBe(true); }); - }); - - describe('bm25Index', () => { - it('creates index definition with extension config payload', () => { - const indexDef = bm25Index({ - keyField: 'id', - name: 'search_idx', - fields: [bm25.text('description', { tokenizer: 'simple' })], - }); - expect(indexDef).toEqual({ - columns: ['description'], - name: 'search_idx', - using: 'bm25', - config: { - keyField: 'id', - fields: [{ column: 'description', tokenizer: 'simple' }], - }, - }); + it('rejects bm25 options with extra unknown keys', () => { + const entry = paradedbIndexTypes.entries[0]; + if (!entry) throw new Error('expected bm25 entry'); + const result = entry.options({ key_field: 'id', extra: 'nope' }); + expect(result instanceof type.errors).toBe(true); }); - it('keeps expression fields in config while preserving core-safe columns', () => { - const indexDef = bm25Index({ - keyField: 'id', - fields: [ - bm25.text('description'), - bm25.expression("description || ' ' || category", { alias: 'concat' }), - ], - }); - - expect(indexDef.columns).toEqual(['description']); - expect(indexDef.config).toEqual({ - keyField: 'id', - fields: [ - { column: 'description' }, - { expression: "description || ' ' || category", alias: 'concat' }, - ], - }); + it('rejects bm25 options where key_field is not a string', () => { + const entry = paradedbIndexTypes.entries[0]; + if (!entry) throw new Error('expected bm25 entry'); + const result = entry.options({ key_field: 42 }); + expect(result instanceof type.errors).toBe(true); }); }); }); diff --git a/packages/3-extensions/paradedb/test/operations.test.ts b/packages/3-extensions/paradedb/test/operations.test.ts new file mode 100644 index 0000000000..bea51d62fa --- /dev/null +++ b/packages/3-extensions/paradedb/test/operations.test.ts @@ -0,0 +1,227 @@ +import { createSqlOperationRegistry } from '@prisma-next/sql-operations'; +import { OperationExpr, ParamRef } from '@prisma-next/sql-relational-core/ast'; +import { describe, expect, it } from 'vitest'; +import { ParadeDbProximityChain } from '../src/core/proximity-chain'; +import paradedbDescriptor from '../src/exports/runtime'; + +function getProximityChain(start: unknown): ParadeDbProximityChain { + const operations = paradedbDescriptor.queryOperations?.() ?? []; + const op = operations.find((o) => o.method === 'paradeDbProximity'); + if (!op) throw new Error('paradeDbProximity not found'); + const result = op.impl(start as never) as unknown; + if (!(result instanceof ParadeDbProximityChain)) { + throw new Error('paradeDbProximity did not return a ParadeDbProximityChain'); + } + return result; +} + +function buildOpAst( + op: { impl: (...args: never[]) => unknown } | undefined, + ...args: unknown[] +): OperationExpr { + if (!op) throw new Error('operation not found'); + const expr = op.impl(...(args as never[])) as unknown as { buildAst(): OperationExpr }; + return expr.buildAst(); +} + +describe('paradedb operations', () => { + it('descriptor has correct metadata', () => { + expect(paradedbDescriptor.kind).toBe('extension'); + expect(paradedbDescriptor.id).toBe('paradedb'); + expect(paradedbDescriptor.familyId).toBe('sql'); + expect(paradedbDescriptor.targetId).toBe('postgres'); + expect(paradedbDescriptor.version).toBe('0.0.1'); + }); + + it('descriptor provides query operations whose impls build AST with lowering', () => { + const operations = paradedbDescriptor.queryOperations?.() ?? []; + expect(operations).toHaveLength(11); + + const matchOps: ReadonlyArray = [ + ['paradeDbMatch', '@@@'], + ['paradeDbMatchAny', '|||'], + ['paradeDbMatchAll', '&&&'], + ['paradeDbTerm', '==='], + ['paradeDbPhrase', '###'], + ]; + for (const [method, op] of matchOps) { + const ast = buildOpAst( + operations.find((o) => o.method === method), + ParamRef.of('hello', { codecId: 'pg/text@1' }), + 'world', + ); + expect(ast).toBeInstanceOf(OperationExpr); + expect(ast.lowering).toEqual({ + targetFamily: 'sql', + strategy: 'function', + template: `{{self}} ${op} {{arg0}}`, + }); + } + + const scoreAst = buildOpAst( + operations.find((op) => op.method === 'paradeDbScore'), + ParamRef.of(1, { codecId: 'pg/int4@1' }), + ); + expect(scoreAst.lowering).toEqual({ + targetFamily: 'sql', + strategy: 'function', + template: 'pdb.score({{self}})', + }); + + const typmodOps: ReadonlyArray = [ + ['paradeDbFuzzy', 'fuzzy', 2], + ['paradeDbBoost', 'boost', 3], + ['paradeDbConst', 'const', 1], + ['paradeDbSlop', 'slop', 2], + ]; + for (const [method, pdbType, n] of typmodOps) { + const ast = buildOpAst( + operations.find((op) => op.method === method), + ParamRef.of('q', { codecId: 'pg/text@1' }), + n, + ); + expect(ast.lowering).toEqual({ + targetFamily: 'sql', + strategy: 'function', + template: `{{self}}::pdb.${pdbType}({{arg0}})`, + }); + // typmod cast args must be inline literals (PG rejects parameterized typmods). + expect(ast.args?.[0]?.kind).toBe('literal'); + } + + // paradeDbProximity returns a builder; one .within(...) step produces the + // single-edge `(start ## N ## term)` AST. + const proximityAst = getProximityChain(ParamRef.of('sleek', { codecId: 'pg/text@1' })) + .within(1, 'shoes') + .buildAst() as OperationExpr; + expect(proximityAst).toBeInstanceOf(OperationExpr); + expect(proximityAst.lowering).toEqual({ + targetFamily: 'sql', + strategy: 'function', + template: '({{self}} ## {{arg0}} ## {{arg1}})', + }); + expect(proximityAst.args?.[0]?.kind).toBe('literal'); // distance literal + }); + + it('paradeDbProximity chains multiple .within(...) steps with mixed direction', () => { + const ast = getProximityChain(ParamRef.of('sleek', { codecId: 'pg/text@1' })) + .within(1, 'running') + .within(2, 'shoes', { ordered: true }) + .buildAst() as OperationExpr; + + expect(ast.lowering).toEqual({ + targetFamily: 'sql', + strategy: 'function', + template: '({{self}} ## {{arg0}} ## {{arg1}} ##> {{arg2}} ##> {{arg3}})', + }); + // arg0 = distance literal (##), arg2 = distance literal (##>). + expect(ast.args?.[0]?.kind).toBe('literal'); + expect(ast.args?.[2]?.kind).toBe('literal'); + }); + + it('paradeDbProximity throws on empty chain or invalid distance', () => { + const chain = getProximityChain(ParamRef.of('sleek', { codecId: 'pg/text@1' })); + + expect(() => chain.buildAst()).toThrow('chain must have at least one .within'); + expect(() => chain.within(-1, 'x')).toThrow('non-negative integer'); + expect(() => chain.within(1.5, 'x')).toThrow('non-negative integer'); + }); + + it('typmod-cast ops reject out-of-range / non-integer values', () => { + const operations = paradedbDescriptor.queryOperations?.() ?? []; + const find = (method: string) => { + const op = operations.find((o) => o.method === method); + if (!op) throw new Error(`${method} not found`); + return op; + }; + const term = ParamRef.of('term', { codecId: 'pg/text@1' }); + + expect(() => find('paradeDbFuzzy').impl(term as never, 3 as never)).toThrow( + 'distance must be an integer in [0, 2]', + ); + expect(() => find('paradeDbFuzzy').impl(term as never, 1.5 as never)).toThrow( + 'distance must be an integer in [0, 2]', + ); + + expect(() => find('paradeDbBoost').impl(term as never, 3000 as never)).toThrow( + 'boost must be an integer in [-2048, 2048]', + ); + expect(() => find('paradeDbBoost').impl(term as never, 1.5 as never)).toThrow( + 'boost must be an integer in [-2048, 2048]', + ); + + expect(() => find('paradeDbConst').impl(term as never, 1.5 as never)).toThrow( + 'value must be an integer', + ); + + expect(() => find('paradeDbSlop').impl(term as never, -1 as never)).toThrow( + 'slop must be a non-negative integer', + ); + expect(() => find('paradeDbSlop').impl(term as never, 1.5 as never)).toThrow( + 'slop must be a non-negative integer', + ); + }); + + it('operations carry self codec dispatch hints', () => { + const operations = paradedbDescriptor.queryOperations?.() ?? []; + + const textOps = [ + 'paradeDbMatch', + 'paradeDbMatchAny', + 'paradeDbMatchAll', + 'paradeDbTerm', + 'paradeDbPhrase', + 'paradeDbFuzzy', + 'paradeDbBoost', + 'paradeDbConst', + 'paradeDbSlop', + 'paradeDbProximity', + ]; + for (const method of textOps) { + expect(operations.find((op) => op.method === method)?.self).toEqual({ + codecId: 'pg/text@1', + }); + } + expect(operations.find((op) => op.method === 'paradeDbScore')?.self).toEqual({ + codecId: 'pg/int4@1', + }); + }); + + it('operations can be registered in registry', () => { + const operations = paradedbDescriptor.queryOperations?.() ?? []; + + const registry = createSqlOperationRegistry(); + for (const op of operations) { + registry.register(op); + } + + const entries = registry.entries(); + for (const method of [ + 'paradeDbMatch', + 'paradeDbMatchAny', + 'paradeDbMatchAll', + 'paradeDbTerm', + 'paradeDbPhrase', + 'paradeDbFuzzy', + 'paradeDbBoost', + 'paradeDbConst', + 'paradeDbSlop', + 'paradeDbProximity', + 'paradeDbScore', + ]) { + expect(entries[method]).toBeDefined(); + } + }); + + it('descriptor exposes empty codec and parameterized codec registries', () => { + const codecs = paradedbDescriptor.codecs(); + expect(codecs).toBeDefined(); + expect(paradedbDescriptor.parameterizedCodecs()).toEqual([]); + }); + + it('instance is minimal (identity only)', () => { + const instance = paradedbDescriptor.create(); + expect(instance.familyId).toBe('sql'); + expect(instance.targetId).toBe('postgres'); + }); +}); diff --git a/packages/3-extensions/paradedb/tsdown.config.ts b/packages/3-extensions/paradedb/tsdown.config.ts index bd8de70770..4aef66f46a 100644 --- a/packages/3-extensions/paradedb/tsdown.config.ts +++ b/packages/3-extensions/paradedb/tsdown.config.ts @@ -1,5 +1,11 @@ import { defineConfig } from '@prisma-next/tsdown'; export default defineConfig({ - entry: ['src/exports/control.ts', 'src/exports/index-types.ts', 'src/exports/pack.ts'], + entry: [ + 'src/exports/control.ts', + 'src/exports/index-types.ts', + 'src/exports/operation-types.ts', + 'src/exports/pack.ts', + 'src/exports/runtime.ts', + ], }); diff --git a/packages/3-extensions/paradedb/vitest.config.ts b/packages/3-extensions/paradedb/vitest.config.ts new file mode 100644 index 0000000000..0d44e4c0fb --- /dev/null +++ b/packages/3-extensions/paradedb/vitest.config.ts @@ -0,0 +1,31 @@ +import { timeouts } from '@prisma-next/test-utils'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + testTimeout: timeouts.default, + hookTimeout: timeouts.default, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: [ + 'dist/**', + 'test/**', + '**/*.test.ts', + '**/*.test-d.ts', + '**/*.config.ts', + '**/exports/**', + '**/types.ts', + ], + thresholds: { + lines: 95, + branches: 90, + functions: 95, + statements: 95, + }, + }, + }, +}); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 9c2e1c632b..c4cd630f8e 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -15,6 +15,7 @@ import type { SqlPlannerConflict, SqlPlannerConflictLocation, } from '@prisma-next/family-sql/control'; +import { arraysEqual } from '@prisma-next/family-sql/schema-verify'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { SchemaIssue } from '@prisma-next/framework-components/control'; import type { @@ -211,7 +212,12 @@ function mapIssueToCall( ]; for (const index of contractTable.indexes) { const indexName = index.name ?? `${issue.table}_${index.columns.join('_')}_idx`; - calls.push(new CreateIndexCall(schemaName, issue.table, indexName, [...index.columns])); + const extras: { type?: string; options?: Record } = {}; + if (index.type !== undefined) extras.type = index.type; + if (index.options !== undefined) extras.options = index.options; + calls.push( + new CreateIndexCall(schemaName, issue.table, indexName, [...index.columns], extras), + ); } const explicitIndexColumnSets = new Set( contractTable.indexes.map((idx) => idx.columns.join(',')), @@ -446,8 +452,14 @@ function mapIssueToCall( return notOk(issueConflict('indexIncompatible', 'Index issue has no table name')); if (isMissing(issue) && issue.expected) { const columns = issue.expected.split(', '); - const indexName = `${issue.table}_${columns.join('_')}_idx`; - return ok([new CreateIndexCall(schemaName, issue.table, indexName, columns)]); + const contractIndex = ctx.toContract.storage.tables[issue.table]?.indexes.find((idx) => + arraysEqual(idx.columns, columns), + ); + const indexName = contractIndex?.name ?? `${issue.table}_${columns.join('_')}_idx`; + const extras: { type?: string; options?: Record } = {}; + if (contractIndex?.type !== undefined) extras.type = contractIndex.type; + if (contractIndex?.options !== undefined) extras.options = contractIndex.options; + return ok([new CreateIndexCall(schemaName, issue.table, indexName, columns, extras)]); } return notOk( issueConflict( diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index 1a0c533009..7a4bf868c6 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -506,6 +506,8 @@ export class CreateIndexCall extends PostgresOpFactoryCallNode { readonly tableName: string; readonly indexName: string; readonly columns: readonly string[]; + readonly indexType: string | undefined; + readonly options: Record | undefined; readonly label: string; constructor( @@ -513,22 +515,40 @@ export class CreateIndexCall extends PostgresOpFactoryCallNode { tableName: string, indexName: string, columns: readonly string[], + extras?: { readonly type?: string; readonly options?: Record }, ) { super(); this.schemaName = schemaName; this.tableName = tableName; this.indexName = indexName; this.columns = columns; + this.indexType = extras?.type; + this.options = extras?.options; this.label = `Create index "${indexName}" on "${tableName}"`; this.freeze(); } toOp(): Op { - return createIndex(this.schemaName, this.tableName, this.indexName, this.columns); + const extras: { type?: string; options?: Record } = {}; + if (this.indexType !== undefined) extras.type = this.indexType; + if (this.options !== undefined) extras.options = this.options; + return createIndex(this.schemaName, this.tableName, this.indexName, this.columns, extras); } renderTypeScript(): string { - return `createIndex(${jsonToTsSource(this.schemaName)}, ${jsonToTsSource(this.tableName)}, ${jsonToTsSource(this.indexName)}, ${jsonToTsSource(this.columns)})`; + const args = [ + jsonToTsSource(this.schemaName), + jsonToTsSource(this.tableName), + jsonToTsSource(this.indexName), + jsonToTsSource(this.columns), + ]; + if (this.indexType !== undefined || this.options !== undefined) { + const extrasParts: string[] = []; + if (this.indexType !== undefined) extrasParts.push(`type: ${jsonToTsSource(this.indexType)}`); + if (this.options !== undefined) extrasParts.push(`options: ${jsonToTsSource(this.options)}`); + args.push(`{ ${extrasParts.join(', ')} }`); + } + return `createIndex(${args.join(', ')})`; } } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/indexes.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/indexes.ts index 4fce8a9f3b..71b9130f41 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/indexes.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/indexes.ts @@ -1,15 +1,40 @@ -import { quoteIdentifier } from '../../sql-utils'; +import { escapeLiteral, quoteIdentifier } from '../../sql-utils'; import { qualifyTableName, toRegclassLiteral } from '../planner-sql-checks'; import { type Op, step, targetDetails } from './shared'; +export interface CreateIndexExtras { + readonly type?: string; + readonly options?: Record; +} + +function renderIndexOptionValue(key: string, value: unknown): string { + if (typeof value === 'string') return `'${escapeLiteral(value)}'`; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + throw new Error( + `Index option "${key}" must be a string, finite number, or boolean; got ${typeof value}`, + ); +} + +function renderIndexOptions(options: Record): string { + return Object.entries(options) + .map(([key, value]) => `${quoteIdentifier(key)} = ${renderIndexOptionValue(key, value)}`) + .join(', '); +} + export function createIndex( schemaName: string, tableName: string, indexName: string, columns: readonly string[], + extras?: CreateIndexExtras, ): Op { const qualified = qualifyTableName(schemaName, tableName); const columnList = columns.map(quoteIdentifier).join(', '); + const using = extras?.type ? ` USING ${quoteIdentifier(extras.type)}` : ''; + const options = extras?.options; + const withClause = + options && Object.keys(options).length > 0 ? ` WITH (${renderIndexOptions(options)})` : ''; return { id: `index.${tableName}.${indexName}`, label: `Create index "${indexName}" on "${tableName}"`, @@ -24,7 +49,7 @@ export function createIndex( execute: [ step( `create index "${indexName}"`, - `CREATE INDEX ${quoteIdentifier(indexName)} ON ${qualified} (${columnList})`, + `CREATE INDEX ${quoteIdentifier(indexName)} ON ${qualified}${using} (${columnList})${withClause}`, ), ], postcheck: [ diff --git a/packages/3-targets/3-targets/postgres/test/migrations/index-ddl.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/index-ddl.test.ts new file mode 100644 index 0000000000..b26aad7923 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/index-ddl.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { createIndex } from '../../src/core/migrations/operations/indexes'; + +function executeSql(op: ReturnType): string { + const stmt = op.execute[0]; + if (!stmt) throw new Error('createIndex op has no execute step'); + return stmt.sql; +} + +describe('createIndex DDL emission', () => { + it('emits a plain CREATE INDEX when no extras are supplied', () => { + const op = createIndex('public', 'user', 'user_email_idx', ['email']); + expect(executeSql(op)).toBe('CREATE INDEX "user_email_idx" ON "public"."user" ("email")'); + }); + + it('emits USING when type is supplied', () => { + const op = createIndex('public', 'doc', 'doc_body_idx', ['body'], { type: 'gin' }); + expect(executeSql(op)).toBe( + 'CREATE INDEX "doc_body_idx" ON "public"."doc" USING "gin" ("body")', + ); + }); + + it('emits WITH (...) when options are supplied', () => { + const op = createIndex('public', 'doc', 'doc_body_idx', ['body'], { + type: 'gin', + options: { fastupdate: false }, + }); + expect(executeSql(op)).toBe( + 'CREATE INDEX "doc_body_idx" ON "public"."doc" USING "gin" ("body") WITH ("fastupdate" = false)', + ); + }); + + it('omits WITH when options is an empty object', () => { + const op = createIndex('public', 'doc', 'doc_body_idx', ['body'], { + type: 'gin', + options: {}, + }); + expect(executeSql(op)).toBe( + 'CREATE INDEX "doc_body_idx" ON "public"."doc" USING "gin" ("body")', + ); + }); + + it('renders number, boolean, and string option leaves correctly', () => { + const op = createIndex('public', 'doc', 'doc_body_idx', ['body'], { + type: 'demo', + options: { fillfactor: 70, fastupdate: false, pdb_locale: 'en-US' }, + }); + expect(executeSql(op)).toBe( + `CREATE INDEX "doc_body_idx" ON "public"."doc" USING "demo" ("body") WITH ("fillfactor" = 70, "fastupdate" = false, "pdb_locale" = 'en-US')`, + ); + }); + + it('escapes single quotes in string option values', () => { + const op = createIndex('public', 'doc', 'doc_body_idx', ['body'], { + type: 'demo', + options: { needle: "with'quote" }, + }); + expect(executeSql(op)).toContain(`"needle" = 'with''quote'`); + }); + + it('rejects null option values', () => { + expect(() => + createIndex('public', 'doc', 'doc_body_idx', ['body'], { + type: 'demo', + options: { weird: null }, + }), + ).toThrow(/Index option/); + }); + + it('rejects non-finite numeric option values', () => { + expect(() => + createIndex('public', 'doc', 'doc_body_idx', ['body'], { + type: 'demo', + options: { weird: Number.NaN }, + }), + ).toThrow(/Index option/); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/issue-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/issue-planner.test.ts index 6e8dc4f449..16b9b6b99e 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/issue-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/issue-planner.test.ts @@ -452,6 +452,144 @@ describe('planIssues', () => { }); }); + describe('index_mismatch', () => { + it('threads contract index type and options into CreateIndexCall when the index is missing', () => { + const toContract = makeContract({ + tables: { + doc: { + columns: { + id: { nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false }, + body: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [{ columns: ['body'], type: 'gin', options: { fastupdate: false } }], + foreignKeys: [], + }, + }, + }); + const issues: SchemaIssue[] = [ + { + kind: 'index_mismatch', + table: 'doc', + expected: 'body', + message: 'Table "doc" is missing index: body', + }, + ]; + + const result = planIssues({ + ...defaultCtx, + issues, + toContract, + fromContract: null, + storageTypes: toContract.storage.types ?? {}, + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok'); + expect(result.value.calls).toHaveLength(1); + expect(result.value.calls[0]).toMatchObject({ + factoryName: 'createIndex', + tableName: 'doc', + indexType: 'gin', + options: { fastupdate: false }, + }); + }); + + it('uses the contract index name when set', () => { + const toContract = makeContract({ + tables: { + doc: { + columns: { + id: { nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false }, + body: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [ + { + columns: ['body'], + name: 'doc_body_bm25_idx', + type: 'bm25', + options: { key_field: 'id' }, + }, + ], + foreignKeys: [], + }, + }, + }); + const issues: SchemaIssue[] = [ + { + kind: 'index_mismatch', + table: 'doc', + expected: 'body', + message: 'Table "doc" is missing index: body', + }, + ]; + + const result = planIssues({ + ...defaultCtx, + issues, + toContract, + fromContract: null, + storageTypes: toContract.storage.types ?? {}, + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok'); + expect(result.value.calls[0]).toMatchObject({ + factoryName: 'createIndex', + tableName: 'doc', + indexName: 'doc_body_bm25_idx', + indexType: 'bm25', + options: { key_field: 'id' }, + }); + }); + + it('falls back to a default index name when the contract index has no name', () => { + const toContract = makeContract({ + tables: { + doc: { + columns: { + id: { nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false }, + body: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [{ columns: ['body'] }], + foreignKeys: [], + }, + }, + }); + const issues: SchemaIssue[] = [ + { + kind: 'index_mismatch', + table: 'doc', + expected: 'body', + message: 'Table "doc" is missing index: body', + }, + ]; + + const result = planIssues({ + ...defaultCtx, + issues, + toContract, + fromContract: null, + storageTypes: toContract.storage.types ?? {}, + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok'); + expect(result.value.calls[0]).toMatchObject({ + factoryName: 'createIndex', + tableName: 'doc', + indexName: 'doc_body_idx', + indexType: undefined, + options: undefined, + }); + }); + }); + describe('foreign_key_mismatch', () => { it('returns foreignKeyConflict when the destination contract lacks a matching FK entry', () => { const toContract = makeContract({ diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index daa3fb8dc0..015ddb48bf 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -301,17 +301,22 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { indisunique: boolean; attname: string; attnum: number; + amname: string; + reloptions: readonly string[] | null; }>( `SELECT i.tablename, i.indexname, ix.indisunique, a.attname, - a.attnum + a.attnum, + am.amname, + ic.reloptions FROM pg_indexes i JOIN pg_class ic ON ic.relname = i.indexname JOIN pg_namespace ins ON ins.oid = ic.relnamespace AND ins.nspname = $1 JOIN pg_index ix ON ix.indexrelid = ic.oid + JOIN pg_am am ON am.oid = ic.relam JOIN pg_class t ON t.oid = ix.indrelid JOIN pg_namespace tn ON tn.oid = t.relnamespace AND tn.nspname = $1 LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) AND a.attnum > 0 @@ -323,7 +328,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { AND tc.table_name = i.tablename AND tc.constraint_name = i.indexname ) - ORDER BY i.tablename, i.indexname, a.attnum`, + ORDER BY i.tablename, i.indexname, array_position(ix.indkey::int[], a.attnum)`, [schema], ), // Query extensions @@ -469,7 +474,16 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { })); // Process indexes - const indexesMap = new Map(); + const indexesMap = new Map< + string, + { + columns: string[]; + name: string; + unique: boolean; + type: string | undefined; + options: Record | undefined; + } + >(); for (const idxRow of indexesByTable.get(tableName) ?? []) { if (!idxRow.attname) { continue; @@ -478,10 +492,17 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { if (existing) { existing.columns.push(idxRow.attname); } else { + // Drop btree (the Postgres default) so a contract index without an + // explicit type matches a default-method introspected index without + // forcing DROP+CREATE on every plan. + const indexType = idxRow.amname === 'btree' ? undefined : idxRow.amname; + const indexOptions = parsePgReloptions(idxRow.reloptions); indexesMap.set(idxRow.indexname, { columns: [idxRow.attname], name: idxRow.indexname, unique: idxRow.indisunique, + type: indexType, + options: indexOptions, }); } } @@ -489,6 +510,8 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { columns: Object.freeze([...idx.columns]) as readonly string[], name: idx.name, unique: idx.unique, + ...(idx.type !== undefined && { type: idx.type }), + ...(idx.options !== undefined && { options: idx.options }), })); tables[tableName] = { @@ -622,6 +645,37 @@ function mapReferentialAction(rule: string): SqlReferentialAction | undefined { * Groups an array of objects by a specified key. * Returns a Map for O(1) lookup by group key. */ +/** + * Parses a `pg_class.reloptions` array into a `Record`. + * + * Postgres returns reloptions as a `text[]` whose entries are `key=value` + * strings; the value side is always a string regardless of the underlying + * scalar type. The verifier compares contract options to introspected + * options after coercing both sides to strings, so keeping the raw text + * here is correct. + * + * Returns `undefined` when the input is null/empty (no WITH clause). + */ +function parsePgReloptions( + reloptions: readonly string[] | null, +): Record | undefined { + if (!reloptions || reloptions.length === 0) { + return undefined; + } + const result: Record = {}; + for (const entry of reloptions) { + const eq = entry.indexOf('='); + if (eq === -1) { + // Defensive: skip malformed entries rather than corrupting the IR. + continue; + } + const key = entry.slice(0, eq); + const value = entry.slice(eq + 1); + result[key] = value; + } + return Object.keys(result).length > 0 ? result : undefined; +} + function groupBy(items: readonly T[], key: K): Map { const map = new Map(); for (const item of items) { diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/index-introspection.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/index-introspection.integration.test.ts new file mode 100644 index 0000000000..a65498845e --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/index-introspection.integration.test.ts @@ -0,0 +1,105 @@ +/** + * Postgres index introspection populates `SqlIndexIR.type` and + * `SqlIndexIR.options` from `pg_am.amname` and `pg_class.reloptions`. + * + * Without these fields the migration planner would treat any contract + * index whose `type` is set as different from any introspected index on + * the same columns — forcing a spurious DROP+CREATE on every plan even + * when the live index already matches the contract. + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + createDriver, + createTestDatabase, + familyInstance, + type PostgresControlDriver, + resetDatabase, + testTimeout, +} from './fixtures/runner-fixtures'; + +describe.sequential('Postgres index introspection — type and options', () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, testTimeout); + + afterAll(async () => { + if (database) await database.close(); + }, testTimeout); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + }, testTimeout); + + afterEach(async () => { + if (driver) { + await driver.close(); + driver = undefined; + } + }); + + it( + 'leaves type and options unset on a default btree index', + { timeout: testTimeout }, + async () => { + await driver!.query('CREATE TABLE doc (id int PRIMARY KEY, body text NOT NULL)'); + await driver!.query('CREATE INDEX doc_body_idx ON doc (body)'); + + const schema = await familyInstance.introspect({ driver: driver! }); + const indexes = schema.tables['doc']?.indexes ?? []; + const idx = indexes.find((i) => i.name === 'doc_body_idx'); + expect(idx).toBeDefined(); + expect(idx?.type).toBeUndefined(); + expect(idx?.options).toBeUndefined(); + }, + ); + + it('populates type for non-default index methods (gin)', { timeout: testTimeout }, async () => { + await driver!.query('CREATE TABLE doc (id int PRIMARY KEY, tags jsonb NOT NULL)'); + await driver!.query('CREATE INDEX doc_tags_gin_idx ON doc USING gin (tags)'); + + const schema = await familyInstance.introspect({ driver: driver! }); + const idx = schema.tables['doc']?.indexes.find((i) => i.name === 'doc_tags_gin_idx'); + expect(idx).toBeDefined(); + expect(idx?.type).toBe('gin'); + expect(idx?.options).toBeUndefined(); + }); + + it( + 'populates options from reloptions when WITH parameters are set', + { timeout: testTimeout }, + async () => { + await driver!.query('CREATE TABLE doc (id int PRIMARY KEY, body text NOT NULL)'); + await driver!.query('CREATE INDEX doc_body_idx ON doc (body) WITH (fillfactor = 70)'); + + const schema = await familyInstance.introspect({ driver: driver! }); + const idx = schema.tables['doc']?.indexes.find((i) => i.name === 'doc_body_idx'); + expect(idx).toBeDefined(); + // btree is the Postgres default → type is dropped to undefined + expect(idx?.type).toBeUndefined(); + // reloptions are returned as raw text; the family verifier compares + // contract values to introspected strings via String() coercion. + expect(idx?.options).toEqual({ fillfactor: '70' }); + }, + ); + + it( + 'populates both type and options together (gin with fastupdate)', + { timeout: testTimeout }, + async () => { + await driver!.query('CREATE TABLE doc (id int PRIMARY KEY, tags jsonb NOT NULL)'); + await driver!.query( + 'CREATE INDEX doc_tags_gin_idx ON doc USING gin (tags) WITH (fastupdate = false)', + ); + + const schema = await familyInstance.introspect({ driver: driver! }); + const idx = schema.tables['doc']?.indexes.find((i) => i.name === 'doc_tags_gin_idx'); + expect(idx).toBeDefined(); + expect(idx?.type).toBe('gin'); + expect(idx?.options).toEqual({ fastupdate: 'false' }); + }, + ); +}); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.rendering.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.rendering.test.ts index 73c0307438..5d0c4659e4 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.rendering.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.rendering.test.ts @@ -235,6 +235,16 @@ describe('Postgres call classes - per-class renderTypeScript coverage', () => { expectFactoryImport(di, 'dropIndex'); }); + it('CreateIndexCall renders type and options when they are provided', () => { + const ci = new CreateIndexCall('public', 'doc', 'doc_body_idx', ['body'], { + type: 'gin', + options: { fastupdate: false }, + }); + expect(ci.renderTypeScript()).toBe( + 'createIndex("public", "doc", "doc_body_idx", ["body"], { type: "gin", options: { fastupdate: false } })', + ); + }); + it('CreateEnumTypeCall emits the enum values as an array literal', () => { const call = new CreateEnumTypeCall('public', 'status', ['active', 'archived']); expect(call.renderTypeScript()).toBe( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b7ff34c39..b6eab4a8f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,82 @@ importers: specifier: 'catalog:' version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + examples/paradedb-demo: + dependencies: + '@prisma-next/adapter-postgres': + specifier: workspace:* + version: link:../../packages/3-targets/6-adapters/postgres + '@prisma-next/contract': + specifier: workspace:* + version: link:../../packages/1-framework/0-foundation/contract + '@prisma-next/driver-postgres': + specifier: workspace:* + version: link:../../packages/3-targets/7-drivers/postgres + '@prisma-next/extension-paradedb': + specifier: workspace:* + version: link:../../packages/3-extensions/paradedb + '@prisma-next/family-sql': + specifier: workspace:* + version: link:../../packages/2-sql/9-family + '@prisma-next/postgres': + specifier: workspace:* + version: link:../../packages/3-extensions/postgres + '@prisma-next/sql-builder': + specifier: workspace:* + version: link:../../packages/2-sql/4-lanes/sql-builder + '@prisma-next/sql-contract': + specifier: workspace:* + version: link:../../packages/2-sql/1-core/contract + '@prisma-next/sql-contract-ts': + specifier: workspace:* + version: link:../../packages/2-sql/2-authoring/contract-ts + '@prisma-next/sql-runtime': + specifier: workspace:* + version: link:../../packages/2-sql/5-runtime + '@prisma-next/target-postgres': + specifier: workspace:* + version: link:../../packages/3-targets/3-targets/postgres + arktype: + specifier: ^2.1.29 + version: 2.1.29 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + pg: + specifier: 'catalog:' + version: 8.16.3 + devDependencies: + '@prisma-next/cli': + specifier: workspace:* + version: link:../../packages/1-framework/3-tooling/cli + '@prisma-next/emitter': + specifier: workspace:* + version: link:../../packages/1-framework/3-tooling/emitter + '@prisma-next/sql-contract-emitter': + specifier: workspace:* + version: link:../../packages/2-sql/3-tooling/emitter + '@prisma-next/test-utils': + specifier: workspace:* + version: link:../../test/utils + '@prisma-next/tsconfig': + specifier: workspace:* + version: link:../../packages/0-config/tsconfig + '@types/node': + specifier: 'catalog:' + version: 24.10.4 + '@types/pg': + specifier: 'catalog:' + version: 8.16.0 + tsx: + specifier: ^4.19.2 + version: 4.20.6 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + examples/prisma-next-demo: dependencies: '@prisma-next/adapter-postgres': @@ -1903,6 +1979,9 @@ importers: '@prisma-next/tsdown': specifier: workspace:* version: link:../../../0-config/tsdown + arktype: + specifier: ^2.1.25 + version: 2.1.29 tsdown: specifier: 'catalog:' version: 0.18.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(typescript@5.9.3) @@ -2413,7 +2492,40 @@ importers: '@prisma-next/contract-authoring': specifier: workspace:* version: link:../../1-framework/2-authoring/contract + '@prisma-next/family-sql': + specifier: workspace:* + version: link:../../2-sql/9-family + '@prisma-next/framework-components': + specifier: workspace:* + version: link:../../1-framework/1-core/framework-components + '@prisma-next/sql-contract': + specifier: workspace:* + version: link:../../2-sql/1-core/contract + '@prisma-next/sql-operations': + specifier: workspace:* + version: link:../../2-sql/1-core/operations + '@prisma-next/sql-relational-core': + specifier: workspace:* + version: link:../../2-sql/4-lanes/relational-core + '@prisma-next/sql-runtime': + specifier: workspace:* + version: link:../../2-sql/5-runtime + arktype: + specifier: ^2.1.25 + version: 2.1.29 devDependencies: + '@prisma-next/adapter-postgres': + specifier: workspace:* + version: link:../../3-targets/6-adapters/postgres + '@prisma-next/operations': + specifier: workspace:* + version: link:../../1-framework/1-core/operations + '@prisma-next/sql-contract-ts': + specifier: workspace:* + version: link:../../2-sql/2-authoring/contract-ts + '@prisma-next/test-utils': + specifier: workspace:* + version: link:../../../test/utils '@prisma-next/tsconfig': specifier: workspace:* version: link:../../0-config/tsconfig @@ -3409,6 +3521,9 @@ importers: '@prisma-next/extension-arktype-json': specifier: workspace:* version: link:../../packages/3-extensions/arktype-json + '@prisma-next/extension-paradedb': + specifier: workspace:* + version: link:../../packages/3-extensions/paradedb '@prisma-next/extension-pgvector': specifier: workspace:* version: link:../../packages/3-extensions/pgvector diff --git a/test/e2e/framework/test/sqlite/migrations/additive.test.ts b/test/e2e/framework/test/sqlite/migrations/additive.test.ts index 0cab99ea3d..b20c3fdf76 100644 --- a/test/e2e/framework/test/sqlite/migrations/additive.test.ts +++ b/test/e2e/framework/test/sqlite/migrations/additive.test.ts @@ -164,7 +164,7 @@ describe('SQLite Migration E2E - From empty schema', () => { fields: { id: int.id(), name: text, date: text, location: text.optional() }, }).sql((ctx) => ({ indexes: [ - ctx.constraints.index(ctx.cols.date, { name: 'idx_events_date' }), + ctx.constraints.index([ctx.cols.date], { name: 'idx_events_date' }), ctx.constraints.index([ctx.cols.name, ctx.cols.date], { name: 'idx_events_name_date', }), @@ -264,7 +264,7 @@ describe('SQLite Migration E2E - Schema evolution', () => { ...pack, models: { User: model('User', { fields: { id: int.id(), email: text } }).sql((ctx) => ({ - indexes: [ctx.constraints.index(ctx.cols.email, { name: 'idx_users_email' })], + indexes: [ctx.constraints.index([ctx.cols.email], { name: 'idx_users_email' })], })), }, }), @@ -293,7 +293,7 @@ describe('SQLite Migration E2E - Schema evolution', () => { status: text.default('active'), }, }).sql((ctx) => ({ - indexes: [ctx.constraints.index(ctx.cols.email, { name: 'idx_users_email' })], + indexes: [ctx.constraints.index([ctx.cols.email], { name: 'idx_users_email' })], })), Post: model('Post', { fields: { id: int.id(), title: text, userId: int.column('user_id') }, diff --git a/test/e2e/framework/test/sqlite/migrations/destructive.test.ts b/test/e2e/framework/test/sqlite/migrations/destructive.test.ts index a43be2b787..796290799e 100644 --- a/test/e2e/framework/test/sqlite/migrations/destructive.test.ts +++ b/test/e2e/framework/test/sqlite/migrations/destructive.test.ts @@ -39,7 +39,7 @@ describe('SQLite Migration E2E - Destructive operations', () => { ...pack, models: { User: model('User', { fields: { id: int.id(), email: text } }).sql((ctx) => ({ - indexes: [ctx.constraints.index(ctx.cols.email, { name: 'idx_users_email' })], + indexes: [ctx.constraints.index([ctx.cols.email], { name: 'idx_users_email' })], })), }, }), @@ -63,7 +63,7 @@ describe('SQLite Migration E2E - Destructive operations', () => { models: { User: model('User', { fields: { id: int.id(), email: text, name: text } }).sql( (ctx) => ({ - indexes: [ctx.constraints.index(ctx.cols.email, { name: 'idx_email' })], + indexes: [ctx.constraints.index([ctx.cols.email], { name: 'idx_email' })], }), ), }, @@ -72,7 +72,9 @@ describe('SQLite Migration E2E - Destructive operations', () => { ...pack, models: { User: model('User', { fields: { id: int.id(), email: text, name: text } }).sql( - (ctx) => ({ indexes: [ctx.constraints.index(ctx.cols.name, { name: 'idx_name' })] }), + (ctx) => ({ + indexes: [ctx.constraints.index([ctx.cols.name], { name: 'idx_name' })], + }), ), }, }), diff --git a/test/e2e/framework/test/sqlite/migrations/fk-preservation.test.ts b/test/e2e/framework/test/sqlite/migrations/fk-preservation.test.ts index 44470e13d0..0f295e7655 100644 --- a/test/e2e/framework/test/sqlite/migrations/fk-preservation.test.ts +++ b/test/e2e/framework/test/sqlite/migrations/fk-preservation.test.ts @@ -147,7 +147,7 @@ describe('SQLite Migration E2E - FK preservation through recreate-table', () => fields: { id: int.id(), email: text, name: text }, }).sql((ctx) => ({ indexes: [ - ctx.constraints.index(ctx.cols.email, { name: 'idx_users_email' }), + ctx.constraints.index([ctx.cols.email], { name: 'idx_users_email' }), ctx.constraints.index([ctx.cols.name, ctx.cols.email], { name: 'idx_users_name_email' }), ], })); @@ -155,7 +155,7 @@ describe('SQLite Migration E2E - FK preservation through recreate-table', () => fields: { id: int.id(), email: text, name: text.optional() }, }).sql((ctx) => ({ indexes: [ - ctx.constraints.index(ctx.cols.email, { name: 'idx_users_email' }), + ctx.constraints.index([ctx.cols.email], { name: 'idx_users_email' }), ctx.constraints.index([ctx.cols.name, ctx.cols.email], { name: 'idx_users_name_email' }), ], })); diff --git a/test/integration/package.json b/test/integration/package.json index 2cb9e8a692..7774126040 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -25,6 +25,7 @@ "@prisma-next/driver-postgres": "workspace:*", "@prisma-next/emitter": "workspace:*", "@prisma-next/extension-arktype-json": "workspace:*", + "@prisma-next/extension-paradedb": "workspace:*", "@prisma-next/extension-pgvector": "workspace:*", "@prisma-next/family-sql": "workspace:*", "@prisma-next/ids": "workspace:*", diff --git a/test/integration/test/authoring/paradedb-bm25-narrowing.test.ts b/test/integration/test/authoring/paradedb-bm25-narrowing.test.ts new file mode 100644 index 0000000000..f6d914dd59 --- /dev/null +++ b/test/integration/test/authoring/paradedb-bm25-narrowing.test.ts @@ -0,0 +1,242 @@ +/** + * End-to-end TS narrowing for the paradedb bm25 index type. + * + * Verifies that when a contract attaches `paradedbPack` via the + * `defineContract({...}, ({ model }) => ...)` factory form, the + * `constraints.index({ type: 'bm25', options: ... })` call site narrows + * `options` against the registered shape and rejects unregistered types + * and bad option shapes at compile time. + */ +import { int4Column, textColumn } from '@prisma-next/adapter-postgres/column-types'; +import paradedbPack from '@prisma-next/extension-paradedb/pack'; +import sqlFamily from '@prisma-next/family-sql/pack'; +import { defineContract, field, model } from '@prisma-next/sql-contract-ts/contract-builder'; +import postgresPack from '@prisma-next/target-postgres/pack'; +import { describe, expect, expectTypeOf, it } from 'vitest'; + +describe('paradedb bm25 narrowing in TS authoring DSL', () => { + it('typechecks and accepts a well-formed bm25 index via the helpers factory', () => { + const contract = defineContract( + { + family: sqlFamily, + target: postgresPack, + extensionPacks: { paradedb: paradedbPack }, + }, + ({ model: helperModel, field: helperField }) => { + const Doc = helperModel('Doc', { + fields: { + id: helperField.column(int4Column).id(), + body: helperField.column(textColumn), + }, + }).sql(({ cols, constraints }) => ({ + table: 'doc', + indexes: [ + constraints.index([cols.body], { + type: 'bm25', + options: { key_field: 'id' }, + name: 'doc_body_bm25_idx', + }), + ], + })); + return { models: { Doc } }; + }, + ); + + const indexes = contract.storage.tables.doc.indexes; + expect(indexes).toHaveLength(1); + expect(indexes[0]).toMatchObject({ + columns: ['body'], + name: 'doc_body_bm25_idx', + type: 'bm25', + options: { key_field: 'id' }, + }); + }); + + it('rejects a bm25 index with an unknown options key at compile time', () => { + expect(() => + defineContract( + { + family: sqlFamily, + target: postgresPack, + extensionPacks: { paradedb: paradedbPack }, + }, + ({ model: helperModel, field: helperField }) => { + const Doc = helperModel('Doc', { + fields: { + id: helperField.column(int4Column).id(), + body: helperField.column(textColumn), + }, + }).sql(({ cols, constraints }) => ({ + table: 'doc', + indexes: [ + constraints.index([cols.body], { + type: 'bm25', + // @ts-expect-error — bm25 options is { key_field: string } in strict mode; unknown_key is rejected + options: { key_field: 'id', unknown_key: 'x' }, + }), + ], + })); + return { models: { Doc } }; + }, + ), + ).toThrow(/unknown_key/); + }); + + it('rejects a bm25 index missing the required key_field at compile time', () => { + expect(() => + defineContract( + { + family: sqlFamily, + target: postgresPack, + extensionPacks: { paradedb: paradedbPack }, + }, + ({ model: helperModel, field: helperField }) => { + const Doc = helperModel('Doc', { + fields: { + id: helperField.column(int4Column).id(), + body: helperField.column(textColumn), + }, + }).sql(({ cols, constraints }) => ({ + table: 'doc', + indexes: [ + constraints.index([cols.body], { + type: 'bm25', + // @ts-expect-error — bm25 options requires key_field + options: {}, + }), + ], + })); + return { models: { Doc } }; + }, + ), + ).toThrow(/key_field/); + }); + + it('rejects an unregistered index type at compile time', () => { + expect(() => + defineContract( + { + family: sqlFamily, + target: postgresPack, + extensionPacks: { paradedb: paradedbPack }, + }, + ({ model: helperModel, field: helperField }) => { + const Doc = helperModel('Doc', { + fields: { + id: helperField.column(int4Column).id(), + body: helperField.column(textColumn), + }, + }).sql(({ cols, constraints }) => ({ + table: 'doc', + indexes: [ + constraints.index([cols.body], { + // @ts-expect-error — only 'bm25' is registered when paradedb is attached; 'made-up' is not + type: 'made-up', + options: { key_field: 'id' }, + }), + ], + })); + return { models: { Doc } }; + }, + ), + ).toThrow(/unregistered index type "made-up"/); + }); + + it('rejects options without a type at compile time', () => { + expect(() => + defineContract( + { + family: sqlFamily, + target: postgresPack, + extensionPacks: { paradedb: paradedbPack }, + }, + ({ model: helperModel, field: helperField }) => { + const Doc = helperModel('Doc', { + fields: { + id: helperField.column(int4Column).id(), + body: helperField.column(textColumn), + }, + }).sql(({ cols, constraints }) => ({ + table: 'doc', + indexes: [ + // @ts-expect-error — providing options without a type is a compile error when packs contribute index types + constraints.index([cols.body], { + options: { key_field: 'id' }, + }), + ], + })); + return { models: { Doc } }; + }, + ), + ).toThrow(/options without a type/); + }); + + it('imported bare model() rejects any type/options — strict by default', () => { + const Doc = model('Doc', { + fields: { + id: field.column(int4Column).id(), + body: field.column(textColumn), + }, + }).sql(({ cols, constraints }) => ({ + table: 'doc', + indexes: [ + // @ts-expect-error - bare model() has no attached packs, so no index + // type literals are registered; type/options aren't allowed at all. + constraints.index([cols.body], { type: 'made-up', options: {} }), + ], + })); + + expect(() => + defineContract({ + family: sqlFamily, + target: postgresPack, + models: { Doc }, + }), + ).toThrow(/unregistered index type "made-up"/); + }); + + it('imported bare model() still accepts a default index with no type/options', () => { + const Doc = model('Doc', { + fields: { + id: field.column(int4Column).id(), + body: field.column(textColumn), + }, + }).sql(({ cols, constraints }) => ({ + table: 'doc', + indexes: [constraints.index([cols.body])], + })); + expectTypeOf().toEqualTypeOf>(); + + defineContract({ + family: sqlFamily, + target: postgresPack, + models: { Doc }, + }); + }); + + it("helpers-bound model() carries the merged packs' index-type map", () => { + defineContract( + { + family: sqlFamily, + target: postgresPack, + extensionPacks: { paradedb: paradedbPack }, + }, + ({ model: helperModel, field: helperField }) => { + const Doc = helperModel('Doc', { + fields: { + id: helperField.column(int4Column).id(), + body: helperField.column(textColumn), + }, + }); + expectTypeOf().toMatchTypeOf<{ + readonly bm25: { readonly options: { readonly key_field: string } }; + }>(); + return { + models: { + Doc: Doc.sql({ table: 'doc' }), + }, + }; + }, + ); + }); +}); diff --git a/test/integration/test/authoring/parity/core-surface/contract.ts b/test/integration/test/authoring/parity/core-surface/contract.ts index 1b6d3fe2ed..db9b5b8a01 100644 --- a/test/integration/test/authoring/parity/core-surface/contract.ts +++ b/test/integration/test/authoring/parity/core-surface/contract.ts @@ -49,7 +49,7 @@ const Post = model('Post', { })) .sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.userId)], + indexes: [constraints.index([cols.userId])], })); export const contract = defineContract({ diff --git a/test/integration/test/authoring/parity/map-attributes/contract.ts b/test/integration/test/authoring/parity/map-attributes/contract.ts index 7c684d7fec..82b4de0a95 100644 --- a/test/integration/test/authoring/parity/map-attributes/contract.ts +++ b/test/integration/test/authoring/parity/map-attributes/contract.ts @@ -29,7 +29,7 @@ const Member = model('Member', { })) .sql(({ cols, constraints }) => ({ table: 'team_member', - indexes: [constraints.index(cols.teamId)], + indexes: [constraints.index([cols.teamId])], })); export const contract = defineContract({ diff --git a/test/integration/test/authoring/psl-index-type-options.integration.test.ts b/test/integration/test/authoring/psl-index-type-options.integration.test.ts new file mode 100644 index 0000000000..aa2b9c6bbf --- /dev/null +++ b/test/integration/test/authoring/psl-index-type-options.integration.test.ts @@ -0,0 +1,85 @@ +import { ContractValidationError } from '@prisma-next/contract/validate-contract'; +import paradedbPack from '@prisma-next/extension-paradedb/pack'; +import { parsePslDocument } from '@prisma-next/psl-parser'; +import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl'; +import postgresPack from '@prisma-next/target-postgres/pack'; +import { describe, expect, it } from 'vitest'; + +const scalarTypeDescriptors = new Map([ + ['Int', { codecId: 'pg/int4@1', nativeType: 'int4' }], + ['String', { codecId: 'pg/text@1', nativeType: 'text' }], +]); + +function interpret(schema: string) { + return interpretPslDocumentToSqlContract({ + document: parsePslDocument({ schema, sourceId: 'schema.prisma' }), + target: postgresPack, + scalarTypeDescriptors, + composedExtensionPacks: [paradedbPack.id], + composedExtensionPackRefs: [paradedbPack], + }); +} + +describe('PSL @@index type and options — integration with real paradedb pack', () => { + it('lowers the documented example to a Contract IR index node carrying type, options, and name', () => { + const result = interpret(`model Doc { + id Int @id + body String + @@index([body], type: "bm25", options: { key_field: "id" }, map: "doc_body_bm25_idx") +}`); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.storage).toMatchObject({ + tables: { + doc: { + indexes: [ + { + columns: ['body'], + name: 'doc_body_bm25_idx', + type: 'bm25', + options: { key_field: 'id' }, + }, + ], + }, + }, + }); + }); + + it('the interpreter rejects a PSL-authored bm25 index whose options miss key_field', () => { + let thrown: unknown; + try { + interpret(`model Doc { + id Int @id + body String + @@index([body], type: "bm25", options: { wrong_field: "x" }) +}`); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(ContractValidationError); + const message = (thrown as ContractValidationError).message; + expect(message).toContain('bm25'); + expect(message).toContain('key_field'); + }); + + it('the interpreter rejects a PSL-authored index whose type is not registered', () => { + expect(() => + interpret(`model Doc { + id Int @id + body String + @@index([body], type: "made-up") +}`), + ).toThrow(/unregistered index type "made-up"/); + }); + + it('the interpreter rejects an empty options literal for bm25 (missing key_field)', () => { + expect(() => + interpret(`model Doc { + id Int @id + body String + @@index([body], type: "bm25", options: {}) +}`), + ).toThrow(/key_field/); + }); +}); diff --git a/test/integration/test/family.schema-verify.basic.integration.test.ts b/test/integration/test/family.schema-verify.basic.integration.test.ts index 998d8f63a6..e5f801c8db 100644 --- a/test/integration/test/family.schema-verify.basic.integration.test.ts +++ b/test/integration/test/family.schema-verify.basic.integration.test.ts @@ -75,7 +75,7 @@ describe('family instance schemaVerify', () => { }, }).sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.userId)], + indexes: [constraints.index([cols.userId])], foreignKeys: [constraints.foreignKey(cols.userId, User.refs.id)], })); diff --git a/test/integration/test/family.schema-verify.basic.test.ts b/test/integration/test/family.schema-verify.basic.test.ts index d0577e0975..404c3ef6a6 100644 --- a/test/integration/test/family.schema-verify.basic.test.ts +++ b/test/integration/test/family.schema-verify.basic.test.ts @@ -66,7 +66,7 @@ describe('family instance schemaVerify - basic', () => { }, }).sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.userId)], + indexes: [constraints.index([cols.userId])], })); const contract = defineContract({ diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/contract-add-project-slug.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/contract-add-project-slug.ts index 1e022f5208..2dd04255a7 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/contract-add-project-slug.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/contract-add-project-slug.ts @@ -46,7 +46,7 @@ const Project = model('Project', { }, }).sql(({ cols, constraints }) => ({ table: 'project', - indexes: [constraints.index(cols.accountId)], + indexes: [constraints.index([cols.accountId])], foreignKeys: [constraints.foreignKey(cols.accountId, Account.refs.id)], })); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/contract.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/contract.ts index 4445de642d..a5c1d2a91b 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/contract.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/contract.ts @@ -45,7 +45,7 @@ const Project = model('Project', { }, }).sql(({ cols, constraints }) => ({ table: 'project', - indexes: [constraints.index(cols.accountId)], + indexes: [constraints.index([cols.accountId])], foreignKeys: [constraints.foreignKey(cols.accountId, Account.refs.id)], })); diff --git a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts index 459712ac04..9d008ca7af 100644 --- a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts +++ b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts @@ -44,7 +44,7 @@ const Post = model('Post', { })) .sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.userId)], + indexes: [constraints.index([cols.userId])], foreignKeys: [ constraints.foreignKey(cols.userId, User.refs.id, { onDelete: 'cascade', diff --git a/test/integration/test/referential-actions.integration.test.ts b/test/integration/test/referential-actions.integration.test.ts index 3da3c801a3..6559739912 100644 --- a/test/integration/test/referential-actions.integration.test.ts +++ b/test/integration/test/referential-actions.integration.test.ts @@ -227,7 +227,7 @@ describe('referential actions integration', () => { }, }).sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.userId)], + indexes: [constraints.index([cols.userId])], foreignKeys: [ constraints.foreignKey(cols.userId, User.refs.id, { onDelete: 'cascade', @@ -323,7 +323,7 @@ describe('referential actions integration', () => { }, }).sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.userId)], + indexes: [constraints.index([cols.userId])], foreignKeys: [ constraints.foreignKey(cols.userId, User.refs.id, { onDelete: 'cascade', @@ -397,7 +397,7 @@ describe('referential actions integration', () => { }, }).sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.userId)], + indexes: [constraints.index([cols.userId])], foreignKeys: [ constraints.foreignKey(cols.userId, User.refs.id, { onDelete: 'cascade', @@ -498,7 +498,7 @@ describe('referential actions integration', () => { }, }).sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.userId)], + indexes: [constraints.index([cols.userId])], foreignKeys: [constraints.foreignKey(cols.userId, User.refs.id)], })); @@ -574,7 +574,7 @@ describe('referential actions integration', () => { }, }).sql(({ cols, constraints }) => ({ table: 'post', - indexes: [constraints.index(cols.userId)], + indexes: [constraints.index([cols.userId])], foreignKeys: [ constraints.foreignKey(cols.userId, User.refs.id, { onDelete: 'cascade',