diff --git a/.changeset/callable-compiled-queries.md b/.changeset/callable-compiled-queries.md new file mode 100644 index 00000000..16c32fd0 --- /dev/null +++ b/.changeset/callable-compiled-queries.md @@ -0,0 +1,8 @@ +--- +'@cleverbrush/knex-schema': minor +'@cleverbrush/orm': minor +--- + +Add `parameter('name')` for callable PostgreSQL SELECT templates with schema-inferred positional arguments. Compile SQL, binding slots and decoding once on first invocation or `.toSQL(...)`; reuse them with independent values on later calls. `.query(...)` returns an ordinary composable bound reader. + +Support repeated names, grouped filters, fixed membership/range slots, alias joins, relation and STI/CTI customizers, and caller-owned transactions. Preserve storage types, null comparison semantics, result schemas, property navigation, and ORM identity tracking. Reject unsupported placeholder positions and unbound terminals or writes before execution. diff --git a/docs/framework-feature-candidates.md b/docs/framework-feature-candidates.md index 961c5f2d..7236eeb0 100644 --- a/docs/framework-feature-candidates.md +++ b/docs/framework-feature-candidates.md @@ -1,296 +1,12 @@ # Framework feature candidates -Status: F01–F06 merged; F07 implemented on the polymorphic-write lifecycle branch for PR review. -Assessment date: 2026-10-02. +Assessment date: 2026-10-03. -This is an unprioritized list of reusable Framework capabilities and correctness -fixes. Numbering identifies candidates; it does not indicate implementation order. -Priority fields are intentionally blank. There are no estimates or release -commitments, and proposed interfaces are not current supported APIs. +This file lists outstanding reusable Framework capabilities and correctness +fixes. Completed candidates are removed; their identifiers are not reused. +There are currently no outstanding candidates recorded here. The next candidate +identifier is F09. -## Review overview - -| ID | Candidate | Main packages | Priority | -| --- | --- | --- | --- | -| F01 | Reliable multiple-file multipart uploads | `server` | | -| F02 | Typed upload contracts, client support, and OpenAPI | `server`, `client`, `server-openapi` | | -| F03 | Lossless JSONB document storage | `knex-schema`, `orm` | | -| F04 | Provider-independent object storage | Proposed `storage` package | | -| F05 | S3-compatible storage adapter | Proposed `storage-s3` package | | -| F06 | CORS preflight support | `server` | | -| F07 | Consistent polymorphic ORM writes | `orm`, `knex-schema` | | - -Package names in the table omit the `@cleverbrush/` scope. New package names are -proposals for review. - -## F01 — Reliable multiple-file multipart uploads - -**Priority:** - -**Original assessment.** `.upload()` parses multipart requests and exposes -buffered files. The [parser](../libs/server/src/Server.ts) stores one file per field -name and does not handle all parser limit signals. Read-only HTTP probes found: - -| Request | Observed result | -| --- | --- | -| Two files under the same `files` field | Only the second file reached the handler | -| Eight-byte file with a four-byte limit | Success with four truncated bytes | -| Three files with a two-file limit | Third file silently discarded | -| Multipart body exceeding `maxBodySize` | Request accepted | -| Upload endpoint without a body schema | Uploaded files not populated | - -**Proposed capability.** Preserve multiple files per field and their order. Enforce -file, request, field, and part limits; never present truncated files as successful -uploads. Provide explicit rejection information, terminate interrupted parsing, -and release resources. File-only requests must work without an unrelated text -body schema. Bounded buffering is sufficient for the initial capability; streaming -or temporary-file upload modes can be considered separately. - -**Public API implications.** Extend upload options and handler file collections -without breaking existing single-file usage. Coordinate collection types and -rejection behavior with F02. - -**Acceptance criteria.** - -- Repeated fields preserve every accepted file, filename, and byte sequence. -- Size/count limits, including total multipart size, produce explicit failures or - documented rejections without silent data loss. -- File-only requests, malformed bodies, disallowed types, truncated fields, and - disconnected requests have coverage. -- Existing single-file endpoints continue to work. - -**Implementation:** Added schema-based `file()` / `array(file())` uploads, -resource-limit enforcement, typed client serialization and matching OpenAPI. -See the [server upload guide](../libs/server/README.md#file-upload). - -**Review notes:** - -## F02 — Typed upload contracts, client support, and OpenAPI - -**Priority:** - -**Original assessment.** The typed client accepts one `FilePart` or `Blob` per -field. Its [request builder](../libs/client/src/client.ts) only creates multipart -bodies when a body argument is supplied. The -[OpenAPI generator](../libs/server-openapi/src/generateOpenApiSpec.ts) describes -multipart text fields but does not describe the uploaded file fields. - -**Proposed capability.** Declare file field names, requiredness, and cardinality -in endpoint contracts. Infer matching handler and browser-client types. Serialize -file collections as repeated multipart fields, including file-only requests, and -generate equivalent OpenAPI binary-file schemas. - -**Public API implications.** Extend endpoint upload metadata, inferred handler and -client argument types, and OpenAPI generation together. Preserve legacy single-file -calls. Keep shared contracts browser-safe and avoid duplicated application DTOs or -manual `FormData` construction in ordinary client calls. - -**Acceptance criteria.** - -- Type tests cover required/optional file fields and single/multiple cardinality. -- A real typed-client request reaches the server with all files intact. -- File-only requests work without a dummy body argument. -- OpenAPI describes the same file fields and requirements as the runtime contract. -- Existing client calls and browser builds remain compatible. - -**Review notes:** - -## F03 — Lossless JSONB document storage - -**Priority:** - -**Original assessment.** JSONB DDL already exists through `.jsonb()`. However, -the [read-schema compiler](../libs/knex-schema/src/read-schema.ts) reconstructs -objects from declared properties. A decoder probe using a stored document with -`type`, `scenes`, and extension data returned only `type` when that was the only -declared property. Nested `any` schemas and record-based JSONB columns were also -rejected. This probe exercised decoding, not a complete PostgreSQL round trip. - -**Proposed capability.** Use native `object({...}).jsonb()` schemas for document -columns. Add `.acceptUnknownProps()` at each object node whose undeclared JSON -fields must survive reads and write-returning results. Document roots are objects; -nested values can include arrays, scalars and nulls. Reject non-JSON extension -values before persistence, while retaining declared-field serialization and -strict object projection behavior. - -**Public API implications.** Reuse existing object schemas and database extensions. -Keep JSON storage validation, serialization and decoding in `knex-schema` and ORM -tracking in `orm`. Schema and JSON Schema packages remain database-agnostic. -Declared fields retain normal type inference; unknown fields are preserved at -runtime. Identifiers, ownership and revision metadata remain relational columns. - -**Acceptance criteria.** - -- Real PostgreSQL insert, update, select, projection, and ORM round trips preserve - nested objects, arrays, nulls, optional fields, and undeclared extension keys. -- Write-returning results and subsequent reads have the same document shape. -- Invalid values such as functions, cycles, and non-finite numbers are rejected. -- Type inference and mapping preserve the JSON-document contract without `any`. -- Equality is structural; JSON object key ordering is not a storage guarantee. -- Existing relational projection and strict-schema behavior remains unchanged. - -**Implementation:** Added native open-object preservation, database-local JSON -validation, PostgreSQL round-trip coverage and document-aware ORM tracking. -See the [JSONB guide](../libs/knex-schema/README.md#lossless-jsonb-documents). - -**Review notes:** - -## F04 — Provider-independent object storage - -**Priority:** - -**Existing support and gap.** HTTP file/stream results exist, but the Framework has -no reusable object-storage contract or implementation. Applications must currently -own provider access and object lifecycle plumbing themselves. - -**Proposed capability.** Introduce a small server-side storage abstraction for -writing, streaming reads, metadata lookup, copying, and deletion. Identify objects -by stable keys and carry content type, size, and relevant metadata. Support -cancellation and explicit resource ownership for streams. Allow applications to -configure stable public asset URLs independently of provider endpoints. - -**Public API implications.** Add a proposed `@cleverbrush/storage` package with -provider-neutral interfaces, results, and errors suitable for dependency injection. -The core package must not require an S3 SDK. Application ownership checks, -reference tracking, rendering, and database/filesystem consistency policies remain -application responsibilities. - -**Acceptance criteria.** - -- A shared adapter contract suite exercises read/write/stat/copy/delete behavior. -- Missing objects, failed writes, cancellation, and stream cleanup are explicit. -- Content metadata survives storage and retrieval. -- Public URL construction handles object keys correctly and does not expose - credentials or depend on temporary signed URLs. - -**Implementation:** Added the provider-neutral storage contract, portable errors, key and public URL helpers, and a shared adapter contract suite. See the [storage guide](../libs/storage/README.md). - -**Review notes:** - -## F05 — S3-compatible storage adapter - -**Priority:** - -**Existing support and gap.** There is no S3 adapter in the Framework. This -candidate supplies the first production implementation of F04. - -**Proposed capability.** Add an adapter using the modular AWS SDK, configurable -with endpoint, region, bucket, credentials, path-style addressing, and key prefix. -Support streaming transfers and metadata, plus a separately configured public -asset base URL. All durable assets can use object storage while processing tools -materialize temporary local inputs when needed. - -**Public API implications.** Add a proposed `@cleverbrush/storage-s3` package -implementing F04. Keep SDK types and credentials out of browser contracts. Preserve -public reads at stable URLs; bucket/CDN/proxy provisioning remains deployment -configuration. Direct browser uploads, private signed URLs, and provider-specific -features are separate future candidates. - -**Acceptance criteria.** - -- Run the storage contract suite against an S3-compatible test service. -- Exercise custom endpoints, path-style addressing, prefixes, public URL mapping, - metadata, streaming, copies, and deletion. -- Cover missing objects, failed/interrupted transfers, repeated operations, and - provider errors without leaking credentials. -- Verify object contents with an independent checksum or byte comparison rather - than assuming an ETag always represents a content checksum. - -**Implementation:** Added the configurable S3 adapter, bounded multipart transfers, cancellation and stream cleanup, Garage integration tests and a dedicated CI job. Hetzner configuration is documented; a live Hetzner account is not included in CI. See the [S3 guide](../libs/storage-s3/README.md). - -**Review notes:** - -## F06 — CORS preflight support - -**Priority:** - -**Original assessment.** Applications can set response headers in middleware, -but [route matching](../libs/server/src/Server.ts) occurs first. An HTTP probe sent -an OPTIONS preflight to a POST route and received `405`; ordinary middleware never -ran. Application middleware alone therefore cannot handle that preflight. - -**Proposed capability.** Provide opt-in CORS configuration that handles preflight -before route rejection, while retaining normal authentication and authorization -for the actual request. Apply appropriate CORS headers to successful and error -responses. Support configured origins, methods, allowed/exposed headers, and -credential behavior. - -**Public API.** `ServerBuilder.useCors(options)` and the exported -`ServerCorsOptions` provide a server-wide policy with static origins or synchronous/ -asynchronous origin predicates, preflight methods and headers, exposed headers, -credentials and browser preflight cache duration. Ordinary middleware order is -unchanged. - -**Acceptance criteria.** - -- Preflight for a registered route succeeds when origin/method/headers are allowed. -- Disallowed origins, methods, and headers are not inadvertently authorized. -- Relevant validation, authentication, not-found, and method errors have consistent - CORS behavior. -- Actual protected requests still require authentication. -- Same-origin applications without CORS configuration remain unaffected. - -**Implementation:** Added the CORS stage before routing and authentication, -route-aware preflight responses, rejection of disallowed origins before handlers, -and consistent headers on success/error responses and cache/idempotency replays. -Includes real HTTP and type tests, built-in health/batch route support, and updated -documentation. See the [CORS guide](../libs/server/README.md#cors). - -**Review notes:** - -## F07 — Consistent polymorphic ORM writes - -**Priority:** - -**Original assessment.** Ordinary entities support soft deletion and lifecycle -hooks. Source review of [variant writes](../libs/orm/src/variant-write.ts) found -direct Knex updates/deletes: variant deletion physically removes rows and these -paths bypass the ordinary write pipeline. This finding needs PostgreSQL -integration coverage before its complete behavior and compatibility impact are -considered verified. - -**Proposed capability.** Make polymorphic mutations honor applicable soft-delete, -timestamp, and lifecycle-hook metadata. Keep an explicit permanent-deletion path. -Define consistent behavior across single-table and class-table variants, including -transactional updates to related base/variant rows. - -**Public API implications.** Review variant update/delete/restore/permanent-delete -operations alongside ordinary DbSet behavior. Changing physical deletion to soft -deletion may affect existing consumers and requires an explicit compatibility and -release decision. Ordinary entity relationships remain a viable alternative while -this candidate is pending. - -**Acceptance criteria.** - -- PostgreSQL tests cover soft deletion, restoration, and permanent deletion for - both polymorphic storage strategies. -- Hooks and timestamps follow the documented ordinary-entity contract. -- Failures roll back base/variant writes together and respect query predicates. -- Type tests describe the supported mutation surface accurately. - -**Implementation:** Variant and tracked polymorphic writes share an atomic -lifecycle pipeline for STI and CTI, including hooks, timestamps, soft deletion, -restoration and explicit permanent deletion. Base soft-delete metadata controls -visibility; CTI child rows remain intact until permanent deletion. PostgreSQL -regressions cover scopes, transaction/savepoint rollback, concurrency and exact -keys. The deletion change ships in the coordinated v5 major release; see the -[ORM lifecycle contract](../libs/orm/README.md#variant-deletion-and-lifecycle) and -[v5 migration guide](../libs/knex-schema/MIGRATION-v5.md#7-review-polymorphic-mutation-lifecycle). - -**Review notes:** - -## Dependencies and review boundaries - -- F01 and F02 form one coordinated upload capability; priorities remain open. -- F05 depends on F04. Its integration tests should reuse the storage contract suite. -- Existing bearer-token authentication, typed API calls, durable jobs, dependency - injection, logging, and tracing can be reused. Additional authentication methods - are not part of this list. -- Rendering engines, document revision rules, asset ownership, shared-asset cleanup, - and data-import tools belong to applications, rather than Framework packages. -- Each accepted candidate needs focused tests, documentation, and a changeset for - published-package changes. Required repository gates remain `npm run lint`, - `npm run build`, and `npm run test`; database/storage features also need their - relevant integration suites. - -**Overall review notes:** +Supported query APIs are documented in the +[query guide](../libs/knex-schema/README.md#parameterized-compiled-queries) and +[ORM guide](../libs/orm/README.md#parameterized-compiled-reads). diff --git a/libs/benchmarks/src/compiled-query.bench.ts b/libs/benchmarks/src/compiled-query.bench.ts new file mode 100644 index 00000000..dbcfe72e --- /dev/null +++ b/libs/benchmarks/src/compiled-query.bench.ts @@ -0,0 +1,78 @@ +import Knex from 'knex'; +import { bench, describe } from 'vitest'; +import { + number, + object, + parameter, + query, + string +} from '../../knex-schema/src/index.js'; + +// No database latency: compare construction/compilation separately from binding. +const knex = Knex({ client: 'pg' }); +const User = object({ + id: number().primaryKey(), + name: string(), + age: number() +}).hasTableName('users'); +const template = () => + query(knex, User) + .where(t => t.name, parameter('name')) + .where(t => t.age, '>=', parameter('age')) + .orderBy(t => t.id) + .limit(10); +const warmed = template(); +warmed.toSQL('John', 18); +let value = 18; + +describe('parameterized queries (no database)', () => { + bench('construct a template', () => { + template(); + }); + bench('construct and compile on first use', () => { + template().toSQL('John', value++ % 80); + }); + bench('bind a warmed statement', () => { + warmed.toSQL('John', value++ % 80); + }); + bench('rebuild and compile an ordinary query', () => { + query(knex, User) + .where(t => t.name, 'John') + .where(t => t.age, '>=', value++ % 80) + .orderBy(t => t.id) + .limit(10) + .compile() + .toSQL(); + }); + bench('materialize and compile a bound reader', () => { + warmed + .query('John', value++ % 80) + .compile() + .toSQL(); + }); +}); + +// Exercise the real Knex runner and decoder with an in-memory driver response. +const executionDb = Knex({ client: 'pg' }); +executionDb.client.acquireConnection = async () => ({}); +executionDb.client.releaseConnection = async () => {}; +executionDb.client._query = async (_connection: unknown, statement: any) => { + statement.response = { + command: 'SELECT', + rows: [{ id: 1, name: 'John', age: 18 }] + }; + return statement; +}; +const executeWarmed = query(executionDb, User).where( + t => t.id, + parameter('id') +); +executeWarmed.toSQL(1); +describe('query execution (stubbed database)', () => { + bench('execute a warmed statement and decode', async () => { + await executeWarmed(1); + }); + bench('rebuild, execute and decode', async () => { + await query(executionDb, User).where(t => t.id, 1); + }); +}); diff --git a/libs/knex-schema/README.md b/libs/knex-schema/README.md index 419619a1..a4e8f112 100644 --- a/libs/knex-schema/README.md +++ b/libs/knex-schema/README.md @@ -202,6 +202,124 @@ Available WHERE methods: `where`, `andWhere`, `orWhere`, `whereNot`, `whereIn`, --- +## Parameterized compiled queries + +Use `parameter('name')` in a typed predicate to make a query callable. Call it +with values to execute a SELECT, use `.query(...)` to get an independent bound +reader, or `.toSQL(...)` to inspect SQL and bindings without execution. + +```ts +import { number, object, parameter, query, string } from '@cleverbrush/knex-schema'; + +const User = object({ + id: number().primaryKey(), + firstName: string().hasColumnName('first_name'), + lastName: string().hasColumnName('last_name'), + age: number() +}).hasTableName('users'); + +// One numeric argument, inferred from User.id. +const findUser = query(knex, User).where(t => t.id, parameter('id')); +const first = await findUser(10); +const second = await findUser(20); + +// Two arguments in first-appearance order: string, number. +const findUsers = query(knex, User) + .where(t => t.firstName, parameter('firstName')) + .where(t => t.age, '>=', parameter('minimumAge')); +const users = await findUsers('John', 18); + +// Three arguments; constants do not add arguments. +const inAgeRange = query(knex, User) + .where(t => t.id, '>', 0) + .where(t => t.firstName, parameter('name')) + .whereBetween(t => t.age, [parameter('minimum'), parameter('maximum')]); +const matches = await inAgeRange('Jane', 18, 65); + +// Repeated names share one argument, including inside groups. +const byName = query(knex, User).where(p => p + .where(t => t.firstName, parameter('name')) + .orWhere(t => t.lastName, parameter('name'))); +const names = await byName('John'); + +// Fixed membership tuples retain a fixed SQL shape. +const byIds = query(knex, User) + .whereIn(t => t.id, [parameter('first'), parameter('second')]); +const pair = await byIds(10, 20); +``` + +The first direct call or `.toSQL(...)` compiles the SQL and caches its binding +slots and result decoder. Later calls reuse them without rebuilding the query +or rerunning selectors, groups, relation customizers, or variant customizers. +Every invocation executes against the database; results are not cached. Each +call receives independent bindings, including copies of dates and JSON values. + +```ts +const { sql, bindings } = findUsers.toSQL('John', 18); // warm without executing +const bound = findUsers.query('John', 18); // bind without executing +const oldest = await bound.orderBy(t => t.age, 'desc').limit(10).execute(); +const debugSql = bound.toQuery(); + +const firstTen = findUsers.limit(10).select(t => ({ id: t.id })); +const ids = await firstTen('John', 18); // { id: number }[]; independent SQL cache + +await knex.transaction(async trx => { + const rows = await findUsers.transacting(trx)('John', 18); +}); +``` + +`.query(...)` retains ordinary composition, terminals, and permitted writes; +this optional path uses normal query-building machinery. It never changes the +template or another bound reader. Transaction derivatives from the same Knex +client share compiled SQL and use the caller's transaction without committing +or rolling it back. A different client configuration gets an independent plan. + +Parameters also work with flat alias joins, relation includes, and STI/CTI +variants. A child's distinct parameter names enter the parent's argument list +when that child is configured. Repeated names across the graph share an argument. +Removing a variant removes arguments used only by that variant; surviving names +keep their order. + +```ts +const findProjects = query(knex, ProjectEntity.schema) + .include(t => t.tasks, tasks => tasks + .where(t => t.title, parameter('title'))) + .where(t => t.id, parameter('projectId')); +const projects = await findProjects('Review', 10); + +const findAssets = query(knex, AssetEntity.schema) + .forVariant('photo', photos => photos + .where(t => t.width, '>=', parameter('minimumWidth'))) + .where(t => t.id, '>=', parameter('minimumId')); +const assets = await findAssets(640, 1); +``` + +Names must be non-empty string literals. Missing/extra arguments, incorrect +value types, and incompatible reuse of one name are type errors. JavaScript +callers also receive runtime arity and storage-type checks before execution. +Types follow stored values: exact decimal/bigint fields take strings, timestamps +take valid `Date` objects, and optional/nullable columns allow `null`, never +`undefined`. Input defaults and preprocessors are not replayed. + +Nullable predicates preserve ordinary Knex semantics: shorthand +`where(t => t.age, parameter('age'))` with `null` matches SQL null, while an +explicit operator such as `where(t => t.age, '=', parameter('age'))` keeps that +operator's SQL null semantics. A single compiled statement handles both null +and non-null arguments. + +This API supports PostgreSQL SELECTs with fixed SQL shapes. Placeholders belong +in schema-backed scalar comparisons, `whereNot`, LIKE helpers on string fields, +ranges, or fixed membership tuples. Use `.whereIn(column, [parameter('id'), ...])` +for membership; a parameter cannot stand for a variable-length list. Placeholders +are not supported in raw SQL/bindings, object-form filters, JSON-path comparisons, +schema scopes, pagination controls, operators, identifiers, or mutations. + +An unbound template is callable, not thenable: `await template` does not execute. +Parameterless terminals, writes and SQL escape hatches are unavailable until +values are bound. SQL inspection uses `?` value placeholders and separate +bindings; normal execution uses the PostgreSQL driver's bindings. This caches +application-side SQL compilation, not named server-side prepared statements. + ## Ordering, Pagination, Grouping ```typescript diff --git a/libs/knex-schema/integration/compiled-queries.test.ts b/libs/knex-schema/integration/compiled-queries.test.ts new file mode 100644 index 00000000..507820fa --- /dev/null +++ b/libs/knex-schema/integration/compiled-queries.test.ts @@ -0,0 +1,320 @@ +import { randomUUID } from 'node:crypto'; +import { + alias, + array, + createDb, + date, + defineEntity, + eq, + number, + object, + parameter, + query, + string +} from '@cleverbrush/orm'; +import Knex from 'knex'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +const connection = process.env.QUERY_TEST_DATABASE_URL; +if (!connection) throw new Error('QUERY_TEST_DATABASE_URL is required'); +const knex = Knex({ client: 'pg', connection }); +const prefix = `cb_compiled_${randomUUID().replaceAll('-', '')}`; +const users = `${prefix}_users`; +const tasks = `${prefix}_tasks`; +const assets = `${prefix}_assets`; +const photos = `${prefix}_photos`; +const User = object({ + id: number().primaryKey(), + name: string().hasColumnName('first_name'), + lastName: string().hasColumnName('last_name'), + age: number().optional(), + birthday: date().optional(), + balance: number().decimal(24, 6), + tags: array(string()).optional(), + profile: object({ when: date() }).optional() +}).hasTableName(users); +const Task = defineEntity( + object({ + id: number().primaryKey(), + ownerId: number().hasColumnName('owner_id'), + owner: User.optional() + }).hasTableName(tasks) +).belongsTo( + t => t.owner, + t => t.ownerId, + t => t.id +); +const Asset = defineEntity( + object({ + id: number().primaryKey(), + kind: string(), + title: string() + }).hasTableName(assets) +) + .discriminator('kind') + .stiVariant('note', object({ text: string() })) + .ctiVariant( + 'photo', + defineEntity( + object({ + assetId: number().hasColumnName('asset_id'), + width: number() + }).hasTableName(photos) + ), + t => t.assetId + ); + +beforeAll(async () => { + await knex.schema.createTable(users, t => { + t.integer('id').primary(); + t.text('first_name'); + t.text('last_name'); + t.integer('age'); + t.timestamp('birthday', { useTz: true }); + t.decimal('balance', 24, 6); + t.jsonb('tags'); + t.jsonb('profile'); + }); + await knex.schema.createTable(tasks, t => { + t.integer('id').primary(); + t.integer('owner_id'); + }); + await knex.schema.createTable(assets, t => { + t.integer('id').primary(); + t.text('kind'); + t.text('title'); + t.text('text'); + }); + await knex.schema.createTable(photos, t => { + t.integer('asset_id').primary(); + t.integer('width'); + }); + await knex(users).insert([ + { + id: 1, + first_name: 'John', + tags: JSON.stringify(['reader', 'writer']), + profile: { when: '2026-01-01T00:00:00.000Z' }, + last_name: 'Doe', + age: 30, + birthday: '2000-01-01T00:00:00Z', + balance: '9007199254740993.000001' + }, + { + id: 2, + first_name: 'Jane', + last_name: 'Doe', + age: 18, + birthday: null, + balance: '1.000000' + }, + { + id: 3, + first_name: 'John', + last_name: 'Smith', + age: null, + birthday: null, + balance: '2.000000' + } + ]); + await knex(tasks).insert([ + { id: 10, owner_id: 1 }, + { id: 20, owner_id: 2 } + ]); + await knex(assets).insert([ + { id: 1, kind: 'note', title: 'One', text: 'hello' }, + { id: 2, kind: 'photo', title: 'Two', text: null }, + { id: 3, kind: 'note', title: 'Three', text: 'bye' } + ]); + await knex(photos).insert({ asset_id: 2, width: 100 }); +}); +afterAll(async () => { + for (const table of [photos, assets, tasks, users]) + await knex.schema.dropTableIfExists(table); + await knex.destroy(); +}); + +describe('compiled queries against PostgreSQL', () => { + it('binds typed JSON arrays and objects with nested dates as JSON values', async () => { + const read = query(knex, User) + .where(t => t.tags, parameter('tags')) + .where(t => t.profile, parameter('profile')); + const tags = ['reader', 'writer']; + const profile = { when: new Date('2026-01-01T00:00:00Z') }; + const bound = read.query(tags, profile); + const sql = read.toSQL(tags, profile); + expect((await read(tags, profile)).map(x => x.id)).toEqual([1]); + profile.when.setFullYear(2030); + tags.push('changed'); + expect((await bound).map(x => x.id)).toEqual([1]); + expect(sql.bindings).toContain(JSON.stringify(['reader', 'writer'])); + expect(await read(tags, profile)).toEqual([]); + }); + it('matches ordinary reads across values, projections and concurrent invocations', async () => { + const read = query(knex, User) + .where(t => t.name, parameter('name')) + .where(t => t.id, '>=', parameter('minimum')) + .orderBy(t => t.id); + const [john, jane] = await Promise.all([ + read('John', 1), + read('Jane', 2) + ]); + expect(john).toEqual(await read.query('John', 1)); + expect(jane).toEqual( + await query(knex, User) + .where(t => t.name, 'Jane') + .where(t => t.id, '>=', 2) + .orderBy(t => t.id) + ); + expect(john[0].birthday).toBeInstanceOf(Date); + expect(john[0].balance).toBe('9007199254740993.000001'); + expect(await read.select(t => ({ name: t.name }))('John', 2)).toEqual([ + { name: 'John' } + ]); + expect(await read("' OR 1=1 --", 1)).toEqual([]); + }); + + it('retains Knex null equality, inequality and negation semantics', async () => { + for (const operator of ['=', '!=', '<>']) { + const compiled = query(knex, User) + .where(t => t.age, operator, parameter('age')) + .orderBy(t => t.id); + for (const value of [null, 18, 30]) { + expect(await compiled(value)).toEqual( + await query(knex, User) + .where(t => t.age, operator, value) + .orderBy(t => t.id) + ); + } + } + const not = query(knex, User) + .whereNot(t => t.age, parameter('age')) + .orderBy(t => t.id); + for (const value of [null, 18]) + expect(await not(value)).toEqual( + await query(knex, User) + .whereNot(t => t.age, value) + .orderBy(t => t.id) + ); + }); + + it('supports groups, repeated names, LIKE, ranges, and fixed membership tuples', async () => { + const read = query(knex, User) + .where(p => + p + .where(t => t.name, parameter('name')) + .orWhere(t => t.lastName, parameter('name')) + ) + .whereBetween(t => t.id, [parameter('low'), parameter('high')]) + .whereIn(t => t.id, [parameter('low'), 2, 3]) + .orderBy(t => t.id); + expect((await read('John', 1, 3)).map(x => x.id)).toEqual([1, 3]); + expect((await read('Doe', 2, 3)).map(x => x.id)).toEqual([2]); + expect( + ( + await query(knex, User).whereILike( + t => t.name, + parameter('pattern') + )('jo%') + ) + .map(x => x.id) + .sort() + ).toEqual([1, 3]); + }); + + it('handles included child parameters and flat nullable joins', async () => { + const customize = vi.fn((q: any) => q.where('name', parameter('name'))); + const read = query(knex, Task.schema) + .include(t => t.owner, customize) + .where(t => t.id, '>=', parameter('minimum')) + .orderBy(t => t.id); + const rows = await (read as any)('John', 10); + expect(rows).toEqual(await (read as any).query('John', 10)); + expect(rows[0].owner.name).toBe('John'); + expect(customize).toHaveBeenCalledTimes(1); + const flat = query(knex, alias(Task.schema, 't')) + .leftJoin(alias(User, 'u'), t => eq(t.t.ownerId, t.u.id)) + .where(t => t.u.age, parameter('age')) + .select(t => ({ id: t.t.id, name: t.u.name })); + expect(await flat(18)).toEqual([{ id: 20, name: 'Jane' }]); + expect(await flat(null)).toEqual([]); + }); + + it('supports STI and CTI customizers, preserves callback capture and prunes arguments', async () => { + const read = query(knex, Asset.schema) + .forVariant('note', q => q.where(t => t.text, parameter('text'))) + .forVariant('photo', q => + q.where(t => t.width, '>=', parameter('width')) + ) + .where(t => t.id, '>=', parameter('id')) + .orderBy(t => t.id); + expect(await read('hello', 50, 1)).toEqual( + await read.query('hello', 50, 1) + ); + expect((await read('hello', 50, 1)).map(x => x.id)).toEqual([1, 2]); + expect((await read('bye', 200, 1)).map(x => x.id)).toEqual([3]); + const photo = read.selectVariants(['photo']); + expect((await photo(50, 1)).map(x => x.id)).toEqual([2]); + expect(await photo(200, 1)).toEqual([]); + }); + + it('reuses the statement in caller-owned transactions without recompiling', async () => { + const read = query(knex, User).where(t => t.id, parameter('id')); + const before = read.toSQL(1); + await knex.transaction(async trx => { + await trx(users) + .where('id', 1) + .update({ first_name: 'Transaction' }); + const txRead = read.transacting(trx); + const compiler = vi.spyOn(trx.client, 'queryCompiler'); + try { + expect(txRead.toSQL(1).sql).toBe(before.sql); + expect((await txRead(1))[0].name).toBe('Transaction'); + expect(compiler).not.toHaveBeenCalled(); + } finally { + compiler.mockRestore(); + } + await trx.rollback(); + }); + expect((await read(1))[0].name).toBe('John'); + }); + + it('preserves ORM identity tracking, bound lookups, and detached projections', async () => { + const db = createDb( + knex, + { users: defineEntity(User), assets: Asset }, + { tracking: true } + ); + const find = db.users.where(t => t.id, parameter('id')); + const first = (await find(1))[0]; + expect((await find(1))[0]).toBe(first); + expect(await find.query(1).find(1)).toBe(first); + expect(db.entry(first).state).toBe('Unchanged'); + const projected = ( + await find.select(t => ({ id: t.id, name: t.name }))(1) + )[0]; + expect(projected).not.toBe(first); + expect(() => db.entry(projected)).toThrow(/not tracked/i); + const photo = db.assets + .ofVariant('photo') + .where(t => t.id, parameter('id')); + const image = (await photo(2))[0]; + expect(await photo.query(2).find(2)).toBe(image); + expect(() => (photo as any).update({ title: 'unsafe' })).toThrow( + /unbound/ + ); + }); + + it('reports execution errors through Knex and leaves the cached plan reusable', async () => { + const read = query(knex, User).where(t => t.id, parameter('id')); + const error = vi.fn(); + knex.on('query-error', error); + try { + await expect(read(1.5)).rejects.toThrow(); + expect(error).toHaveBeenCalledTimes(1); + expect((await read(1))[0].id).toBe(1); + } finally { + knex.removeListener('query-error', error); + } + }); +}); diff --git a/libs/knex-schema/src/AliasedQueryBuilder.ts b/libs/knex-schema/src/AliasedQueryBuilder.ts index d7cebc8c..9071ef29 100644 --- a/libs/knex-schema/src/AliasedQueryBuilder.ts +++ b/libs/knex-schema/src/AliasedQueryBuilder.ts @@ -6,15 +6,33 @@ import type { JoinPredicate, TableAlias } from './aliased-query.js'; +import { + assertParametersBound, + COMPILE_PARAMETERS, + COMPILED_READER, + type CompiledReader, + copyParameterOrder, + finishParameterizedQuery, + shareParameterCompilation, + unwrapParameterizedQuery +} from './compiled-query.js'; import { type AggregateExpression, type AliasedColumn, COLUMN } from './expressions.js'; import { OpaqueQuery, type QueryOutput } from './OpaqueQuery.js'; +import type { + ParameterReader, + ParameterState, + QueryView, + WithoutParameters +} from './parameter-types.js'; import { + bindReadPredicate, captureReadRaw, captureValue, + predicateParameters, type ReadPredicate, type ReadPredicateContext, ReadPredicates @@ -46,8 +64,9 @@ type Selector = (tables: ReadAliasTables) => AliasedColumn; /** Immutable flat joined read. Supply select() before accessing rowSchema or executing. */ export class AliasedQueryBuilder< T, - Row extends ReadObject = never -> extends ReadPredicates> { + Row extends ReadObject = never, + P extends ParameterState = [] +> extends ReadPredicates, P, AliasParameterReader> { private fields?: Record; private schema?: Row; private predicates: readonly ReadPredicate[] = []; @@ -65,18 +84,24 @@ export class AliasedQueryBuilder< return this.schema; } private copy(): this { - return Object.assign(Object.create(Object.getPrototypeOf(this)), this, { - planner: this.planner.cloneReadSource() - }); + const copy = Object.assign( + Object.create(Object.getPrototypeOf(this)), + this, + { + planner: this.planner.cloneReadSource() + } + ); + copyParameterOrder(this, copy); + return copy; } /** Add an immutable inner join with typed alias predicates. */ join( table: N extends keyof T ? never : TableAlias, on: (tables: ReadAliasTables>) => JoinPredicate - ): AliasedQueryBuilder, Row> { + ): QueryView, Row, P>> { const copy = this.copy(); copy.planner = copy.planner.join(table, on as any) as any; - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Add an immutable left join; projected right-side fields are nullable. */ leftJoin( @@ -84,15 +109,15 @@ export class AliasedQueryBuilder< on: ( tables: ReadAliasTables> ) => JoinPredicate - ): AliasedQueryBuilder, Row> { + ): QueryView, Row, P>> { const copy = this.copy(); copy.planner = copy.planner.leftJoin(table, on as any) as any; - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Select exact columns and aggregates; opaque raw expressions are deliberately unsupported. */ - select

( - select: (tables: ReadAliasTables) => P - ): AliasedQueryBuilder> { + select( + select: (tables: ReadAliasTables) => Selected + ): QueryView, P>> { const { knex, columns } = this.planner.readContext(); const entries = Object.values( columns as Record>> @@ -121,16 +146,15 @@ export class AliasedQueryBuilder< ]) ) ) as unknown as Row; - return copy as any; + return finishParameterizedQuery(copy) as any; } protected readPredicateContext(): ReadPredicateContext> { const { knex, columns } = this.planner.readContext(); const entries = Object.values( columns as Record>> ).flatMap(table => Object.values(table)); - return { - knex, - column: selector => { + const resolve: ReadPredicateContext>['resolve'] = + selector => { if (typeof selector !== 'function') throw new ReadSchemaError( 'Aliased predicates require a column selector' @@ -141,75 +165,86 @@ export class AliasedQueryBuilder< 'Predicate column does not belong to this query' ); const info = column[COLUMN]; - return `${info.alias}.${info.column}`; - } - }; + return { + column: `${info.alias}.${info.column}`, + schema: compileReadSchema(info.schema).schema + }; + }; + return { knex, resolve, column: selector => resolve(selector).column }; } protected addReadPredicate(predicate: ReadPredicate): this { const copy = this.copy(); copy.predicates = [...this.predicates, predicate]; - return copy; + return finishParameterizedQuery(copy); } /** Order by native database values before decoding. */ - orderBy(column: Selector, direction: 'asc' | 'desc' = 'asc'): this { + orderBy( + column: Selector, + direction: 'asc' | 'desc' = 'asc' + ): QueryView { const copy = this.copy(); copy.planner.orderBy(column as any, direction); - return copy; + return finishParameterizedQuery(copy) as any; } /** Append trusted raw ordering with captured bindings; ref() quotes mapped aliased columns. */ - orderByRaw(sql: string, bindings: readonly Knex.RawBinding[] = []): this { + orderByRaw( + sql: string, + bindings: A & WithoutParameters> = [] as any + ): QueryView { const { knex } = this.planner.readContext(); const captured = captureReadRaw(knex, sql, bindings)().toSQL(); const copy = this.copy(); copy.planner.orderByRaw(captured.sql, captured.bindings); - return copy; + return finishParameterizedQuery(copy) as any; } /** Group native columns for an aggregate projection. */ - groupBy(...columns: Selector[]): this { + groupBy(...columns: Selector[]): QueryView { const copy = this.copy(); copy.planner.groupBy(...(columns as any)); - return copy; + return finishParameterizedQuery(copy) as any; } /** Filter grouped rows using native SQL aggregate values. */ - having( + having( value: ( tables: ReadAliasTables ) => AggregateExpression | AliasedColumn, operator: string, - right: unknown - ): this { + right: V & WithoutParameters> + ): QueryView { const copy = this.copy(); copy.planner.having( value as any, operator, captureValue(this.planner.readContext().knex, right)() ); - return copy; + return finishParameterizedQuery(copy) as any; } /** Limit the flat row count, including repeated parents produced by joins. */ - limit(count: number): this { + limit(count: number): QueryView { if (!Number.isInteger(count) || count < 0) throw new ReadSchemaError('Limit must be a non-negative integer'); const copy = this.copy(); copy.planner.limit(count); - return copy; + return finishParameterizedQuery(copy) as any; } /** Offset flat rows using caller-supplied deterministic ordering. */ - offset(count: number): this { + offset(count: number): QueryView { if (!Number.isInteger(count) || count < 0) throw new ReadSchemaError('Offset must be a non-negative integer'); const copy = this.copy(); copy.planner.offset(count); - return copy; + return finishParameterizedQuery(copy) as any; } /** Use a caller-owned transaction without mutating the original read query. */ - transacting(trx: Knex.Transaction): this { + transacting(trx: Knex.Transaction): QueryView { const copy = this.copy(); copy.planner = copy.planner.transacting(trx); - return copy; + shareParameterCompilation(this, copy); + return finishParameterizedQuery(copy) as any; } /** @internal Compile selected fields, casting exact numeric values before driver parsing. */ - compile(): Knex.QueryBuilder { + compile(mode?: typeof COMPILE_PARAMETERS): Knex.QueryBuilder { + assertParametersBound(this, mode); void this.rowSchema; const { sql, knex } = this.planner.readContext(); for (const predicate of this.predicates) predicate(sql); @@ -237,6 +272,7 @@ export class AliasedQueryBuilder< configure: (query: Knex.QueryBuilder) => Knex.QueryBuilder | undefined, options: QueryOutput ): OpaqueQuery { + assertParametersBound(this); const { knex, sql: source } = this.planner.readContext(); const sql = this.fields ? this.compile() : source; if (!this.fields) @@ -275,9 +311,33 @@ export class AliasedQueryBuilder< decodeObject(nodes, row, 'row') ); } + /** @internal Cached SELECT execution and independent materialization. */ + [COMPILED_READER](): CompiledReader { + const nodes = Object.fromEntries( + Object.entries(this.fields ?? {}).map(([key, field]) => [ + key, + field.node + ]) + ); + return { + knex: this.planner.readConnection(), + uses: predicateParameters(this.predicates), + compile: () => this.compile(COMPILE_PARAMETERS), + decode: row => decodeObject(nodes, row, 'row'), + bind: values => { + const copy = this.copy(); + copy.predicates = this.predicates.map(predicate => + bindReadPredicate(predicate, values) + ); + return finishParameterizedQuery(copy); + } + }; + } /** Fetch the first selected row, or undefined. */ async first(): Promise | undefined> { - return (await this.limit(1).execute())[0]; + return ( + await (unwrapParameterizedQuery(this.limit(1)) as this).execute() + )[0]; } /** Awaiting executes this reader; repeated awaits execute again. */ // biome-ignore lint/suspicious/noThenProperty: query readers intentionally support await @@ -288,3 +348,17 @@ export class AliasedQueryBuilder< return this.execute().then(resolve, reject); } } + +/** @internal Fluent return constructor for aliased SELECTs. */ +export interface AliasParameterReader + extends ParameterReader { + readonly result: QueryView< + AliasedQueryBuilder< + T, + Row, + this['parameters'] extends ParameterState + ? this['parameters'] + : never + > + >; +} diff --git a/libs/knex-schema/src/PolymorphicQueryBuilder.ts b/libs/knex-schema/src/PolymorphicQueryBuilder.ts index c81eacf2..80ec5002 100644 --- a/libs/knex-schema/src/PolymorphicQueryBuilder.ts +++ b/libs/knex-schema/src/PolymorphicQueryBuilder.ts @@ -9,23 +9,53 @@ import { } from '@cleverbrush/schema'; import type { Knex } from 'knex'; import { buildColumnMap, getPrimaryKeyColumns } from './columns.js'; +import { + assertParametersBound, + COMPILE_PARAMETERS, + COMPILED_READER, + type CompiledReader, + copyParameterOrder, + finishParameterizedQuery, + readerParameters, + shareParameterCompilation, + unwrapParameterizedQuery +} from './compiled-query.js'; import type { SchemaProps } from './entity.js'; import { type AliasedColumn, COLUMN } from './expressions.js'; import { getTableName, getVariants } from './extension.js'; import { OpaqueQuery, type QueryOutput } from './OpaqueQuery.js'; import { privateColumn } from './operations/ordering.js'; +import type { + AttachParameters, + CheckParameterState, + CheckParameterValue, + MergeParameters, + ParameterReader, + ParameterState, + ParametersOf, + QueryView, + ScopedParameters, + SelectParameterVariants, + ValueParameters, + WithoutParameters +} from './parameter-types.js'; import type { EntityReadSchema, ReadRelations, ReadVariantMetadata } from './read-entity.js'; import { + bindReadPredicate, captureReadRaw, + type PredicateValue, + predicateParameters, type ReadPredicate, type ReadPredicateContext, + type ReadPredicateSelector, ReadPredicates } from './read-predicates.js'; import { + compileReadSchema, type ObjectReadSchema, type ReadObject, type ReadSchema, @@ -123,14 +153,27 @@ type BranchSource< ObjectSchemaBuilder>, ReadRelations & ReadRelations> >; +type VariantRelationQuery< + S extends ReadObject, + K extends keyof VariantMap & string, + R extends string +> = R extends keyof ReadRelations> + ? SchemaAwareQuery>[R]>> + : SchemaQueryBuilder; + type BranchQueries< S extends ReadObject, - B extends Record + B extends Record, + P extends ParameterState = [] > = { - [K in keyof B & keyof VariantMap & string]: SchemaQueryBuilder< - BranchSource, - B[K], - ReadRelations & ReadRelations> + [K in keyof B & keyof VariantMap & string]: QueryView< + SchemaQueryBuilder< + BranchSource, + B[K], + ReadRelations & ReadRelations>, + true, + ScopedParameters + > >; }; type Selector = ( @@ -148,8 +191,13 @@ type PolymorphicOrder = */ export class PolymorphicQueryBuilder< S extends ReadObject, - B extends Record = VariantReadSchemas -> extends ReadPredicates>> { + B extends Record = VariantReadSchemas, + P extends ParameterState = [] +> extends ReadPredicates< + ReadColumns>, + P, + PolymorphicParameterReader +> { /** @internal Nominal identity for typed child-query customizers. */ declare readonly [READ_QUERY]: true; /** Runtime union matching decoded results, including selected variant bodies. */ @@ -158,9 +206,9 @@ export class PolymorphicQueryBuilder< readonly variantRowSchemas: Readonly; private branches: Record< string, - SchemaQueryBuilder + SchemaQueryBuilder >; - private fallback: SchemaQueryBuilder; + private fallback: SchemaQueryBuilder; private orders: PolymorphicOrder[] = []; private rowLimit?: number; private rowOffset?: number; @@ -220,7 +268,7 @@ export class PolymorphicQueryBuilder< buildColumnMap(source).propToCol.get(config.discriminatorKey) ?? config.discriminatorKey; // Invert only the discriminator guard, not caller/default-scope filters. - this.fallback = new SchemaQueryBuilder( + this.fallback = new SchemaQueryBuilder( knex, common, knex @@ -252,7 +300,7 @@ export class PolymorphicQueryBuilder< ) as unknown as PolymorphicRowSchema; const scope = source.introspect().extensions?.defaultScope; if (typeof scope === 'function') { - const configured = scope(this.copy()); + const configured = unwrapParameterizedQuery(scope(this.copy())); if ( !this.sameSource(configured) || configured.rowSchema !== this.rowSchema || @@ -272,6 +320,7 @@ export class PolymorphicQueryBuilder< limit: configured.rowLimit, offset: configured.rowOffset }; + assertParametersBound(configured); } } @@ -323,7 +372,7 @@ export class PolymorphicQueryBuilder< private branch( key: string, body: boolean - ): SchemaQueryBuilder { + ): SchemaQueryBuilder { const config = getVariants(this.source)!; const variant = config.variants[key]; const baseInfo = this.source.introspect(); @@ -439,7 +488,7 @@ export class PolymorphicQueryBuilder< '__read_cti_present' ); } - return new SchemaQueryBuilder( + return new SchemaQueryBuilder( this.knex, schema, query.select(columns), @@ -448,14 +497,21 @@ export class PolymorphicQueryBuilder< } private copy(): this { - return Object.assign(Object.create(Object.getPrototypeOf(this)), this, { - branches: { ...this.branches }, - orders: [...this.orders] - }); + const copy = Object.assign( + Object.create(Object.getPrototypeOf(this)), + this, + { + branches: { ...this.branches }, + orders: [...this.orders] + } + ); + copyParameterOrder(this, copy); + return copy; } /** @internal Check the identity of the original read source, retained by clones. */ sameSource(other: unknown): boolean { + other = unwrapParameterizedQuery(other); return ( other instanceof PolymorphicQueryBuilder && this.base === other.base && @@ -476,53 +532,60 @@ export class PolymorphicQueryBuilder< protected readPredicateContext(): ReadPredicateContext< ReadColumns> > { + const resolve: ReadPredicateContext< + ReadColumns> + >['resolve'] = selector => { + const column = + typeof selector === 'string' + ? this.columns[selector] + : selector(this.columns as any); + if (!column || !Object.values(this.columns).includes(column)) + throw new ReadSchemaError( + 'Column does not belong to this polymorphic query' + ); + return { + column: `${this.predicateAlias}.${column[COLUMN].column}`, + schema: compileReadSchema(column[COLUMN].schema).schema + }; + }; return { knex: this.knex, - column: selector => { - const column = - typeof selector === 'string' - ? this.columns[selector] - : selector(this.columns as any); - if (!column || !Object.values(this.columns).includes(column)) - throw new ReadSchemaError( - 'Column does not belong to this polymorphic query' - ); - return `${this.predicateAlias}.${column[COLUMN].column}`; - } + resolve, + column: selector => resolve(selector).column }; } protected addReadPredicate(predicate: ReadPredicate): this { const copy = this.copy(); copy.predicates = [...this.predicates, predicate]; - return copy; + return finishParameterizedQuery(copy); } /** Remove the default scope while preserving explicit predicates. */ - unscoped(): this { + unscoped(): QueryView { const copy = this.copy(); copy.skipDefaults = true; - return copy; + return finishParameterizedQuery(copy) as any; } /** Include soft-deleted entities in every branch. */ - withDeleted(): this { + withDeleted(): QueryView { const copy = this.copy(); copy.deleted = 'include'; - return copy; + return finishParameterizedQuery(copy) as any; } /** Match only soft-deleted entities in every branch. */ - onlyDeleted(): this { + onlyDeleted(): QueryView { const copy = this.copy(); copy.deleted = 'only'; - return copy; + return finishParameterizedQuery(copy) as any; } /** Apply a named immutable scope once. */ - scoped(name: string): this { + scoped(name: string): QueryView { const scope = ( this.source.introspect().extensions?.scopes as | Record | undefined )?.[name]; if (!scope) throw new ReadSchemaError(`Unknown scope: ${name}`); - const configured = scope(this.copy()); + const configured = unwrapParameterizedQuery(scope(this.copy())); if ( !this.sameSource(configured) || configured.rowSchema !== this.rowSchema || @@ -535,7 +598,15 @@ export class PolymorphicQueryBuilder< 'Scopes must synchronously return a shape-preserving query' ); } - return configured; + if ( + readerParameters(configured).some( + use => !readerParameters(this).includes(use) + ) + ) + throw new ReadSchemaError( + 'Schema scopes cannot introduce query parameters' + ); + return finishParameterizedQuery(configured) as any; } /** True when all branches retain complete entity rows. */ get returnsEntityRows(): boolean { @@ -544,14 +615,54 @@ export class PolymorphicQueryBuilder< ); } /** Customize a relation on one discriminator branch. */ - includeVariant( - key: keyof B & keyof VariantMap & string, - relation: string, - customize?: (query: SchemaQueryBuilder) => ReadQueryShape - ): this { - return this.forVariant(key, query => - query.include(() => relation as any, customize as any) - ) as unknown as this; + includeVariant< + K extends keyof B & keyof VariantMap & string, + R extends string, + Child extends ReadQueryShape = VariantRelationQuery + >( + key: K, + relation: R, + customize?: ( + query: VariantRelationQuery + ) => Child & + CheckParameterState< + MergeParameters>> + > + ): QueryView< + PolymorphicQueryBuilder< + S, + R extends keyof ReadRelations> + ? Omit & + Record< + K, + ObjectSchemaBuilder< + SchemaProps & { + -readonly [F in keyof Pick< + ReadRelations>, + R + >]-?: RelationField< + ReadRelations>[F], + Child['rowSchema'] + >; + } + > + > + : B, + AttachParameters< + P, + MergeParameters< + ScopedParameters, + ParametersOf + >, + `variant:${K}` + > + > + > { + return this.forVariant( + key, + query => + query.include(() => relation as any, customize as any) as any + ) as any; } /** Load a common relation on every branch, configuring the child exactly once. */ include< @@ -563,22 +674,32 @@ export class PolymorphicQueryBuilder< selector: K | ((relations: { [P in keyof ReadRelations]: P }) => K), customize?: ( query: SchemaAwareQuery[K]>> - ) => Child - ): PolymorphicQueryBuilder< - S, - { - [P in keyof B]: ObjectSchemaBuilder< - SchemaProps & { - -readonly [R in keyof Pick< - ReadRelations, - K - >]-?: RelationField< - ReadRelations[K], - Child['rowSchema'] - >; - } - >; - } + ) => Child & + CheckParameterState< + AttachParameters< + P, + ParametersOf>, + `relation:${K}` + > + > + ): QueryView< + PolymorphicQueryBuilder< + S, + { + [P in keyof B]: ObjectSchemaBuilder< + SchemaProps & { + -readonly [R in keyof Pick< + ReadRelations, + K + >]-?: RelationField< + ReadRelations[K], + Child['rowSchema'] + >; + } + >; + }, + AttachParameters, `relation:${K}`> + > > { const relations = (this.source.introspect().extensions?.relations ?? []) as { name: string }[]; @@ -613,24 +734,53 @@ export class PolymorphicQueryBuilder< const copy = this.copy(); const entries = Object.entries(copy.branches); const [firstKey, first] = entries[0]; - const prepared = first.include(name, customize as any); + const prepared = unwrapParameterizedQuery( + first.include(name, customize as any) + ); copy.branches[firstKey] = prepared; for (const [key, branch] of entries.slice(1)) - copy.branches[key] = branch.includeFrom(name, prepared); - copy.fallback = copy.fallback.includeFrom(name, prepared); + copy.branches[key] = unwrapParameterizedQuery( + branch.includeFrom(name, prepared) + ); + copy.fallback = unwrapParameterizedQuery( + copy.fallback.includeFrom(name, prepared) + ); copy.refresh(); - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Filter one branch using schema property names; other variants remain unaffected. */ - whereVariant( - key: keyof B & keyof VariantMap & string, - selector: string | ((columns: any) => any), + whereVariant< + K extends keyof B & keyof VariantMap & string, + Sel extends ReadPredicateSelector>>, + const A + >( + key: K, + selector: Sel, operator: string, - value: unknown - ): this { + value: A & + CheckParameterValue< + P, + NoInfer, + PredicateValue>, Sel> + > + ): QueryView< + PolymorphicQueryBuilder< + S, + B, + AttachParameters< + P, + ValueParameters< + ScopedParameters, + A, + PredicateValue>, Sel> + >, + `variant:${K}` + > + > + > { return this.forVariant(key, query => - query.where(selector as any, operator, value) - ) as unknown as this; + (query as any).where(selector, operator, value) + ) as any; } /** Order all variants together, not independently within each branch. */ orderBy( @@ -638,7 +788,7 @@ export class PolymorphicQueryBuilder< | Selector | (keyof ReadColumns> & string), direction: 'asc' | 'desc' = 'asc' - ): this { + ): QueryView { if (direction !== 'asc' && direction !== 'desc') throw new ReadSchemaError('Invalid ordering direction'); const column = @@ -652,26 +802,35 @@ export class PolymorphicQueryBuilder< const key = column[COLUMN].column; const copy = this.copy(); copy.orders.push({ key, direction }); - return copy; + return finishParameterizedQuery(copy) as any; } /** Order the combined JSON-envelope SQL using trusted SQL and captured bindings. */ - orderByRaw(sql: string, bindings: readonly Knex.RawBinding[] = []): this { + orderByRaw( + sql: string, + bindings: A & WithoutParameters> = [] as any + ): QueryView { const copy = this.copy(); copy.orders.push({ raw: captureReadRaw(this.knex, sql, bindings) }); - return copy; + return finishParameterizedQuery(copy) as any; } /** Limit the combined result across all variants. */ - limit(count: number): this { + limit(count: number): QueryView { if (!Number.isInteger(count) || count < 0) throw new ReadSchemaError('Limit must be a non-negative integer'); const copy = this.copy(); copy.rowLimit = count; - return copy; + return finishParameterizedQuery(copy) as any; } /** Restrict returned discriminator branches and narrow both runtime and inferred schemas. */ selectVariants( keys: K - ): PolymorphicQueryBuilder> { + ): QueryView< + PolymorphicQueryBuilder< + S, + Pick, + SelectParameterVariants + > + > { if ( !keys.length || new Set(keys).size !== keys.length || @@ -686,15 +845,15 @@ export class PolymorphicQueryBuilder< ); copy.includeUnknown = false; copy.refresh(); - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Skip rows of the combined result, using a stable explicit ordering. */ - offset(count: number): this { + offset(count: number): QueryView { if (!Number.isInteger(count) || count < 0) throw new ReadSchemaError('Offset must be a non-negative integer'); const copy = this.copy(); copy.rowOffset = count; - return copy; + return finishParameterizedQuery(copy) as any; } /** @@ -706,11 +865,24 @@ export class PolymorphicQueryBuilder< Q extends ReadQueryShape >( key: K, - configure: (query: BranchQueries[K]) => Q - ): PolymorphicQueryBuilder & Record> { + configure: ( + query: BranchQueries[K] + ) => Q & + CheckParameterState< + AttachParameters>, `variant:${K}`> + > + ): QueryView< + PolymorphicQueryBuilder< + S, + Omit & Record, + AttachParameters, `variant:${K}`> + > + > { const current = this.branches[key]; if (!current) throw new ReadSchemaError(`Unknown variant: ${key}`); - const configured = configure(current as any); + const configured = unwrapParameterizedQuery( + configure(finishParameterizedQuery(current) as any) + ); if (!current.sameSource(configured)) { if (configured instanceof Promise) void configured.catch(() => {}); throw new ReadSchemaError( @@ -733,17 +905,22 @@ export class PolymorphicQueryBuilder< any >; copy.refresh(); - return copy as any; + return finishParameterizedQuery(copy) as any; } /** @internal Compile one UNION ALL statement; JSON preserves distinct branch shapes. */ - compile(correlate?: ReadCorrelation): Knex.QueryBuilder { - return this.compileRows(correlate); + compile( + correlate?: ReadCorrelation, + mode?: typeof COMPILE_PARAMETERS + ): Knex.QueryBuilder { + assertParametersBound(this, mode); + return this.compileRows(correlate, undefined, mode); } private compileRows( correlate?: ReadCorrelation, - targetKey?: string + targetKey?: string, + mode?: typeof COMPILE_PARAMETERS ): Knex.QueryBuilder { const defaults = this.skipDefaults ? undefined : this.defaults; const reserved = Object.values(this.branches).flatMap(branch => @@ -767,21 +944,27 @@ export class PolymorphicQueryBuilder< this.predicates ]) { if (predicates.length) - branch = branch.withPredicate(query => { - query.where(nested => { - for (const predicate of predicates) - predicate(nested); - }); - }); + branch = unwrapParameterizedQuery( + branch.withPredicate(query => { + query.where(nested => { + for (const predicate of predicates) + predicate(nested); + }); + }) + ); } const softDelete = this.source.introspect().extensions ?.softDelete as { column: string } | undefined; if (softDelete && this.deleted !== 'include') { - branch = branch.withPredicate(query => { - query[ - this.deleted === 'only' ? 'whereNotNull' : 'whereNull' - ](this.deletionColumn); - }); + branch = unwrapParameterizedQuery( + branch.withPredicate(query => { + query[ + this.deleted === 'only' + ? 'whereNotNull' + : 'whereNull' + ](this.deletionColumn); + }) + ); } let nativeKey: Knex.Raw | undefined; const orderColumns: Record = {}; @@ -798,7 +981,7 @@ export class PolymorphicQueryBuilder< ]); } if (Object.keys(orderColumns).length) sql.select(orderColumns); - }); + }, mode); if (targetKey) { return compiled .clearSelect() @@ -854,6 +1037,7 @@ export class PolymorphicQueryBuilder< } /** @internal Capture native primary keys for a single writable ORM variant. */ mutationTargets(variantKey: string): Knex.QueryBuilder { + assertParametersBound(this); const keys = Object.keys(this.branches); if (keys.length !== 1 || keys[0] !== variantKey || this.includeUnknown) throw new ReadSchemaError( @@ -947,9 +1131,11 @@ export class PolymorphicQueryBuilder< 'Page and pageSize must be positive integers' ); const total = await this.countValue(); - const data = await this.offset((page - 1) * pageSize) - .limit(pageSize) - .execute(); + const data = await ( + unwrapParameterizedQuery( + this.offset((page - 1) * pageSize).limit(pageSize) + ) as this + ).execute(); const totalPages = Math.ceil(total / pageSize); return { data, @@ -973,7 +1159,9 @@ export class PolymorphicQueryBuilder< } /** Return the first globally ordered row, or undefined. */ async first(): Promise> | undefined> { - return (await this.limit(1).execute())[0]; + return ( + await (unwrapParameterizedQuery(this.limit(1)) as this).execute() + )[0]; } /** Awaiting deliberately executes the query each time. */ // biome-ignore lint/suspicious/noThenProperty: query readers intentionally support await @@ -988,16 +1176,80 @@ export class PolymorphicQueryBuilder< return this.execute().then(resolve, reject); } /** Bind independent branch queries to a caller-owned transaction. */ - transacting(trx: Knex.Transaction): this { + transacting(trx: Knex.Transaction): QueryView { const copy = this.copy(); Object.assign(copy, { knex: trx }); copy.branches = Object.fromEntries( Object.entries(this.branches).map(([key, q]) => [ key, - q.transacting(trx) + unwrapParameterizedQuery(q.transacting(trx)) ]) ); - copy.fallback = this.fallback.transacting(trx); - return copy; + copy.fallback = unwrapParameterizedQuery( + this.fallback.transacting(trx) + ); + shareParameterCompilation(this, copy); + return finishParameterizedQuery(copy) as any; } + + /** @internal Shared parameter plan across union branches and included relations. */ + [COMPILED_READER](): CompiledReader { + const branches = new Map( + Object.entries(this.branches).map(([key, branch]) => [ + key, + branch[COMPILED_READER]() + ]) + ); + const discriminator = getVariants(this.source)!.discriminatorKey; + return { + knex: this.knex, + uses: [ + ...predicateParameters(this.predicates), + ...[...branches.values()].flatMap(branch => branch.uses), + ...(this.includeUnknown ? readerParameters(this.fallback) : []) + ], + compile: () => this.compile(undefined, COMPILE_PARAMETERS), + decode: row => { + const value = (row as any).__read_poly; + const branch = branches.get(value?.[discriminator]); + if (!branch) + throw new ReadSchemaError( + 'row: unknown polymorphic discriminator' + ); + return branch.decode(value); + }, + bind: values => { + const copy = this.copy(); + copy.predicates = this.predicates.map(predicate => + bindReadPredicate(predicate, values) + ); + copy.branches = Object.fromEntries( + Object.entries(this.branches).map(([key, branch]) => [ + key, + branch[COMPILED_READER]().bind(values) + ]) + ) as typeof this.branches; + copy.fallback = this.fallback[COMPILED_READER]().bind( + values + ) as typeof this.fallback; + return finishParameterizedQuery(copy); + } + }; + } +} + +/** @internal Fluent return constructor for polymorphic SELECTs. */ +export interface PolymorphicParameterReader< + S extends ReadObject, + B extends Record +> extends ParameterReader { + readonly result: QueryView< + PolymorphicQueryBuilder< + S, + B, + this['parameters'] extends ParameterState + ? this['parameters'] + : never + > + >; } diff --git a/libs/knex-schema/src/SchemaQueryBuilder.ts b/libs/knex-schema/src/SchemaQueryBuilder.ts index ea979d90..5f35e207 100644 --- a/libs/knex-schema/src/SchemaQueryBuilder.ts +++ b/libs/knex-schema/src/SchemaQueryBuilder.ts @@ -13,6 +13,17 @@ import { getPrimaryKeyColumns, resolvePropertyKey } from './columns.js'; +import { + assertParametersBound, + COMPILE_PARAMETERS, + COMPILED_READER, + type CompiledReader, + copyParameterOrder, + finishParameterizedQuery, + readerParameters, + shareParameterCompilation, + unwrapParameterizedQuery +} from './compiled-query.js'; import type { RelationInfo, SchemaProps } from './entity.js'; import { type AggregateExpression, @@ -36,11 +47,22 @@ import type { ScopesOf } from './operations/helpers.js'; import { getQuerySourceCtor } from './operations/helpers.js'; import { getState } from './operations/state.js'; import { PolymorphicQueryBuilder } from './PolymorphicQueryBuilder.js'; +import type { + AttachParameters, + CheckParameterState, + ParameterReader, + ParameterState, + ParametersOf, + QueryView, + WithoutParameters +} from './parameter-types.js'; import { QuerySource } from './QuerySource.js'; import type { ReadRelations, ReadVariantMetadata } from './read-entity.js'; import { + bindReadPredicate, captureReadRaw, captureValue, + predicateParameters, type ReadPredicate, type ReadPredicateContext, type ReadPredicateSelector, @@ -204,6 +226,26 @@ export type ReadCorrelation = ( source: ReadObject ) => void; +/** @internal Fluent return constructor, also specialized by ORM readers. */ +export interface TableParameterReader< + S extends ReadObject, + Row extends ReadObject, + Relations extends Record, + Writable extends boolean +> extends ParameterReader { + readonly result: QueryView< + SchemaQueryBuilder< + S, + Row, + Relations, + Writable, + this['parameters'] extends ParameterState + ? this['parameters'] + : never + > + >; +} + /** * Immutable table query whose row schema follows its decoded selection and relations. * Configuration returns independent lazy builders; metadata access never executes SQL. @@ -213,8 +255,13 @@ export class SchemaQueryBuilder< S extends ReadObject, Row extends ReadObject = ObjectReadSchema>, Relations extends Record = ReadRelations, - Writable extends boolean = true -> extends ReadPredicates> { + Writable extends boolean = true, + P extends ParameterState = [] +> extends ReadPredicates< + ReadColumns, + P, + TableParameterReader +> { /** @internal Nominal identity for typed child-query customizers. */ declare readonly [READ_QUERY]: true; private declare readonly writable: Writable; @@ -226,6 +273,8 @@ export class SchemaQueryBuilder< private grouped = false; private distinctRows = false; private defaults?: Knex.QueryBuilder; + private predicates: readonly ReadPredicate[] = []; + private defaultPredicates: readonly ReadPredicate[] = []; private skipDefaults = false; private deleted: 'exclude' | 'include' | 'only' = 'exclude'; private readonly columns: Record>; @@ -269,10 +318,10 @@ export class SchemaQueryBuilder< if (typeof defaultScope === 'function') { const scope = this.copy(); scope.base = knex.queryBuilder(); - this.defaults = this.checkScope( - scope, - defaultScope(scope) - ).base.clone(); + const configured = this.checkScope(scope, defaultScope(scope)); + assertParametersBound(configured); + this.defaults = configured.base.clone(); + this.defaultPredicates = configured.predicates; } } @@ -323,11 +372,13 @@ export class SchemaQueryBuilder< fields: { ...this.fields }, loaded: [...this.loaded] }); + copyParameterOrder(this, copy); return copy; } /** @internal Prevent customizers from substituting an unrelated query source. */ sameSource(other: unknown): boolean { + other = unwrapParameterizedQuery(other); return ( other instanceof SchemaQueryBuilder && this.columns === other.columns @@ -371,6 +422,7 @@ export class SchemaQueryBuilder< } private checkScope(input: this, result: unknown): this { + result = unwrapParameterizedQuery(result); if ( !(result instanceof SchemaQueryBuilder) || !input.sameSource(result) || @@ -389,11 +441,19 @@ export class SchemaQueryBuilder< } // The runtime checks above preserve this query's shape; instanceof // alone cannot recover its schema and relation type parameters. + if ( + readerParameters(result).some( + use => !readerParameters(input).includes(use) + ) + ) + throw new ReadSchemaError( + 'Schema scopes cannot introduce query parameters' + ); return result as unknown as this; } /** Apply a named, synchronous shape-preserving scope once to an independent query. */ - scoped(name: ScopesOf): this { + scoped(name: ScopesOf): QueryView { const scope = ( this.source.introspect().extensions?.scopes as | Record @@ -401,28 +461,30 @@ export class SchemaQueryBuilder< )?.[name]; if (!scope) throw new ReadSchemaError(`Unknown scope: ${name}`); const copy = this.copy(); - return this.checkScope(copy, scope(copy)); + return finishParameterizedQuery( + this.checkScope(copy, scope(copy)) + ) as any; } /** Exclude only the default scope; keep explicitly configured predicates. */ - unscoped(): this { + unscoped(): QueryView { const copy = this.copy(); copy.skipDefaults = true; - return copy; + return finishParameterizedQuery(copy) as any; } /** Include soft-deleted rows without changing the source query. */ - withDeleted(): this { + withDeleted(): QueryView { const copy = this.copy(); copy.deleted = 'include'; - return copy; + return finishParameterizedQuery(copy) as any; } /** Match only soft-deleted rows. */ - onlyDeleted(): this { + onlyDeleted(): QueryView { const copy = this.copy(); copy.deleted = 'only'; - return copy; + return finishParameterizedQuery(copy) as any; } /** True only when rows retain their complete entity shape. */ @@ -432,6 +494,7 @@ export class SchemaQueryBuilder< /** @internal Reject writes through projections or loaded relations. */ assertWritable(): void { + assertParametersBound(this); if (!this.returnsEntityRows || this.loaded.length) throw new ReadSchemaError( 'Writes require an unprojected table query without relations or aggregation' @@ -439,7 +502,8 @@ export class SchemaQueryBuilder< } /** @internal Build an independent statement containing defaults and explicit filters. */ - private filtered(): Knex.QueryBuilder { + private filtered(mode?: typeof COMPILE_PARAMETERS): Knex.QueryBuilder { + assertParametersBound(this, mode); const query = this.base.clone(); const explicitWhere = (query as any)._statements.filter( (statement: any) => statement.grouping === 'where' @@ -462,15 +526,21 @@ export class SchemaQueryBuilder< ...defaults._single, ...(query as any)._single }; - if (where.length) + if (where.length || this.defaultPredicates.length) query.where(nested => { (nested as any)._statements = [...where]; + for (const predicate of this.defaultPredicates) + predicate(nested); }); } if (explicitWhere.length) query.where(nested => { (nested as any)._statements = [...explicitWhere]; }); + if (this.predicates.length) + query.where(nested => { + for (const predicate of this.predicates) predicate(nested); + }); const softDelete = this.source.introspect().extensions?.softDelete as | { column: string } | undefined; @@ -645,28 +715,34 @@ export class SchemaQueryBuilder< | K | ((columns: ReadColumns) => ReadColumn) > - ): SchemaQueryBuilder< - S, - ObjectSchemaBuilder>, K>>, - Relations, - false + ): QueryView< + SchemaQueryBuilder< + S, + ObjectSchemaBuilder>, K>>, + Relations, + false, + P + > >; /** Select named output fields and aggregates, replacing the previous scalar projection. */ - select

( - selector: (columns: ReadColumns) => P - ): SchemaQueryBuilder< - S, - ObjectSchemaBuilder< - MergeProps< - SchemaProps>, - Pick< - SchemaProps, - Extract, keyof Relations> + select( + selector: (columns: ReadColumns) => Selected + ): QueryView< + SchemaQueryBuilder< + S, + ObjectSchemaBuilder< + MergeProps< + SchemaProps>, + Pick< + SchemaProps, + Extract, keyof Relations> + > > - > - >, - Relations, - false + >, + Relations, + false, + P + > >; select(...selectors: any[]): any { const selections = selectors.map(selector => @@ -713,17 +789,20 @@ export class SchemaQueryBuilder< for (const relation of copy.loaded) copy.fields[relation.name] = this.fields[relation.name]; Object.assign(copy, { rowSchema: copy.schema() }); - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Select a typed COUNT result on a new query. */ count( column?: ReadPredicateSelector> - ): SchemaQueryBuilder< - S, - ReadProjection<{ count: AggregateExpression }>, - Relations, - false + ): QueryView< + SchemaQueryBuilder< + S, + ReadProjection<{ count: AggregateExpression }>, + Relations, + false, + P + > > { return this.select(() => ({ count: createAggregate( @@ -735,11 +814,14 @@ export class SchemaQueryBuilder< /** Select a typed COUNTDISTINCT result on a new query. */ countDistinct( column: ReadPredicateSelector> - ): SchemaQueryBuilder< - S, - ReadProjection<{ countDistinct: AggregateExpression }>, - Relations, - false + ): QueryView< + SchemaQueryBuilder< + S, + ReadProjection<{ countDistinct: AggregateExpression }>, + Relations, + false, + P + > > { return this.select(() => ({ countDistinct: createAggregate( @@ -751,11 +833,14 @@ export class SchemaQueryBuilder< /** Select a typed SUM result on a new query. */ sum( column: ReadPredicateSelector> - ): SchemaQueryBuilder< - S, - ReadProjection<{ sum: AggregateExpression }>, - Relations, - false + ): QueryView< + SchemaQueryBuilder< + S, + ReadProjection<{ sum: AggregateExpression }>, + Relations, + false, + P + > > { return this.select(() => ({ sum: createAggregate( @@ -767,11 +852,14 @@ export class SchemaQueryBuilder< /** Select a typed AVG result on a new query. */ avg( column: ReadPredicateSelector> - ): SchemaQueryBuilder< - S, - ReadProjection<{ avg: AggregateExpression }>, - Relations, - false + ): QueryView< + SchemaQueryBuilder< + S, + ReadProjection<{ avg: AggregateExpression }>, + Relations, + false, + P + > > { return this.select(() => ({ avg: createAggregate( @@ -783,15 +871,18 @@ export class SchemaQueryBuilder< /** Select a typed MIN result on a new query. */ min>>( column: C - ): SchemaQueryBuilder< - S, - ReadProjection<{ - min: AggregateExpression< - SelectedValue, C> - >; - }>, - Relations, - false + ): QueryView< + SchemaQueryBuilder< + S, + ReadProjection<{ + min: AggregateExpression< + SelectedValue, C> + >; + }>, + Relations, + false, + P + > > { return this.select(() => ({ min: createAggregate< @@ -802,15 +893,18 @@ export class SchemaQueryBuilder< /** Select a typed MAX result on a new query. */ max>>( column: C - ): SchemaQueryBuilder< - S, - ReadProjection<{ - max: AggregateExpression< - SelectedValue, C> - >; - }>, - Relations, - false + ): QueryView< + SchemaQueryBuilder< + S, + ReadProjection<{ + max: AggregateExpression< + SelectedValue, C> + >; + }>, + Relations, + false, + P + > > { return this.select(() => ({ max: createAggregate< @@ -820,40 +914,47 @@ export class SchemaQueryBuilder< } /** Keep only distinct selected rows, preserving the row schema. */ - distinct(): SchemaQueryBuilder; + distinct(): QueryView>; /** Select a property subset and eliminate duplicate rows on that immutable projection. */ distinct & string>( ...columns: Array< | K | ((columns: ReadColumns) => ReadColumn) > - ): SchemaQueryBuilder< - S, - ObjectSchemaBuilder>, K>>, - Relations, - false + ): QueryView< + SchemaQueryBuilder< + S, + ObjectSchemaBuilder>, K>>, + Relations, + false, + P + > >; distinct(...columns: any[]): any { - const copy = columns.length ? this.select(...columns) : this.copy(); + const copy = ( + columns.length + ? unwrapParameterizedQuery(this.select(...columns)) + : this.copy() + ) as this; copy.distinctRows = true; - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Filter grouped results using trusted SQL and captured bindings. */ havingRaw( sql: string, bindings: readonly Knex.RawBinding[] = [] - ): SchemaQueryBuilder { + ): QueryView> { const copy = this.copy(); copy.base.havingRaw(captureReadRaw(this.knex, sql, bindings)()); copy.grouped = true; - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Filter groups by a mapped column comparison. */ - having( + having( column: ReadPredicateSelector>, operator: string, - value: unknown - ): SchemaQueryBuilder { + value: V & WithoutParameters> + ): QueryView> { const copy = this.copy(); copy.base.having( this.name(this.column(column)), @@ -861,36 +962,42 @@ export class SchemaQueryBuilder< captureValue(this.knex, value)() ); copy.grouped = true; - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Group using trusted SQL without changing the explicit projection. */ - groupByRaw( + groupByRaw( sql: string, - bindings: readonly Knex.RawBinding[] = [] - ): SchemaQueryBuilder { + bindings: A & WithoutParameters> = [] as any + ): QueryView> { const copy = this.copy(); copy.base.groupByRaw(captureReadRaw(this.knex, sql, bindings)()); copy.grouped = true; - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Apply a named schema projection with the same exact row-schema guarantees. */ projected & string>( name: K - ): SchemaQueryBuilder< - S, - ObjectSchemaBuilder< - Pick< - SchemaProps>, - Extract, keyof SchemaProps>> - > & + ): QueryView< + SchemaQueryBuilder< + S, + ObjectSchemaBuilder< Pick< - SchemaProps, - Extract, keyof Relations> - > - >, - Relations, - false + SchemaProps>, + Extract< + NamedKeys, + keyof SchemaProps> + > + > & + Pick< + SchemaProps, + Extract, keyof Relations> + > + >, + Relations, + false, + P + > > { const definition = getProjections(this.source)[name]; if (!definition) @@ -905,19 +1012,29 @@ export class SchemaQueryBuilder< protected readPredicateContext(): ReadPredicateContext< ReadColumns > { + const resolve = ( + selector: ReadPredicateSelector> + ) => { + const column = this.column(selector); + return { + column: this.name(column), + schema: column[READ_COLUMN].schema + }; + }; return { knex: this.knex, - column: selector => this.name(this.column(selector)) + column: selector => resolve(selector).column, + resolve }; } protected addReadPredicate(predicate: ReadPredicate): this { const copy = this.copy(); - predicate(copy.base); - return copy; + copy.predicates = [...this.predicates, predicate]; + return finishParameterizedQuery(copy); } /** @internal Apply an already captured Framework predicate to an independent query. */ - withPredicate(predicate: ReadPredicate): this { - return this.addReadPredicate(predicate); + withPredicate(predicate: ReadPredicate): QueryView { + return this.addReadPredicate(predicate) as any; } /** @internal Native storage columns for composing CTI table sources. */ storageQuery(): Knex.QueryBuilder { @@ -927,42 +1044,45 @@ export class SchemaQueryBuilder< orderBy( column: ReadPredicateSelector>, direction: 'asc' | 'desc' = 'asc' - ): this { + ): QueryView { const copy = this.copy(); copy.base.orderBy(this.name(this.column(column)), direction); - return copy; + return finishParameterizedQuery(copy) as any; } /** Append trusted raw ordering with captured positional bindings; ref() supplies quoted columns. */ - orderByRaw(sql: string, bindings: readonly Knex.RawBinding[] = []): this { + orderByRaw( + sql: string, + bindings: A & WithoutParameters> = [] as any + ): QueryView { const captured = captureReadRaw(this.knex, sql, bindings); const copy = this.copy(); copy.base.orderByRaw(captured()); - return copy; + return finishParameterizedQuery(copy) as any; } /** Group rows before typed aggregate projection. */ groupBy( ...columns: ReadPredicateSelector>[] - ): SchemaQueryBuilder { + ): QueryView> { const copy = this.copy(); copy.base.groupBy(columns.map(c => this.name(this.column(c)))); copy.grouped = true; - return copy as any; + return finishParameterizedQuery(copy) as any; } /** Limit parent rows; relation limits apply independently within each parent. */ - limit(count: number): this { + limit(count: number): QueryView { if (!Number.isInteger(count) || count < 0) throw new ReadSchemaError('Limit must be a non-negative integer'); const copy = this.copy(); copy.base.limit(count); - return copy; + return finishParameterizedQuery(copy) as any; } /** Skip parent rows; use a deterministic order for pagination. */ - offset(count: number): this { + offset(count: number): QueryView { if (!Number.isInteger(count) || count < 0) throw new ReadSchemaError('Offset must be a non-negative integer'); const copy = this.copy(); copy.base.offset(count); - return copy; + return finishParameterizedQuery(copy) as any; } /** @@ -974,17 +1094,29 @@ export class SchemaQueryBuilder< Child extends ReadQueryShape = SchemaAwareQuery> >( selector: K | ((relations: { [P in keyof Relations]: P }) => K), - customize?: (query: SchemaAwareQuery>) => Child - ): SchemaQueryBuilder< - S, - AddField< - Row, - K, - RelationField, - Relations - >, - Relations, - false + customize?: ( + query: SchemaAwareQuery> + ) => Child & + CheckParameterState< + AttachParameters< + P, + ParametersOf>, + `relation:${K}` + > + > + ): QueryView< + SchemaQueryBuilder< + S, + AddField< + Row, + K, + RelationField, + Relations + >, + Relations, + false, + AttachParameters, `relation:${K}`> + > > { if (Object.values(this.fields).some(f => f.aggregate) || this.grouped) throw new ReadSchemaError( @@ -1033,7 +1165,7 @@ export class SchemaQueryBuilder< 'Relation customizer must return its configured read query' ); } - child = customized as any; + child = unwrapParameterizedQuery(customized) as any; } return child; } @@ -1090,18 +1222,18 @@ export class SchemaQueryBuilder< } }; Object.assign(copy, { rowSchema: copy.schema() }); - return copy as any; + return finishParameterizedQuery(copy) as any; } /** @internal Reuse a captured child query across polymorphic parent branches. */ includeFrom( name: string, prepared: SchemaQueryBuilder - ): this { + ): QueryView { const loaded = prepared.loaded.find(relation => relation.name === name); if (!loaded) throw new ReadSchemaError(`Unknown prepared relation: ${name}`); - return this.load(loaded.relation, loaded.query, loaded.required); + return this.load(loaded.relation, loaded.query, loaded.required) as any; } /** Join one typed nested object with explicit property keys, including nullable joins. */ @@ -1112,18 +1244,30 @@ export class SchemaQueryBuilder< Child extends ReadQueryShape = SchemaAwareQuery >( spec: Omit, 'foreignQuery' | 'mappers'>, - customize?: (query: SchemaAwareQuery) => Child - ): SchemaQueryBuilder< - S, - AddField< - Row, - K, - Required extends true - ? Child['rowSchema'] - : SchemaForValue | null> - >, - Relations & Record>, - false + customize?: ( + query: SchemaAwareQuery + ) => Child & + CheckParameterState< + AttachParameters< + P, + ParametersOf>, + `relation:${K}` + > + > + ): QueryView< + SchemaQueryBuilder< + S, + AddField< + Row, + K, + Required extends true + ? Child['rowSchema'] + : SchemaForValue | null> + >, + Relations & Record>, + false, + AttachParameters, `relation:${K}`> + > > { if ('foreignQuery' in spec || 'mappers' in spec) throw new ReadSchemaError( @@ -1160,12 +1304,24 @@ export class SchemaQueryBuilder< JoinManySpec, 'orderBy' | 'foreignQuery' | 'mappers' >, - customize?: (query: SchemaAwareQuery) => Child - ): SchemaQueryBuilder< - S, - AddField>, - Relations & Record>, - false + customize?: ( + query: SchemaAwareQuery + ) => Child & + CheckParameterState< + AttachParameters< + P, + ParametersOf>, + `relation:${K}` + > + > + ): QueryView< + SchemaQueryBuilder< + S, + AddField>, + Relations & Record>, + false, + AttachParameters, `relation:${K}`> + > > { if ('foreignQuery' in spec || 'mappers' in spec || 'orderBy' in spec) throw new ReadSchemaError( @@ -1215,8 +1371,11 @@ export class SchemaQueryBuilder< } /** @internal Compile a bound SQL statement; callers never receive the mutable builder. */ - compile(correlate?: ReadCorrelation): Knex.QueryBuilder { - const query = this.filtered().clearSelect(); + compile( + correlate?: ReadCorrelation, + mode?: typeof COMPILE_PARAMETERS + ): Knex.QueryBuilder { + const query = this.filtered(mode).clearSelect(); correlate?.(query, this.alias, this.source); const expressions: Record = Object.create(null); for (const [key, field] of Object.entries(this.fields)) { @@ -1286,7 +1445,7 @@ export class SchemaQueryBuilder< `${childTable}.${resolveKey(childSource, foreignKey)}`, this.knex.ref(`${parentTable}.${parentPk[0]}`) ); - }); + }, mode); const many = relation.type === 'hasMany' || relation.type === 'belongsToMany'; @@ -1490,7 +1649,9 @@ export class SchemaQueryBuilder< } /** Execute a limited copy, returning undefined when no row matches. */ async first(): Promise | undefined> { - return (await this.limit(1).execute())[0]; + return ( + await (unwrapParameterizedQuery(this.limit(1)) as this).execute() + )[0]; } /** Awaiting executes the query; repeated awaits deliberately execute again. */ // biome-ignore lint/suspicious/noThenProperty: query readers intentionally support await @@ -1501,15 +1662,51 @@ export class SchemaQueryBuilder< return this.execute().then(resolve, reject); } /** Bind an independent query graph to a caller-owned transaction. */ - transacting(trx: Knex.Transaction): this { + transacting(trx: Knex.Transaction): QueryView { const copy = this.copy(); copy.base.transacting(trx); Object.assign(copy, { knex: trx }); copy.loaded = this.loaded.map(r => ({ ...r, - query: r.query.transacting(trx) + query: unwrapParameterizedQuery(r.query.transacting(trx)) as any })); - return copy; + shareParameterCompilation(this, copy); + return finishParameterizedQuery(copy) as any; + } + + /** @internal Compiled SELECT protocol; never replays consumer callbacks. */ + [COMPILED_READER](): CompiledReader { + const nodes = Object.fromEntries( + Object.entries(this.fields).map(([key, field]) => [key, field.node]) + ); + const checkOrphan = + this.source.introspect().extensions?.readOrphanColumn; + return { + knex: this.knex, + uses: [ + ...predicateParameters(this.predicates), + ...this.loaded.flatMap(r => readerParameters(r.query)) + ], + compile: () => this.compile(undefined, COMPILE_PARAMETERS), + decode: row => { + if (checkOrphan && (row as any).__read_cti_present == null) + throw new ReadSchemaError('row: missing CTI variant body'); + return decodeObject(nodes, row, 'row'); + }, + bind: values => { + const copy = this.copy(); + copy.predicates = this.predicates.map(predicate => + bindReadPredicate(predicate, values) + ); + copy.loaded = this.loaded.map(relation => ({ + ...relation, + query: relation.query[COMPILED_READER]().bind( + values + ) as AnyReadQuery + })); + return finishParameterizedQuery(copy); + } + }; } /** * Read a lossless composite cursor page using native SQL ordering. Cursor sort @@ -1635,9 +1832,11 @@ export class SchemaQueryBuilder< throw new ReadSchemaError( 'Pagination count exceeds the safe integer range' ); - const data = await this.offset((page - 1) * pageSize) - .limit(pageSize) - .execute(); + const data = await ( + unwrapParameterizedQuery( + this.offset((page - 1) * pageSize).limit(pageSize) + ) as this + ).execute(); const totalPages = Math.ceil(total / pageSize); return { data, diff --git a/libs/knex-schema/src/aliased-query.ts b/libs/knex-schema/src/aliased-query.ts index 1b98e8c9..06f8ce78 100644 --- a/libs/knex-schema/src/aliased-query.ts +++ b/libs/knex-schema/src/aliased-query.ts @@ -160,6 +160,11 @@ export class AliasedQuerySource { return { knex: this.knex, sql: this.sql.clone(), columns: this.tree() }; } + /** @internal Connection access without cloning the query planner. */ + readConnection(): Knex { + return this.knex; + } + /** * Create a read-only query for one aliased schema. * Prefer query(knex, alias(schema, name)) so the table-context type is inferred. diff --git a/libs/knex-schema/src/compiled-query.test-d.ts b/libs/knex-schema/src/compiled-query.test-d.ts new file mode 100644 index 00000000..265ee95f --- /dev/null +++ b/libs/knex-schema/src/compiled-query.test-d.ts @@ -0,0 +1,166 @@ +import Knex from 'knex'; +import { expectTypeOf, test } from 'vitest'; +import { + alias, + date, + defineEntity, + number, + object, + parameter, + query, + string +} from './index.js'; + +const db = Knex({ client: 'pg' }); +const User = object({ + id: number().primaryKey(), + name: string(), + age: number(), + balance: number().decimal(24, 6), + birthday: date().optional() +}).hasTableName('users'); + +test('parameters infer ordered storage arguments and preserve fluent projections', async () => { + const byId = query(db, User).where(t => t.id, parameter('id')); + expectTypeOf(byId).not.toBeAny(); + expectTypeOf(byId).parameters.toEqualTypeOf<[number]>(); + expectTypeOf(await byId(1)).toEqualTypeOf< + { + id: number; + name: string; + age: number; + balance: string; + birthday: Date | null; + }[] + >(); + // @ts-expect-error parameter values come from the selected schema + byId('1'); + // @ts-expect-error missing argument + byId(); + // @ts-expect-error extra argument + byId(1, 2); + // @ts-expect-error unbound readers cannot execute parameterless terminals + byId.execute(); + // @ts-expect-error unbound readers cannot mutate data + byId.update({ age: 30 }); + const names = byId + .where(t => t.name, parameter('name')) + .orderBy(t => t.id) + .limit(3) + .select(t => ({ name: t.name })); + expectTypeOf(names).parameters.toEqualTypeOf<[number, string]>(); + expectTypeOf(await names(1, 'John')).toEqualTypeOf<{ name: string }[]>(); + expectTypeOf(await names.query(1, 'John').first()).toEqualTypeOf< + { name: string } | undefined + >(); + expectTypeOf( + byId.where(t => t.age, parameter('id')) + ).parameters.toEqualTypeOf<[number]>(); + // @ts-expect-error the same argument cannot be both a number and a string + byId.where(t => t.name, parameter('id')); + const nullable = query(db, User) + .where(t => t.birthday, parameter('date')) + .where(t => t.balance, parameter('amount')); + expectTypeOf(nullable).parameters.toEqualTypeOf<[Date | null, string]>(); + expectTypeOf( + nullable.query(null, '1.000000').where(t => t.age, parameter('new')) + ).parameters.toEqualTypeOf<[number]>(); +}); + +test('groups, fixed membership tuples and aliases retain parameter order', () => { + const grouped = query(db, User) + .where(p => + p + .where(t => t.id, parameter('id')) + .orWhere(t => t.age, parameter('age')) + ) + .whereBetween(t => t.age, [parameter('minimum'), parameter('maximum')]); + expectTypeOf(grouped).parameters.toEqualTypeOf< + [number, number, number, number] + >(); + const tuple = query(db, User).whereIn( + t => t.id, + [parameter('one'), 2, parameter('two'), parameter('one')] + ); + expectTypeOf(tuple).parameters.toEqualTypeOf<[number, number]>(); + const aliased = query(db, alias(User, 'u')) + .where(t => t.u.name, parameter('name')) + .select(t => ({ id: t.u.id })); + expectTypeOf(aliased).parameters.toEqualTypeOf<[string]>(); + expectTypeOf(aliased('John')).toEqualTypeOf>(); +}); + +test('untyped placeholder positions and dynamic names are rejected', () => { + const read = query(db, User); + // @ts-expect-error object predicates cannot infer a parameter contract + read.where({ id: parameter('id') }); + // @ts-expect-error raw SQL has no schema-derived parameter type + read.whereRaw('id = ?', [parameter('id')]); + // @ts-expect-error raw ordering has no schema-derived parameter type + read.orderByRaw('id = ?', [parameter('id')]); + // @ts-expect-error untyped HAVING values cannot declare parameters + read.having(t => t.id, '=', parameter('id')); + // @ts-expect-error dynamic names cannot declare a fixed argument tuple + parameter('id' as string); + // @ts-expect-error empty names are not allowed + parameter(''); + const dynamic = [parameter('id')]; + // @ts-expect-error placeholder lists must have a fixed tuple shape + read.whereIn(t => t.id, dynamic); + const byId = read.where(t => t.id, parameter('value')); + // @ts-expect-error nested groups must reject incompatible reuse too + byId.where(p => p.where(t => t.name, parameter('value'))); + // @ts-expect-error scalar comparisons cannot expand placeholder arrays + read.where(t => t.id, [parameter('id')]); +}); + +test('child and variant parameters infer arguments and reject incompatible graph reuse', () => { + const Task = defineEntity( + object({ + id: number().primaryKey(), + ownerId: number(), + owner: User.optional() + }).hasTableName('tasks') + ).belongsTo( + t => t.owner, + t => t.ownerId, + t => t.id + ); + const byOwner = query(db, Task.schema) + .include( + t => t.owner, + q => q.where(t => t.name, parameter('name')) + ) + .where(t => t.id, parameter('id')); + expectTypeOf(byOwner).parameters.toEqualTypeOf<[string, number]>(); + query(db, Task.schema) + .where(t => t.id, parameter('value')) + .include( + t => t.owner, + // @ts-expect-error a child cannot reuse a numeric parent argument for a string + q => q.where(t => t.name, parameter('value')) + ); + const Asset = defineEntity( + object({ id: number().primaryKey(), kind: string() }).hasTableName( + 'assets' + ) + ) + .discriminator('kind') + .stiVariant('task', Task) + .stiVariant('note', object({ text: string() })); + const nested = query(db, Asset.schema).includeVariant('task', 'owner', q => + q.where(t => t.name, parameter('name')) + ); + expectTypeOf(nested).parameters.toEqualTypeOf<[string]>(); + const variants = query(db, Asset.schema) + .forVariant('note', q => q.where(t => t.text, parameter('text'))) + .where(t => t.id, parameter('id')); + expectTypeOf(variants).parameters.toEqualTypeOf<[string, number]>(); + expectTypeOf(variants.selectVariants(['task'])).parameters.toEqualTypeOf< + [number] + >(); + query(db, Asset.schema) + .where(t => t.id, parameter('value')) + // @ts-expect-error a variant cannot reuse a numeric parent argument for a string + .forVariant('note', q => q.where(t => t.text, parameter('value'))); +}); diff --git a/libs/knex-schema/src/compiled-query.test.ts b/libs/knex-schema/src/compiled-query.test.ts new file mode 100644 index 00000000..b7a126b6 --- /dev/null +++ b/libs/knex-schema/src/compiled-query.test.ts @@ -0,0 +1,283 @@ +import Knex from 'knex'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + alias, + date, + defineEntity, + number, + object, + parameter, + query, + string +} from './index.js'; + +const User = object({ + id: number().primaryKey(), + name: string().hasColumnName('display_name'), + age: number(), + birthday: date().optional(), + balance: number().decimal(24, 6) +}).hasTableName('users'); +const knex = Knex({ client: 'pg' }); +afterEach(() => vi.restoreAllMocks()); + +describe('compiled parameterized SELECTs', () => { + it('compiles lazily once, reuses named bindings and keeps values out of SQL', () => { + const compiler = vi.spyOn(knex.client, 'queryCompiler'); + const read = query(knex, User) + .where(t => t.name, parameter('name')) + .orWhere(t => t.name, parameter('name')) + .where(t => t.age, '>=', parameter('age')); + expect(compiler).not.toHaveBeenCalled(); + const one = read.toSQL("' OR 1=1 --", 18); + const calls = compiler.mock.calls.length; + expect(calls).toBeGreaterThan(0); + const two = read.toSQL('Jane', 21); + expect(compiler).toHaveBeenCalledTimes(calls); + expect(two.sql).toBe(one.sql); + expect(one.sql).not.toContain('OR 1=1'); + expect(one.bindings).toEqual(["' OR 1=1 --", "' OR 1=1 --", 18]); + expect(two.bindings).toEqual(['Jane', 'Jane', 21]); + expect(one.sql).toContain('"display_name"'); + }); + + it('materializes independent composable readers without replaying callbacks', () => { + const selector = vi.fn((t: any) => t.id); + const group = vi.fn((p: any) => p.where(selector, parameter('id'))); + const read = query(knex, User).where(group); + const one = (read as any).query(10).where('age', 18); + const two = (read as any).query(20); + expect(one.toQuery()).toContain('= 10'); + expect(two.toQuery()).toContain('= 20'); + expect(two.toQuery()).not.toContain('= 18'); + expect(group).toHaveBeenCalledTimes(1); + expect(selector).toHaveBeenCalledTimes(1); + expect((read as any).toSQL(30).bindings).toEqual([30]); + const rebound = two.where('age', parameter('id')); + expect(rebound.toSQL(40).bindings).toEqual([20, 40]); + }); + + it('preserves SQL null comparison behavior with a fixed statement', () => { + { + const read = query(knex, User).where( + t => t.birthday, + parameter('date') + ); + const empty = read.toSQL(null); + const date = new Date('2026-01-01T00:00:00Z'); + const present = read.toSQL(date); + expect(empty.sql).toBe(present.sql); + expect(empty.sql).toContain('case when ? then'); + expect(empty.bindings).toEqual([true, null]); + expect(present.bindings).toEqual([false, date]); + } + }); + + it('validates arity and storage types before compiling', () => { + const read = query(knex, User).where( + t => t.balance, + parameter('amount') + ); + const compiler = vi.spyOn(knex.client, 'queryCompiler'); + for (const args of [[], [1], [undefined], [null], ['1.00', 2]]) { + expect(() => (read.toSQL as any)(...args)).toThrow(); + } + expect(compiler).not.toHaveBeenCalled(); + expect(read.toSQL('1.00').bindings).toEqual(['1.00']); + expect(() => (read as any).where('age', parameter('amount'))).toThrow( + /incompatible/ + ); + }); + + it('captures scopes once, preserves their filters and rejects scope parameters', () => { + const defaults = vi.fn((q: any) => q.where('age', '>=', 18)); + const named = vi.fn((q: any) => q.where('name', 'John')); + const schema = User.defaultScope(defaults).scope('john', named); + const read = query(knex, schema) + .where(t => t.id, parameter('id')) + .scoped('john'); + expect(read.toSQL(1).bindings).toEqual([18, 1, 'John']); + expect(read.toSQL(2).bindings).toEqual([18, 2, 'John']); + expect(read.query(3).toQuery()).toContain('= 3'); + expect(defaults).toHaveBeenCalledTimes(1); + expect(named).toHaveBeenCalledTimes(1); + expect(read.unscoped().toSQL(4).bindings).toEqual([4, 'John']); + const invalid = User.defaultScope(q => + q.where(t => t.id, parameter('id')) + ); + expect(() => query(knex, invalid)).toThrow(/scopes cannot introduce/); + }); + + it('captures fixed membership slots and independently snapshots mutable values', () => { + const read = query(knex, User) + .whereIn( + t => t.id, + [parameter('a'), 2, parameter('b'), parameter('a')] + ) + .whereBetween( + t => t.birthday, + [parameter('start'), parameter('end')] + ); + const start = new Date('2026-01-01'); + const end = new Date('2026-02-01'); + const bound = read.query(1, 3, start, end); + const before = bound.toQuery(); + const sql = read.toSQL(1, 3, start, end); + start.setFullYear(2030); + expect(bound.toQuery()).toBe(before); + expect((sql.bindings[4] as Date).getFullYear()).toBe(2026); + expect(sql.bindings.slice(0, 4)).toEqual([1, 2, 3, 1]); + }); + + it('creates a fresh plan for shape changes and preserves the source', () => { + const read = query(knex, User).where(t => t.id, parameter('id')); + const before = read.toSQL(1); + const changed = read + .orderBy(t => t.age) + .limit(3) + .select(t => ({ name: t.name })); + expect(changed.toSQL(2).sql).toContain('limit ?'); + expect(read.toSQL(3).sql).toBe(before.sql); + expect(changed.toSQL(2).sql).not.toBe(before.sql); + }); + + it('supports alias readers without rebuilding the planner on warmed calls', () => { + const read = query(knex, alias(User, 'u')) + .where(t => t.u.id, parameter('id')) + .select(t => ({ name: t.u.name })); + const first = read.toSQL(1); + const clone = vi.spyOn( + knex.queryBuilder().constructor.prototype, + 'clone' + ); + const compiler = vi.spyOn(knex.client, 'queryCompiler'); + expect(read.toSQL(2).sql).toBe(first.sql); + expect(compiler).not.toHaveBeenCalled(); + expect(clone).not.toHaveBeenCalled(); + expect(read.query(3).toQuery()).toContain('= 3'); + }); + + it('captures relation and variant customizers once and removes inactive arguments', () => { + const Task = object({ + id: number().primaryKey(), + ownerId: number(), + owner: User.optional() + }).hasTableName('tasks'); + const entity = defineEntity(Task).belongsTo( + t => t.owner, + t => t.ownerId, + t => t.id + ); + const customize = vi.fn((q: any) => q.where('name', parameter('name'))); + const read = query(knex, entity.schema) + .include(t => t.owner, customize) + .where(t => t.id, parameter('id')); + expect((read as any).toSQL('John', 10).bindings).toEqual([ + 'John', + 1, + 10, + 'John', + 1 + ]); + expect((read as any).query('Jane', 20).toQuery()).toContain("'Jane'"); + expect(customize).toHaveBeenCalledTimes(1); + + const Asset = defineEntity( + object({ id: number().primaryKey(), kind: string() }).hasTableName( + 'assets' + ) + ) + .discriminator('kind') + .stiVariant('note', object({ text: string() })) + .stiVariant('image', object({ width: number() })); + const poly = query(knex, Asset.schema) + .forVariant('note', q => q.where(t => t.text, parameter('text'))) + .where(t => t.id, parameter('id')); + expect(poly.toSQL('Hello', 1).bindings).toContain('Hello'); + expect(poly.query('World', 2).toQuery()).toContain('World'); + const image = poly.selectVariants(['image']); + expect(image.toSQL(3).bindings).not.toContain('Hello'); + expect(image.toSQL(3).bindings).toContain(3); + }); + + it.each([ + false, + true + ])('executes and caches concurrent calls (inspect first: %s)', async inspectFirst => { + const db = Knex({ client: 'pg' }); + vi.spyOn(db.client, 'acquireConnection').mockResolvedValue({}); + const release = vi + .spyOn(db.client, 'releaseConnection') + .mockResolvedValue(undefined); + const event = vi.fn(); + db.on('query', event); + vi.spyOn(db.client, '_query').mockImplementation( + async (_connection: unknown, statement: any) => { + statement.response = { + command: 'SELECT', + rows: [ + { + id: statement.bindings[0], + birthday: '2026-01-01T00:00:00Z' + } + ] + }; + return statement; + } + ); + const read = query(db, User) + .where(t => t.id, parameter('id')) + .select(t => ({ id: t.id, birthday: t.birthday })); + const compiler = vi.spyOn(db.client, 'queryCompiler'); + if (inspectFirst) read.toSQL(1); + const [a, b] = await Promise.all([read(1), read(2)]); + expect(a).toEqual([ + { id: 1, birthday: new Date('2026-01-01T00:00:00Z') } + ]); + expect(b[0].id).toBe(2); + const compiled = compiler.mock.calls.length; + expect(compiled).toBeGreaterThan(0); + await read(3); + expect(compiler).toHaveBeenCalledTimes(compiled); + expect(event).toHaveBeenCalledTimes(3); + expect(release).toHaveBeenCalledTimes(3); + expect(event.mock.calls[0][0].sql).toContain('$1'); + await db.destroy(); + }); + + it('blocks unbound escape hatches and unsupported placeholder positions', async () => { + const read = query(knex, User).where(t => t.id, parameter('id')); + expect((read as any).then).toBeUndefined(); + expect(await Promise.resolve(read)).toBe(read); + for (const method of [ + 'execute', + 'compile', + 'first', + 'update', + 'delete', + 'toKnexQuery', + 'apply' + ]) { + expect(() => (read as any)[method]()).toThrow( + /Bind query parameters/ + ); + } + const source: any = query(knex, User); + expect(() => source.where({ id: parameter('id') })).toThrow( + /schema-typed/ + ); + expect(() => source.whereRaw('id = ?', [parameter('id')])).toThrow( + /schema-typed/ + ); + expect(() => + source.whereJsonPath('name', '$.x', '=', parameter('x')) + ).toThrow(/schema-typed/); + const other = Knex({ client: 'pg' }); + other.client.config.client = 'other'; + expect(() => + query(other, User) + .where(t => t.id, parameter('id')) + .toSQL(1) + ).toThrow(/PostgreSQL/); + }); +}); diff --git a/libs/knex-schema/src/compiled-query.ts b/libs/knex-schema/src/compiled-query.ts new file mode 100644 index 00000000..270b9282 --- /dev/null +++ b/libs/knex-schema/src/compiled-query.ts @@ -0,0 +1,286 @@ +import { randomUUID } from 'node:crypto'; +import type { Knex } from 'knex'; +import { + copyBinding, + ParameterSlot, + type ParameterUse, + type ParameterValues, + validateParameterUses, + validateParameterValue +} from './parameter.js'; +import type { BoundQuerySql, UnderlyingQuery } from './parameter-types.js'; +import { ReadSchemaError } from './read-schema.js'; + +/** @internal Internal compilation is the only path allowed to emit placeholder slots. */ +export const COMPILE_PARAMETERS = Symbol('compile-query-parameters'); +/** @internal Shared reader protocol; ORM wrappers preserve it. */ +export const COMPILED_READER = Symbol('compiled-reader'); + +/** @internal A reader's captured graph, decoder, and independent binding operation. */ +export interface CompiledReader { + readonly knex: Knex; + readonly uses: readonly ParameterUse[]; + compile(): Knex.QueryBuilder; + decode(row: unknown): unknown; + bind(values: ParameterValues): unknown; +} + +type Reader = { [COMPILED_READER](): CompiledReader }; +type Plan = { + statement: Knex.Sql; + slots: readonly (ParameterSlot | unknown)[]; + decode: (row: unknown) => unknown; + context: unknown; +}; +type Cache = { plan?: Plan }; +const originals = new WeakMap(); +const facades = new WeakMap(); +const orders = new WeakMap(); +const caches = new WeakMap(); +const runtimes = new WeakMap(); + +function runtimeFor(reader: Reader): CompiledReader { + let runtime = runtimes.get(reader); + if (!runtime) { + runtime = reader[COMPILED_READER](); + runtimes.set(reader, runtime); + } + return runtime; +} + +/** @internal Recover a class reader from its callable facade without losing source identity. */ +export function unwrapParameterizedQuery(value: T): UnderlyingQuery { + return ( + value && (typeof value === 'object' || typeof value === 'function') + ? (originals.get(value as object) ?? value) + : value + ) as UnderlyingQuery; +} + +/** @internal Detect a callable template without assimilating its Promise interface. */ +export function isParameterizedQuery(value: unknown): boolean { + return typeof value === 'function' && originals.has(value); +} + +/** @internal Runtime reader metadata; intended for query/ORM composition only. */ +export function readerParameters(value: unknown): readonly ParameterUse[] { + const reader = unwrapParameterizedQuery(value) as Reader | undefined; + return reader && typeof reader[COMPILED_READER] === 'function' + ? reader[COMPILED_READER]().uses + : []; +} + +/** @internal Copy construction order, not a shape-dependent compiled cache. */ +export function copyParameterOrder(source: object, target: object): void { + orders.set(target, orders.get(source) ?? []); +} + +/** @internal Transaction-only derivatives share an immutable statement holder. */ +export function shareParameterCompilation( + source: object, + target: object +): void { + // Identifier formatting and other client options can change SQL. A caller + // using another Knex instance gets its own plan, even on the same dialect. + if ( + (source as Reader)[COMPILED_READER]().knex.client.config !== + (target as Reader)[COMPILED_READER]().knex.client.config + ) + return; + let cache = caches.get(source); + if (!cache) { + cache = {}; + caches.set(source, cache); + } + caches.set(target, cache); +} + +/** @internal Guard SQL escape hatches and ordinary terminals before touching Knex. */ +export function assertParametersBound( + reader: object, + mode?: typeof COMPILE_PARAMETERS +): void { + if (mode !== COMPILE_PARAMETERS && readerParameters(reader).length) + throw new ReadSchemaError( + 'This query has unbound parameters; call it with arguments or use query(...args)' + ); +} + +function names( + reader: Reader, + uses: readonly ParameterUse[] +): readonly string[] { + const active = new Set(uses.map(use => use.name)); + const result = (orders.get(reader) ?? []).filter(name => active.has(name)); + for (const use of uses) + if (!result.includes(use.name)) result.push(use.name); + orders.set(reader, result); + return result; +} + +function valuesFor(reader: Reader, args: readonly unknown[]): ParameterValues { + const uses = runtimeFor(reader).uses; + const declared = names(reader, uses); + if (args.length !== declared.length) + throw new ReadSchemaError( + `Expected ${declared.length} query arguments (${declared.join(', ')}), received ${args.length}` + ); + const values = new Map(declared.map((name, i) => [name, args[i]])); + for (const use of uses) validateParameterValue(use, values.get(use.name)); + return new Map( + [...values].map(([name, value]) => [name, copyBinding(value)]) + ); +} + +function planFor(reader: Reader): Plan { + let cache = caches.get(reader); + if (!cache) { + cache = {}; + caches.set(reader, cache); + } + if (!cache.plan) { + const runtime = runtimeFor(reader); + if ( + !['pg', 'postgres', 'postgresql'].includes( + runtime.knex.client.config.client as string + ) + ) + throw new ReadSchemaError( + 'Compiled queries currently require PostgreSQL' + ); + const query = runtime.compile(); + const statement = query.toSQL(); + if (Array.isArray(statement) || statement.method !== 'select') + throw new ReadSchemaError( + 'A compiled query must produce one SELECT' + ); + const declared = new Set(names(reader, runtime.uses)); + const slots = (statement.bindings ?? []).map(value => { + if (value instanceof ParameterSlot && !declared.has(value.name)) + throw new ReadSchemaError( + `Undeclared query parameter: ${value.name}` + ); + return copyBinding(value); + }); + cache.plan = { + statement: { ...statement, bindings: [] }, + slots, + decode: runtime.decode, + context: query.queryContext() + }; + } + return cache.plan; +} + +function bindSlots(plan: Plan, values: ParameterValues): unknown[] { + return plan.slots.map(slot => + slot instanceof ParameterSlot + ? slot.mode === 'isNull' + ? values.get(slot.name) === null + : slot.mode === 'json' && values.get(slot.name) !== null + ? JSON.stringify(copyBinding(values.get(slot.name))) + : copyBinding(values.get(slot.name)) + : copyBinding(slot) + ); +} + +function inspect(reader: Reader, args: readonly unknown[]): BoundQuerySql { + const values = valuesFor(reader, args); + const plan = planFor(reader); + return { sql: plan.statement.sql, bindings: bindSlots(plan, values) }; +} + +async function execute( + reader: Reader, + args: readonly unknown[] +): Promise { + const values = valuesFor(reader, args); + const plan = planFor(reader); + const { knex } = runtimeFor(reader); + // A fresh carrier retains Knex's runner, pooling, events, transactions and + // response processing. Only its SQL compilation is replaced by a snapshot. + const execution = knex.raw(''); + execution.queryContext(plan.context); + execution.toSQL = () => + ({ + ...plan.statement, + bindings: bindSlots(plan, values), + __knexQueryUid: randomUUID() + }) as Knex.Sql; + const rows = await execution; + return rows.map(plan.decode); +} + +const blocked = new Set([ + 'execute', + 'first', + 'all', + 'find', + 'findOrFail', + 'findMany', + 'paginate', + 'paginateAfter', + 'pluck', + 'countValue', + 'countDistinctValue', + 'sumValue', + 'avgValue', + 'minValue', + 'maxValue', + 'compile', + 'toKnexQuery', + 'toQuery', + 'apply', + 'selectRaw', + 'insert', + 'insertMany', + 'update', + 'delete', + 'hardDelete', + 'restore', + 'bulkInsert', + 'bulkUpdate', + 'bulkUpsert', + 'upsert', + 'onConflict', + 'save', + 'storageQuery', + 'mutationTargets' +]); + +/** @internal Finish immutable composition; ordinary readers remain ordinary objects. */ +export function finishParameterizedQuery(value: T): T { + const reader = unwrapParameterizedQuery(value) as T & Reader; + if (!reader || typeof reader[COMPILED_READER] !== 'function') return value; + const uses = reader[COMPILED_READER]().uses; + validateParameterUses(uses); + names(reader, uses); + if (!uses.length) return reader; + const previous = facades.get(reader); + if (previous) return previous as T; + const callable = (...args: unknown[]) => execute(reader, args); + // Keep source checks and instanceof working; actual class methods are bound + // to the captured reader, never to the function object. + Object.setPrototypeOf(callable, Object.getPrototypeOf(reader)); + const proxy = new Proxy(callable, { + get(_target, prop) { + if (prop === 'then') return undefined; + if (prop === 'query') + return (...args: unknown[]) => + reader[COMPILED_READER]().bind(valuesFor(reader, args)); + if (prop === 'toSQL') + return (...args: unknown[]) => inspect(reader, args); + if (blocked.has(prop)) + return () => { + throw new ReadSchemaError( + `Bind query parameters before calling ${String(prop)}()` + ); + }; + const member = Reflect.get(reader, prop, reader); + return typeof member === 'function' ? member.bind(reader) : member; + } + }); + originals.set(proxy, reader); + facades.set(reader, proxy); + return proxy as T; +} diff --git a/libs/knex-schema/src/index.ts b/libs/knex-schema/src/index.ts index 5cdc59b4..3aec5c01 100644 --- a/libs/knex-schema/src/index.ts +++ b/libs/knex-schema/src/index.ts @@ -23,6 +23,12 @@ export { resolveColumnRef, resolvePropertyKey } from './columns.js'; +/** @internal Shared query/ORM composition contract. */ +export { + assertParametersBound, + COMPILED_READER, + isParameterizedQuery +} from './compiled-query.js'; // DDL generation export { generateCreatePolymorphicTables, @@ -101,6 +107,21 @@ export type { VariantReadSchemas } from './PolymorphicQueryBuilder.js'; export { PolymorphicQueryBuilder } from './PolymorphicQueryBuilder.js'; +export { parameter, type QueryParameter } from './parameter.js'; +export type { + AttachParameters, + BoundQuerySql, + CheckParameterState, + MergeParameters, + ParameterizedQuery, + ParameterReader, + ParameterState, + ParametersOf, + QueryArguments, + QueryView +} from './parameter-types.js'; +/** @internal Shared query/ORM fluent typing. */ +export { PARAMETER_READER, PARAMETER_STATE } from './parameter-types.js'; export type { BoundQuery } from './query.js'; // Main entry point export { createQuery, query } from './query.js'; @@ -120,6 +141,7 @@ export type { ReadMembership, ReadPredicateBuilder, ReadPredicateGroup, + ReadPredicateMethods, ReadPredicateSelector } from './read-predicates.js'; export type { diff --git a/libs/knex-schema/src/json-validation.ts b/libs/knex-schema/src/json-validation.ts index 01d039d5..efe1553a 100644 --- a/libs/knex-schema/src/json-validation.ts +++ b/libs/knex-schema/src/json-validation.ts @@ -2,7 +2,7 @@ * Assert strict JSON without invoking getters or serialization hooks. * Shared references are allowed; cycles and lossy JavaScript values are not. */ -export function assertJsonValue(value: unknown): void { +export function assertJsonValue(value: unknown, allowDates = false): void { const ancestors = new Set(); const pending: { value: unknown; path: string; leave?: boolean }[] = [ { value, path: '$' } @@ -10,6 +10,12 @@ export function assertJsonValue(value: unknown): void { while (pending.length) { const item = pending.pop()!; const current = item.value; + if ( + allowDates && + current instanceof Date && + Number.isFinite(current.getTime()) + ) + continue; if (item.leave) { ancestors.delete(current as object); continue; diff --git a/libs/knex-schema/src/parameter-types.ts b/libs/knex-schema/src/parameter-types.ts new file mode 100644 index 00000000..0b1607fd --- /dev/null +++ b/libs/knex-schema/src/parameter-types.ts @@ -0,0 +1,298 @@ +import type { InferType } from '@cleverbrush/schema'; +import type { QueryParameter } from './parameter.js'; + +/** @internal Query argument state; origins allow branch removal without stale arguments. */ +export type ParameterState = readonly (readonly [ + name: string, + uses: Record +])[]; + +/** @internal */ +export const PARAMETER_STATE = Symbol('query-parameter-state'); +/** @internal Higher-kinded return type for table, alias, group and ORM readers. */ +export const PARAMETER_READER = Symbol('query-parameter-reader'); +declare const QUERY_SOURCE: unique symbol; + +/** @internal Recover the reader behind its callable public type. */ +export type UnderlyingQuery = Q extends { readonly [QUERY_SOURCE]: infer S } + ? S + : Q; + +/** @internal */ +export interface ParameterReader { + readonly parameters: unknown; + readonly result: unknown; +} + +/** @internal */ +export type Reparameterize< + F extends ParameterReader, + P extends ParameterState +> = (F & { readonly parameters: P })['result']; + +/** @internal */ +export type ParametersOf = Q extends { + readonly [PARAMETER_STATE]: infer P extends ParameterState; +} + ? P + : []; + +type ValueOf = { + [K in keyof U]: (value: U[K]) => void; +}[keyof U] extends (value: infer V) => void + ? V + : never; + +/** The positional argument tuple inferred from a parameterized reader. */ +export type QueryArguments

= { + -readonly [K in keyof P]: ValueOf; +}; + +type Find

= Extract< + P[number], + readonly [N, unknown] +>; +type ExistingValue

= + Find extends infer E extends readonly [string, unknown] + ? ValueOf + : never; + +/** @internal */ +export type ParameterCompatible< + P extends ParameterState, + N extends string, + V +> = [Find] extends [never] + ? unknown + : [NonNullable> & NonNullable] extends [never] + ? never + : unknown; + +/** @internal */ +export type AddParameter< + P extends ParameterState, + N extends string, + V, + Origin extends string = 'root' +> = P extends readonly [ + infer H extends ParameterState[number], + ...infer T extends ParameterState +] + ? H[0] extends N + ? [ + readonly [ + N, + Omit & + Record< + Origin, + Origin extends keyof H[1] ? H[1][Origin] & V : V + > + ], + ...T + ] + : [H, ...AddParameter] + : [readonly [N, Record]]; + +type SetOrigin< + U, + C extends ParameterState, + N extends string, + O extends string +> = Omit & + ([Find] extends [never] ? {} : Record>); +type ReplaceOrigins< + P extends ParameterState, + C extends ParameterState, + O extends string +> = P extends readonly [ + infer H extends ParameterState[number], + ...infer T extends ParameterState +] + ? SetOrigin extends infer U + ? [keyof U] extends [never] + ? ReplaceOrigins + : [ + readonly [H[0], U & Record], + ...ReplaceOrigins + ] + : never + : []; +type AppendOrigins< + P extends ParameterState, + C extends ParameterState, + O extends string +> = C extends readonly [ + infer H extends ParameterState[number], + ...infer T extends ParameterState +] + ? AppendOrigins, O>, T, O> + : P; + +/** @internal Replace one nested reader while preserving surviving argument order. */ +export type AttachParameters< + P extends ParameterState, + C extends ParameterState, + Origin extends string +> = AppendOrigins, C, Origin>; + +/** @internal */ +export type MergeParameters< + P extends ParameterState, + C extends ParameterState +> = AppendOrigins; + +/** @internal Recover one child contract for a subsequent variant customizer. */ +export type ScopedParameters< + P extends ParameterState, + O extends string +> = P extends readonly [ + infer H extends ParameterState[number], + ...infer T extends ParameterState +] + ? O extends keyof H[1] + ? [readonly [H[0], { root: H[1][O] }], ...ScopedParameters] + : ScopedParameters + : []; + +/** @internal Retain root/relation constraints and the selected variant constraints. */ +export type SelectParameterVariants< + P extends ParameterState, + K extends string +> = P extends readonly [ + infer H extends ParameterState[number], + ...infer T extends ParameterState +] + ? { + [O in keyof H[1] as O extends `variant:${infer V}` + ? V extends K + ? O + : never + : O]: H[1][O]; + } extends infer U + ? [keyof U] extends [never] + ? SelectParameterVariants + : [ + readonly [H[0], U & Record], + ...SelectParameterVariants + ] + : never + : []; + +/** @internal Reject placeholders in untyped APIs, including nested binding arrays. */ +export type WithoutParameters = V extends QueryParameter + ? never + : V extends readonly unknown[] + ? { [K in keyof V]: WithoutParameters } + : V extends Record + ? { [K in keyof V]: WithoutParameters } + : V; + +/** @internal Add every placeholder in a fixed value tuple in tuple order. */ +export type ValueParameters

= + A extends QueryParameter + ? AddParameter + : A extends readonly [infer H, ...infer T] + ? ValueParameters, T, V> + : P; + +/** @internal Reject incompatible reuse without a permissive value overload. */ +export type CheckParameterValue

= + A extends QueryParameter + ? ParameterCompatible + : A extends readonly [infer H, ...infer T] + ? CheckParameterValue & + CheckParameterValue, T, V> + : A extends readonly (infer E)[] + ? Extract extends never + ? unknown + : never + : A extends WithoutParameters + ? unknown + : never; + +/** @internal Reject incompatible argument intersections introduced by a nested reader. */ +export type CheckParameterState

= P extends readonly [ + infer H extends ParameterState[number], + ...infer T extends ParameterState +] + ? [NonNullable>] extends [never] + ? never + : CheckParameterState + : unknown; + +/** @internal Scalar predicates never expand placeholder arrays or objects. */ +export type CheckScalarParameter< + P extends ParameterState, + A, + V +> = A extends QueryParameter + ? CheckParameterValue + : A extends WithoutParameters + ? unknown + : never; + +type UnboundTerminal = + | 'then' + | 'execute' + | 'first' + | 'all' + | 'find' + | 'findOrFail' + | 'findMany' + | 'paginate' + | 'paginateAfter' + | 'pluck' + | 'countValue' + | 'countDistinctValue' + | 'sumValue' + | 'avgValue' + | 'minValue' + | 'maxValue' + | 'compile' + | 'toKnexQuery' + | 'toQuery' + | 'apply' + | 'selectRaw' + | 'insert' + | 'insertMany' + | 'update' + | 'delete' + | 'hardDelete' + | 'restore' + | 'bulkInsert' + | 'bulkUpdate' + | 'bulkUpsert' + | 'upsert' + | 'onConflict' + | 'save'; + +/** SQL with positional value placeholders and independently snapshotted bindings. */ +export interface BoundQuerySql { + readonly sql: string; + readonly bindings: readonly unknown[]; +} + +/** A callable SELECT template; use query(...args) for ordinary query composition. */ +export type ParameterizedQuery = Omit< + Q, + UnboundTerminal | 'query' +> & { + readonly [QUERY_SOURCE]: Q; + ( + ...args: QueryArguments

+ ): Promise[] : never>; + /** Bind an independent ordinary reader without executing it. */ + query(...args: QueryArguments

): Q extends { + readonly [PARAMETER_READER]: infer F extends ParameterReader; + } + ? Reparameterize + : never; + /** Compile once on first use; subsequent calls only bind values. */ + toSQL(...args: QueryArguments

): BoundQuerySql; +}; + +/** @internal Ordinary readers keep their existing API until a parameter is added. */ +export type QueryView = + ParametersOf extends readonly [] + ? Q + : ParameterizedQuery>; diff --git a/libs/knex-schema/src/parameter.ts b/libs/knex-schema/src/parameter.ts new file mode 100644 index 00000000..68258479 --- /dev/null +++ b/libs/knex-schema/src/parameter.ts @@ -0,0 +1,177 @@ +import type { Knex } from 'knex'; +import { assertJsonValue } from './json-validation.js'; +import { type ReadSchema, ReadSchemaError } from './read-schema.js'; + +const PARAMETER = Symbol('query-parameter'); + +/** A named value placeholder. Its value type is inferred where it is used. */ +export class QueryParameter { + /** @internal Opaque placeholder identity. */ + readonly [PARAMETER] = true; + /** @internal Create placeholders with parameter(). */ + constructor( + /** Stable name used to share one positional argument across predicates. */ + readonly name: Name + ) { + Object.freeze(this); + } +} + +type IsUnion = T extends Whole + ? [Whole] extends [T] + ? false + : true + : never; + +/** + * Mark a caller-supplied value in a schema-backed predicate. + * Distinct names become positional arguments in first-appearance order; + * repeating a name reuses its argument. No SQL is executed here. + * @example query(db, User).where(t => t.id, parameter('id'))(42) + */ +export function parameter( + name: Name & + (string extends Name + ? never + : IsUnion extends true + ? never + : Name extends '' + ? never + : unknown) +): QueryParameter { + if (typeof name !== 'string' || !name.length) + throw new ReadSchemaError('Parameter names must be non-empty strings'); + return new QueryParameter(name); +} + +/** @internal */ +export function isParameter(value: unknown): value is QueryParameter { + return value instanceof QueryParameter; +} + +/** @internal One schema constraint at one occurrence of a named argument. */ +export interface ParameterUse { + name: string; + schema: ReadSchema; +} + +/** @internal Values already validated and snapshotted for one invocation. */ +export type ParameterValues = ReadonlyMap; + +/** @internal Native SQL bindings retain these objects until template compilation. */ +export class ParameterSlot { + constructor( + readonly name: string, + readonly mode: 'value' | 'isNull' | 'json' = 'value' + ) { + Object.freeze(this); + } +} + +/** @internal Snapshot values without retaining caller-owned mutable objects. */ +export function copyBinding(value: any): any { + if (value instanceof Date) return new Date(value.getTime()); + if (Buffer.isBuffer(value)) return Buffer.from(value); + if (Array.isArray(value)) return value.map(copyBinding); + if (value && typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + if (prototype === Object.prototype || prototype === null) + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + copyBinding(item) + ]) + ); + } + return value; +} + +/** @internal Reject placeholders in APIs without schema-derived argument types. */ +export function assertNoParameters( + value: unknown, + seen = new Set() +): void { + if (isParameter(value) || value instanceof ParameterSlot) + throw new ReadSchemaError( + 'Parameters require a schema-typed predicate; bind the query before using raw APIs' + ); + if (!value || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) assertNoParameters(item, seen); + } else if ( + Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null + ) { + for (const item of Object.values(value)) assertNoParameters(item, seen); + } +} + +/** @internal Compatible storage types can share an argument, including nullability narrowing. */ +export function validateParameterUses(uses: readonly ParameterUse[]): void { + const types = new Map(); + for (const use of uses) { + const info = use.schema.introspect(); + const type = info.type; + const literal = 'equalsTo' in info ? info.equalsTo : undefined; + const previous = types.get(use.name); + if ( + previous && + (previous.type !== type || + (previous.literal !== undefined && + literal !== undefined && + previous.literal !== literal)) + ) + throw new ReadSchemaError( + `Parameter "${use.name}" is used with incompatible column types` + ); + types.set(use.name, { type, literal: previous?.literal ?? literal }); + } +} + +/** @internal Validate storage values, without applying input defaults or preprocessors. */ +export function validateParameterValue( + use: ParameterUse, + value: unknown +): void { + const info = use.schema.introspect() as any; + const fail = () => { + throw new ReadSchemaError(`Invalid value for parameter "${use.name}"`); + }; + if (value === undefined) fail(); + if (value === null) { + if (!info.isNullable) fail(); + return; + } + if (info.type === 'date') { + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) + fail(); + } else if (info.type === 'object' || info.type === 'array') { + assertJsonValue(value, true); + if (!use.schema.validate(value).valid) fail(); + } else if ( + typeof value !== info.type || + (typeof value === 'number' && !Number.isFinite(value)) || + (info.equalsTo !== undefined && info.equalsTo !== value) + ) { + fail(); + } +} + +/** @internal A value or null-test binding for either compilation or a bound reader. */ +export function parameterBinding( + parameter: QueryParameter, + values?: ParameterValues, + mode: ParameterSlot['mode'] = 'value' +): Knex.RawBinding { + if (!values?.has(parameter.name)) + return new ParameterSlot(parameter.name, mode) as any; + const value = values.get(parameter.name); + return ( + mode === 'isNull' + ? value === null + : mode === 'json' && value !== null + ? JSON.stringify(copyBinding(value)) + : copyBinding(value) + ) as any; +} diff --git a/libs/knex-schema/src/query-scope.ts b/libs/knex-schema/src/query-scope.ts index 1e815379..182802e7 100644 --- a/libs/knex-schema/src/query-scope.ts +++ b/libs/knex-schema/src/query-scope.ts @@ -1,4 +1,5 @@ import type { Knex } from 'knex'; +import type { ParameterReader } from './parameter-types.js'; import type { ReadRelations } from './read-entity.js'; import type { ReadPredicateSelector, @@ -9,7 +10,11 @@ import type { ReadColumns } from './SchemaQueryBuilder.js'; /** Shape-preserving immutable API supplied to named and default scopes. */ export interface QueryScope - extends ReadPredicates>> { + extends ReadPredicates< + ReadColumns>, + [], + ScopeParameterReader + > { /** Append native column ordering. Return the new query. */ orderBy( column: ReadPredicateSelector>>, @@ -22,3 +27,7 @@ export interface QueryScope /** Offset a new query without changing its row shape. */ offset(count: number): this; } + +interface ScopeParameterReader extends ParameterReader { + readonly result: QueryScope; +} diff --git a/libs/knex-schema/src/read-predicates.ts b/libs/knex-schema/src/read-predicates.ts index 48f15f9f..0941dd51 100644 --- a/libs/knex-schema/src/read-predicates.ts +++ b/libs/knex-schema/src/read-predicates.ts @@ -1,7 +1,29 @@ import type { Knex } from 'knex'; import type { AliasedColumn } from './expressions.js'; import { ALLOWED_OPS } from './operations/helpers.js'; -import { ReadSchemaError } from './read-schema.js'; +import { + assertNoParameters, + copyBinding, + isParameter, + type ParameterUse, + type ParameterValues, + parameterBinding +} from './parameter.js'; +import { + type CheckParameterState, + type CheckParameterValue, + type CheckScalarParameter, + type MergeParameters, + PARAMETER_READER, + PARAMETER_STATE, + type ParameterReader, + type ParameterState, + type ParametersOf, + type Reparameterize, + type ValueParameters, + type WithoutParameters +} from './parameter-types.js'; +import { type ReadSchema, ReadSchemaError } from './read-schema.js'; const finishGroup = Symbol('finishReadPredicateGroup'); @@ -9,44 +31,60 @@ const finishGroup = Symbol('finishReadPredicateGroup'); export type ReadPredicateSelector = | ((columns: C) => AliasedColumn) | (keyof C & string); +/** @internal Infer the storage representation from the selected column. */ +export type PredicateValue = S extends ( + columns: C +) => AliasedColumn + ? V + : S extends keyof C + ? C[S] extends AliasedColumn + ? V + : never + : never; -/** A synchronous, parenthesized predicate group. The callback cannot shape or execute a query. */ -export type ReadPredicateGroup = ( +/** A synchronous, parenthesized predicate group; configuration never executes SQL. */ +export type ReadPredicateGroup> = ( predicates: ReadPredicateBuilder -) => ReadPredicateBuilder; - -/** A bound value list or a caller-built SELECT subquery; captured without executing it. */ +) => G; +/** A bound value list or a caller-built SELECT subquery, captured without execution. */ export type ReadMembership = readonly unknown[] | Knex.QueryBuilder; -/** @internal Predicate application contains only library-owned, already captured operations. */ -export type ReadPredicate = (query: Knex.QueryBuilder) => void; +/** @internal Captured library operation; user callbacks are never replayed. */ +export interface ReadPredicate { + (query: Knex.QueryBuilder, values?: ParameterValues): void; + readonly parameters?: readonly ParameterUse[]; +} -/** @internal Resolve references without exposing the parent's mutable SQL builder. */ +/** @internal Resolve SQL and storage metadata together, invoking selectors once. */ export interface ReadPredicateContext { knex: Knex; column: (selector: ReadPredicateSelector) => string | Knex.Raw; + resolve: (selector: ReadPredicateSelector) => { + column: string | Knex.Raw; + schema: ReadSchema; + }; } -/** @internal Snapshot common mutable binding values independently of query builders. */ -function copyValue(value: any): any { - if (value instanceof Date) return new Date(value.getTime()); - if (Buffer.isBuffer(value)) return Buffer.from(value); - if (Array.isArray(value)) return value.map(copyValue); - if (value && typeof value === 'object') { - const prototype = Object.getPrototypeOf(value); - if (prototype === Object.prototype || prototype === null) { - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [ - key, - copyValue(item) - ]) - ); - } - } - return value; +/** @internal */ +export function predicateParameters( + predicates: readonly ReadPredicate[] +): ParameterUse[] { + return predicates.flatMap(predicate => predicate.parameters ?? []); +} +/** @internal Materialize captured operations, not user configuration callbacks. */ +export function bindReadPredicate( + predicate: ReadPredicate, + values: ParameterValues +): ReadPredicate { + return query => predicate(query, values); +} +function capturedPredicate( + predicate: ReadPredicate, + parameters: readonly ParameterUse[] +): ReadPredicate { + return Object.assign(predicate, { parameters }); } -/** @internal Compiled SQL is recreated per use, so externally owned builders are never retained. */ function captureSql( knex: Knex, source: Knex.Raw | Knex.QueryBuilder @@ -56,20 +94,20 @@ function captureSql( throw new ReadSchemaError( 'Read predicates require a single SQL expression' ); + assertNoParameters(compiled.bindings); const sql = compiled.sql; - const bindings = compiled.bindings?.map(copyValue) ?? []; - return () => knex.raw(sql, bindings.map(copyValue)); + const bindings = compiled.bindings?.map(copyBinding) ?? []; + return () => knex.raw(sql, bindings.map(copyBinding)); } - -/** @internal Capture trusted SQL and positional bindings now, including refs and nested raw expressions. */ +/** @internal Capture trusted SQL and bindings, including nested raw expressions. */ export function captureReadRaw( knex: Knex, sql: string, bindings: readonly Knex.RawBinding[] = [] ): () => Knex.Raw { + assertNoParameters(bindings); return captureSql(knex, knex.raw(sql, [...bindings])); } - function captureSubquery(knex: Knex, query: Knex.QueryBuilder): () => Knex.Raw { if ( !query || @@ -83,171 +121,201 @@ function captureSubquery(knex: Knex, query: Knex.QueryBuilder): () => Knex.Raw { !['select', 'first'].includes(compiled.method) ) throw new ReadSchemaError('Read predicates require a SELECT subquery'); - // Rewrap the compiled statement, not the original builder or its callbacks. + assertNoParameters(compiled.bindings); return captureSql( knex, knex.raw(compiled.sql, [...(compiled.bindings ?? [])]) ); } - -/** @internal Capture mutable bindings without retaining caller-owned values. */ +/** @internal Snapshot concrete bindings; placeholders require a typed predicate. */ export function captureValue(knex: Knex, value: any): () => any { - if (value && typeof value.toSQL === 'function') { + assertNoParameters(value); + if (value && typeof value.toSQL === 'function') return typeof value.clone === 'function' ? captureSubquery(knex, value) : captureSql(knex, value); - } if (typeof value === 'function') throw new ReadSchemaError('Predicate values cannot be callbacks'); - const captured = copyValue(value); - return () => copyValue(captured); + const captured = copyBinding(value); + return () => copyBinding(captured); +} +function typedValue(knex: Knex, value: unknown, schema: ReadSchema) { + return isParameter(value) + ? { + parameters: [{ name: value.name, schema }], + get: (values?: ParameterValues) => + parameterBinding( + value, + values, + ['object', 'array'].includes(schema.introspect().type) + ? 'json' + : 'value' + ) + } + : { parameters: [] as ParameterUse[], get: captureValue(knex, value) }; } -/** - * Shared shape-preserving predicate methods for immutable readers and scoped groups. - * @internal Consumers obtain these methods through query factories, not inheritance. - */ -export abstract class ReadPredicates { +/** @internal Higher-kinded predicate group return type; groups are never executable. */ +export interface GroupParameterReader extends ParameterReader { + readonly result: ReadPredicateBuilder< + C, + this['parameters'] extends ParameterState ? this['parameters'] : never + >; +} + +/** Shared immutable predicates for schema readers and grouped conditions. */ +export abstract class ReadPredicates< + C, + P extends ParameterState = [], + Self extends ParameterReader = GroupParameterReader +> { + /** @internal Type-only ordered argument contract. */ + declare readonly [PARAMETER_STATE]: P; + /** @internal Type-only fluent return constructor. */ + declare readonly [PARAMETER_READER]: Self; protected abstract readPredicateContext(): ReadPredicateContext; - protected abstract addReadPredicate(predicate: ReadPredicate): this; + protected abstract addReadPredicate(predicate: ReadPredicate): any; - /** - * Quote a schema-backed column for raw bindings or correlated subqueries. - * The reference uses this reader's actual SQL alias and never executes SQL. - * @example read.whereRaw('lower(??) = ?', [read.ref(t => t.name), 'alice']) - */ + /** Quote a mapped column for trusted raw SQL or correlated subqueries. */ ref(selector: ReadPredicateSelector): Knex.Ref | Knex.Raw { const { knex, column } = this.readPredicateContext(); const resolved = column(selector); return typeof resolved === 'string' ? knex.ref(resolved) : resolved; } - /** Add a parenthesized AND group using a synchronous predicate-only callback. */ - where(group: ReadPredicateGroup): this; - /** Match a record of property names and bound equality values. */ - where(values: Partial>): this; - /** Add a bound equality comparison. Null uses SQL IS NULL. */ - where(column: ReadPredicateSelector, value: unknown): this; + /** Add a synchronous group, retaining the group's inferred named parameters. */ + where>( + group: ReadPredicateGroup< + C, + G & + CheckParameterState< + MergeParameters>> + > + > + ): Reparameterize>>; + /** Match a record of concrete equality values. Use selectors for parameters. */ + where>>( + values: V & WithoutParameters> + ): Reparameterize; + /** Match a column and infer a named parameter's storage type. */ + where, const A>( + column: S, + value: A & CheckScalarParameter, PredicateValue> + ): Reparameterize>>; /** Add a bound comparison using a supported SQL operator. */ - where( - column: ReadPredicateSelector, + where, const A>( + column: S, operator: string, - value: unknown - ): this; - where( - first: - | ReadPredicateSelector - | ReadPredicateGroup - | Partial>, - ...args: [] | [unknown] | [string, unknown] - ): this { + value: A & CheckScalarParameter, PredicateValue> + ): Reparameterize>>; + where(first: any, ...args: any[]): any { if (typeof first === 'object' && first !== null && !args.length) { + assertNoParameters(first); return Object.entries(first).reduce( - (query, [key, value]) => - query.where(key as keyof C & string, value), + (q: any, [key, value]) => q.where(key, value), this ); } - return this.comparison( - 'and', - first as ReadPredicateSelector | ReadPredicateGroup, - args - ); - } - - /** Explicit AND spelling of where(), including nested groups. */ - andWhere(group: ReadPredicateGroup): this; - /** Match all property/value pairs with AND semantics. */ - andWhere(values: Partial>): this; - /** Add a bound AND equality comparison. */ - andWhere(column: ReadPredicateSelector, value: unknown): this; - /** Add a bound AND comparison. */ - andWhere( - column: ReadPredicateSelector, + return this.comparison('and', first, args); + } + /** Explicit AND spelling of where(), including groups and typed parameters. */ + andWhere>( + group: ReadPredicateGroup< + C, + G & + CheckParameterState< + MergeParameters>> + > + > + ): Reparameterize>>; + andWhere>>( + values: V & WithoutParameters> + ): Reparameterize; + andWhere, const A>( + column: S, + value: A & CheckScalarParameter, PredicateValue> + ): Reparameterize>>; + andWhere, const A>( + column: S, operator: string, - value: unknown - ): this; - andWhere( - first: - | ReadPredicateSelector - | ReadPredicateGroup - | Partial>, - ...args: [] | [unknown] | [string, unknown] - ): this { + value: A & CheckScalarParameter, PredicateValue> + ): Reparameterize>>; + andWhere(first: any, ...args: any[]): any { if (typeof first === 'object' && first !== null && !args.length) return this.where(first); - return this.comparison( - 'and', - first as ReadPredicateSelector | ReadPredicateGroup, - args - ); - } - - /** Add a parenthesized OR group. Use an enclosing AND group beside authorization filters. */ - orWhere(group: ReadPredicateGroup): this; - /** Match a parenthesized AND record as one alternative to the preceding predicates. */ - orWhere(values: Partial>): this; - /** Add a bound OR equality comparison. */ - orWhere(column: ReadPredicateSelector, value: unknown): this; - /** Add a bound OR comparison. */ - orWhere( - column: ReadPredicateSelector, + return this.comparison('and', first, args); + } + /** Add an OR comparison or parenthesized group. */ + orWhere>( + group: ReadPredicateGroup< + C, + G & + CheckParameterState< + MergeParameters>> + > + > + ): Reparameterize>>; + orWhere>>( + values: V & WithoutParameters> + ): Reparameterize; + orWhere, const A>( + column: S, + value: A & CheckScalarParameter, PredicateValue> + ): Reparameterize>>; + orWhere, const A>( + column: S, operator: string, - value: unknown - ): this; - orWhere( - first: - | ReadPredicateSelector - | ReadPredicateGroup - | Partial>, - ...args: [] | [unknown] | [string, unknown] - ): this { - if (typeof first === 'object' && first !== null && !args.length) - return this.orWhere(group => group.where(first)); - return this.comparison( - 'or', - first as ReadPredicateSelector | ReadPredicateGroup, - args - ); + value: A & CheckScalarParameter, PredicateValue> + ): Reparameterize>>; + orWhere(first: any, ...args: any[]): any { + if (typeof first === 'object' && first !== null && !args.length) { + assertNoParameters(first); + return this.comparison( + 'or', + (group: ReadPredicateBuilder) => group.where(first), + [] + ); + } + return this.comparison('or', first, args); } private comparison( - boolean: 'and' | 'or', - first: ReadPredicateSelector | ReadPredicateGroup, - args: [] | [unknown] | [string, unknown] - ): this { + boolean: 'and' | 'or' | 'not', + selector: any, + args: any[] + ): any { const context = this.readPredicateContext(); - const method = boolean === 'and' ? 'where' : 'orWhere'; + const method = + boolean === 'or' + ? 'orWhere' + : boolean === 'not' + ? 'whereNot' + : 'where'; if (!args.length) { const group = new ReadPredicateBuilder(context); - let operations: readonly ReadPredicate[]; - { - const result: unknown = (first as ReadPredicateGroup)(group); - if ( - result && - typeof (result as PromiseLike).then === 'function' - ) { - // Consume native async rejection without assimilating foreign - // thenables (a Knex query's then() would execute SQL). - if (result instanceof Promise) void result.catch(() => {}); - throw new ReadSchemaError( - 'Read predicate groups must be synchronous' - ); - } - if ( - !(result instanceof ReadPredicateBuilder) || - !result.sameSource(group) - ) - throw new ReadSchemaError( - 'Predicate callbacks must return a builder from the supplied group' - ); - operations = result[finishGroup](); + const result = selector(group); + if (result && typeof result.then === 'function') { + if (result instanceof Promise) void result.catch(() => {}); + throw new ReadSchemaError( + 'Read predicate groups must be synchronous' + ); } - return this.addReadPredicate(query => { - query[method](nested => { - for (const operation of operations) operation(nested); - }); - }); + if ( + !(result instanceof ReadPredicateBuilder) || + !result.sameSource(group) + ) + throw new ReadSchemaError( + 'Predicate callbacks must return a builder from the supplied group' + ); + const operations = result[finishGroup](); + return this.addReadPredicate( + capturedPredicate((query, values) => { + query[method](nested => { + for (const operation of operations) + operation(nested, values); + }); + }, predicateParameters(operations)) + ); } const operator = args.length === 1 ? '=' : args[0]; if ( @@ -257,40 +325,72 @@ export abstract class ReadPredicates { throw new ReadSchemaError( `Unsupported comparison operator: ${operator}` ); - const column = context.column(first as ReadPredicateSelector); - const value = captureValue( - context.knex, - args.length === 1 ? args[0] : args[1] + const { column, schema } = context.resolve(selector); + const input = args.length === 1 ? args[0] : args[1]; + if (isParameter(input)) { + if ( + ['in', 'not in', 'is', 'is not'].includes( + operator.toLowerCase() + ) + ) + throw new ReadSchemaError( + 'Use a typed scalar comparison or fixed whereIn tuple for parameters' + ); + if ( + operator.toLowerCase().includes('like') && + schema.introspect().type !== 'string' + ) + throw new ReadSchemaError( + 'LIKE parameters require a string column' + ); + } + const value = typedValue(context.knex, input, schema); + return this.addReadPredicate( + capturedPredicate((query, values) => { + if ( + isParameter(input) && + schema.introspect().isNullable && + args.length === 1 + ) { + // Knex rewrites shorthand where(column, null) to IS NULL. + // Explicit operators retain SQL's three-valued null semantics. + const expression = context.knex.raw( + 'case when ? then ?? is null else ?? = ? end', + [ + parameterBinding(input, values, 'isNull'), + column, + column, + value.get(values) + ] + ); + query[method](expression); + } else if (args.length === 1) + query[method](column as any, value.get(values)); + else query[method](column as any, operator, value.get(values)); + }, value.parameters) ); - return this.addReadPredicate(query => { - if (args.length === 1) query[method](column as any, value()); - else query[method](column as any, operator, value()); - }); } private nullPredicate( column: ReadPredicateSelector, method: 'whereNull' | 'whereNotNull' | 'orWhereNull' | 'orWhereNotNull' - ): this { + ): any { const name = this.readPredicateContext().column(column); return this.addReadPredicate(query => { query[method](name as any); }); } - /** Match SQL null without changing the row schema. */ - whereNull(column: ReadPredicateSelector): this { + /** Match SQL null without changing the declared result type. */ + whereNull(column: ReadPredicateSelector): Reparameterize { return this.nullPredicate(column, 'whereNull'); } - /** Exclude SQL null without narrowing the declared row schema. */ - whereNotNull(column: ReadPredicateSelector): this { + whereNotNull(column: ReadPredicateSelector): Reparameterize { return this.nullPredicate(column, 'whereNotNull'); } - /** Add an OR SQL-null condition. */ - orWhereNull(column: ReadPredicateSelector): this { + orWhereNull(column: ReadPredicateSelector): Reparameterize { return this.nullPredicate(column, 'orWhereNull'); } - /** Add an OR SQL-not-null condition. */ - orWhereNotNull(column: ReadPredicateSelector): this { + orWhereNotNull(column: ReadPredicateSelector): Reparameterize { return this.nullPredicate(column, 'orWhereNotNull'); } @@ -298,44 +398,72 @@ export abstract class ReadPredicates { column: ReadPredicateSelector, values: ReadMembership, method: 'whereIn' | 'whereNotIn' | 'orWhereIn' | 'orWhereNotIn' - ): this { + ): any { const context = this.readPredicateContext(); - const name = context.column(column); + const resolved = context.resolve(column); const captured = Array.isArray(values) - ? values.map(value => captureValue(context.knex, value)) + ? values.map(value => + typedValue(context.knex, value, resolved.schema) + ) : captureSubquery(context.knex, values as Knex.QueryBuilder); - return this.addReadPredicate(query => { - if (typeof captured === 'function') { - const rawMethod = method.startsWith('or') - ? 'orWhereRaw' - : 'whereRaw'; - const operator = method.includes('Not') ? 'not in' : 'in'; - query[rawMethod](`?? ${operator} (?)`, [name, captured()]); - } else { - query[method]( - name as any, - captured.map(value => value()) - ); - } - }); + return this.addReadPredicate( + capturedPredicate( + (query, parameters) => { + if (typeof captured === 'function') { + const rawMethod = method.startsWith('or') + ? 'orWhereRaw' + : 'whereRaw'; + const operator = method.includes('Not') + ? 'not in' + : 'in'; + query[rawMethod](`?? ${operator} (?)`, [ + resolved.column, + captured() + ]); + } else + query[method]( + resolved.column as any, + captured.map(value => value.get(parameters)) + ); + }, + typeof captured === 'function' + ? [] + : captured.flatMap(value => value.parameters) + ) + ); } - /** Match captured values or a SELECT subquery. An empty list matches no rows. */ - whereIn(column: ReadPredicateSelector, values: ReadMembership): this { + /** Match a fixed value tuple or captured SELECT subquery. */ + whereIn, const A extends ReadMembership>( + column: S, + values: A & CheckParameterValue, PredicateValue> + ): Reparameterize>> { return this.membership(column, values, 'whereIn'); } - /** Exclude captured values or a SELECT subquery. SQL NOT IN null semantics apply. */ - whereNotIn(column: ReadPredicateSelector, values: ReadMembership): this { + whereNotIn< + S extends ReadPredicateSelector, + const A extends ReadMembership + >( + column: S, + values: A & CheckParameterValue, PredicateValue> + ): Reparameterize>> { return this.membership(column, values, 'whereNotIn'); } - /** Add an OR membership condition. */ - orWhereIn(column: ReadPredicateSelector, values: ReadMembership): this { + orWhereIn< + S extends ReadPredicateSelector, + const A extends ReadMembership + >( + column: S, + values: A & CheckParameterValue, PredicateValue> + ): Reparameterize>> { return this.membership(column, values, 'orWhereIn'); } - /** Add an OR negative membership condition. */ - orWhereNotIn( - column: ReadPredicateSelector, - values: ReadMembership - ): this { + orWhereNotIn< + S extends ReadPredicateSelector, + const A extends ReadMembership + >( + column: S, + values: A & CheckParameterValue, PredicateValue> + ): Reparameterize>> { return this.membership(column, values, 'orWhereNotIn'); } @@ -346,7 +474,7 @@ export abstract class ReadPredicates { | 'whereNotExists' | 'orWhereExists' | 'orWhereNotExists' - ): this { + ): any { const captured = captureSubquery( this.readPredicateContext().knex, subquery @@ -359,25 +487,26 @@ export abstract class ReadPredicates { query[rawMethod](`${operator} (?)`, [captured()]); }); } - /** Require a row in a captured SELECT subquery; use ref() to correlate it. */ - whereExists(subquery: Knex.QueryBuilder): this { - return this.exists(subquery, 'whereExists'); + whereExists(query: Knex.QueryBuilder): Reparameterize { + return this.exists(query, 'whereExists'); } - /** Require no rows in a captured SELECT subquery. */ - whereNotExists(subquery: Knex.QueryBuilder): this { - return this.exists(subquery, 'whereNotExists'); + whereNotExists(query: Knex.QueryBuilder): Reparameterize { + return this.exists(query, 'whereNotExists'); } - /** Add an OR EXISTS predicate. */ - orWhereExists(subquery: Knex.QueryBuilder): this { - return this.exists(subquery, 'orWhereExists'); + orWhereExists(query: Knex.QueryBuilder): Reparameterize { + return this.exists(query, 'orWhereExists'); } - /** Add an OR NOT EXISTS predicate. */ - orWhereNotExists(subquery: Knex.QueryBuilder): this { - return this.exists(subquery, 'orWhereNotExists'); + orWhereNotExists(query: Knex.QueryBuilder): Reparameterize { + return this.exists(query, 'orWhereNotExists'); } - /** Trusted SQL predicate with positional value (?) and identifier (??) bindings; not a SQL sandbox. */ - whereRaw(sql: string, bindings: readonly Knex.RawBinding[] = []): this { + /** Trusted SQL with concrete bindings; use a typed predicate for placeholders. */ + whereRaw( + sql: string, + bindings: A & WithoutParameters> + ): Reparameterize; + whereRaw(sql: string): Reparameterize; + whereRaw(sql: string, bindings: readonly Knex.RawBinding[] = []): any { const captured = captureReadRaw( this.readPredicateContext().knex, sql, @@ -387,8 +516,12 @@ export abstract class ReadPredicates { query.whereRaw(captured()); }); } - /** Add an OR trusted SQL predicate with captured positional bindings. */ - orWhereRaw(sql: string, bindings: readonly Knex.RawBinding[] = []): this { + orWhereRaw( + sql: string, + bindings: A & WithoutParameters> + ): Reparameterize; + orWhereRaw(sql: string): Reparameterize; + orWhereRaw(sql: string, bindings: readonly Knex.RawBinding[] = []): any { const captured = captureReadRaw( this.readPredicateContext().knex, sql, @@ -398,64 +531,92 @@ export abstract class ReadPredicates { query.orWhereRaw(captured()); }); } - - /** Negated bound equality (null uses SQL IS NOT NULL). */ - whereNot(column: ReadPredicateSelector, value: unknown): this { - const context = this.readPredicateContext(); - const name = context.column(column); - const captured = captureValue(context.knex, value); - return this.addReadPredicate(query => { - query.whereNot(name as any, captured()); - }); - } - /** Match an inclusive range of captured values. */ - whereBetween( - column: ReadPredicateSelector, - range: readonly [unknown, unknown] - ): this { + /** Negate equality while preserving ordinary SQL null semantics. */ + whereNot, const A>( + column: S, + value: A & CheckScalarParameter, PredicateValue> + ): Reparameterize>> { + return this.comparison('not', column, [value]); + } + whereBetween< + S extends ReadPredicateSelector, + const A extends readonly [unknown, unknown] + >( + column: S, + range: A & CheckParameterValue, PredicateValue> + ): Reparameterize>> { return this.range(column, range, false); } - /** Exclude an inclusive range of captured values. */ - whereNotBetween( - column: ReadPredicateSelector, - range: readonly [unknown, unknown] - ): this { + whereNotBetween< + S extends ReadPredicateSelector, + const A extends readonly [unknown, unknown] + >( + column: S, + range: A & CheckParameterValue, PredicateValue> + ): Reparameterize>> { return this.range(column, range, true); } private range( column: ReadPredicateSelector, range: readonly [unknown, unknown], not: boolean - ): this { + ): any { const context = this.readPredicateContext(); - const name = context.column(column); - const values = range.map(value => captureValue(context.knex, value)); - return this.addReadPredicate(query => { - query[not ? 'whereNotBetween' : 'whereBetween'](name as any, [ - values[0](), - values[1]() - ]); - }); - } - /** Match a SQL LIKE pattern. Wildcards retain their SQL meaning. */ - whereLike(column: ReadPredicateSelector, value: string): this { - return this.where(column, 'like', value); - } - /** Match a case-insensitive PostgreSQL pattern. */ - whereILike(column: ReadPredicateSelector, value: string): this { - return this.where(column, 'ilike', value); + const resolved = context.resolve(column); + const values = range.map(value => + typedValue(context.knex, value, resolved.schema) + ); + return this.addReadPredicate( + capturedPredicate( + (query, parameters) => { + query[not ? 'whereNotBetween' : 'whereBetween']( + resolved.column as any, + [values[0].get(parameters), values[1].get(parameters)] + ); + }, + values.flatMap(value => value.parameters) + ) + ); } - /** Compare a JSON path while preserving the declared output schema. */ + whereLike, const A>( + column: S, + value: A & + (A extends import('./parameter.js').QueryParameter + ? CheckParameterValue, PredicateValue> + : string) + ): Reparameterize>> { + return this.comparison('and', column, ['like', value]); + } + whereILike, const A>( + column: S, + value: A & + (A extends import('./parameter.js').QueryParameter + ? CheckParameterValue, PredicateValue> + : string) + ): Reparameterize>> { + return this.comparison('and', column, ['ilike', value]); + } + /** JSON-path strings do not provide a schema-derived parameter type. */ + whereJsonPath( + column: ReadPredicateSelector, + path: string, + operator?: string, + value?: A & WithoutParameters> + ): Reparameterize; whereJsonPath( column: ReadPredicateSelector, path: string, operator = '=', value?: unknown - ): this { + ): any { + assertNoParameters(value); const context = this.readPredicateContext(); const name = context.column(column); - const client = context.knex.client.config.client; - if (!['pg', 'postgres', 'postgresql'].includes(client)) + if ( + !['pg', 'postgres', 'postgresql'].includes( + context.knex.client.config.client as string + ) + ) throw new ReadSchemaError( 'whereJsonPath() is only supported on PostgreSQL' ); @@ -477,16 +638,14 @@ export abstract class ReadPredicates { } } -/** - * Predicate-only builder supplied to grouped where/andWhere/orWhere callbacks. - * Group methods return independent builders; attachment snapshots the returned group. - * No select, join, order, raw-query escape hatch, then, or execution method exists. - * Retaining this builder and mutating it after the callback throws. - */ -export class ReadPredicateBuilder extends ReadPredicates { +/** Predicate-only immutable builder; no execution, projection or ordering methods. */ +export class ReadPredicateBuilder< + C, + P extends ParameterState = [] +> extends ReadPredicates> { #context: ReadPredicateContext; - #operations: ReadPredicate[] = []; - /** @internal Created only for grouped predicates. */ + #operations: readonly ReadPredicate[] = []; + /** @internal Created by a reader to capture a synchronous predicate group. */ constructor(context: ReadPredicateContext) { super(); this.#context = context; @@ -494,17 +653,24 @@ export class ReadPredicateBuilder extends ReadPredicates { protected readPredicateContext(): ReadPredicateContext { return this.#context; } - protected addReadPredicate(predicate: ReadPredicate): this { + protected addReadPredicate(predicate: ReadPredicate): any { const copy = new ReadPredicateBuilder(this.#context); copy.#operations = [...this.#operations, predicate]; - return copy as this; + return copy; } - /** @internal Verify that a returned builder belongs to the supplied group. */ - sameSource(other: ReadPredicateBuilder): boolean { + /** @internal Reject builders belonging to another predicate group. */ + sameSource(other: ReadPredicateBuilder): boolean { return this.#context === other.#context; } - /** @internal Snapshot the immutable predicate list. */ + /** @internal Snapshot the configured group without executing its callbacks again. */ [finishGroup](): readonly ReadPredicate[] { return [...this.#operations]; } } + +/** @internal ORM readers specialize the same predicate signatures with their own return type. */ +export type ReadPredicateMethods< + C, + P extends ParameterState, + Self extends ParameterReader +> = Pick, keyof ReadPredicates>; diff --git a/libs/knex-schema/test-fixtures/property-navigation.consumer.js b/libs/knex-schema/test-fixtures/property-navigation.consumer.js index dfa65b82..ad7d4813 100644 --- a/libs/knex-schema/test-fixtures/property-navigation.consumer.js +++ b/libs/knex-schema/test-fixtures/property-navigation.consumer.js @@ -1,5 +1,5 @@ // Checked JavaScript consumers must retain the same declaration origins as TS. -import { query } from '@cleverbrush/knex-schema'; +import { parameter, query } from '@cleverbrush/knex-schema'; import { mapper } from '@cleverbrush/mapper'; import { useSchemaForm } from '@cleverbrush/react-form'; import Knex from 'knex'; @@ -32,3 +32,14 @@ const form = useSchemaForm(Plain); form.useField(t => t./*form-field*/ name); form.useField(t => t.tags[0]./*form-array*/ label); db.users.include(t => t./*orm-include*/ department); + +const findByName = read.where(t => t./*where*/ firstName, parameter('name')); +findByName.where(group => + group.where(t => t./*where*/ firstName, parameter('name')) +); +(await findByName('John'))[0]./*read-row*/ firstName; +const findEntity = db.users.where( + t => t./*orm-where*/ firstName, + parameter('name') +); +(await findEntity('John'))[0]./*orm-row*/ firstName; diff --git a/libs/orm/README.md b/libs/orm/README.md index 6a9f6eb0..0e011e36 100644 --- a/libs/orm/README.md +++ b/libs/orm/README.md @@ -247,6 +247,32 @@ for scoped search, subquery snapshots, pagination, and raw-SQL boundaries. --- +## Parameterized compiled reads + +`parameter` is re-exported by `@cleverbrush/orm`. Adding a named placeholder to +a DbSet query creates a callable SELECT with schema-inferred positional arguments: + +```ts +import { parameter } from '@cleverbrush/orm'; + +const findUsers = db.users + .where(t => t.name, parameter('name')) + .where(t => t.id, '>=', parameter('minimumId')); +const users = await findUsers('John', 10); +const sql = findUsers.toSQL('Jane', 20); // inspect without execution +const one = await findUsers.query('John', 10).find(10); +``` + +SQL compiles once on the first direct call or SQL inspection. Complete entity +rows participate in the context's identity map exactly as ordinary reads do; +projections remain detached. Bound readers retain ORM lookup helpers and +permitted mutation methods. Unbound templates cannot execute parameterless +terminals or writes. `ofVariant(...)` views support the same callable behavior. + +See [parameterized compiled queries](../knex-schema/README.md#parameterized-compiled-queries) +for argument ordering, null semantics, relation/variant parameters, transactions, +and the supported PostgreSQL SELECT shapes. + ## Polymorphic entities (STI / CTI) ### Single-Table Inheritance (STI) diff --git a/libs/orm/src/compiled-query.test-d.ts b/libs/orm/src/compiled-query.test-d.ts new file mode 100644 index 00000000..1f5ea955 --- /dev/null +++ b/libs/orm/src/compiled-query.test-d.ts @@ -0,0 +1,73 @@ +import Knex from 'knex'; +import { expectTypeOf, test } from 'vitest'; +import { + createDb, + defineEntity, + number, + object, + parameter, + string +} from './index.js'; + +const User = object({ id: number().primaryKey(), name: string() }).hasTableName( + 'users' +); +const Task = object({ + id: number().primaryKey(), + ownerId: number(), + owner: User.optional() +}).hasTableName('tasks'); +const tasks = defineEntity(Task).belongsTo( + t => t.owner, + t => t.ownerId, + t => t.id +); +const assets = defineEntity( + object({ id: number().primaryKey(), kind: string() }).hasTableName('assets') +) + .discriminator('kind') + .stiVariant('note', object({ text: string() })); +const db = createDb(Knex({ client: 'pg' }), { + users: defineEntity(User), + tasks, + assets +}); + +test('ORM callable readers retain lookup helpers after binding and fluent filters', async () => { + const find = db.users + .where(t => t.name, parameter('name')) + .limit(5) + .orderBy(t => t.id); + expectTypeOf(find).parameters.toEqualTypeOf<[string]>(); + expectTypeOf(await find('John')).toEqualTypeOf< + { id: number; name: string }[] + >(); + expectTypeOf(await find.query('John').find(1)).toEqualTypeOf< + { id: number; name: string } | undefined + >(); + // @ts-expect-error unbound ORM lookups are unavailable + find.find(1); + // @ts-expect-error unbound ORM mutations are unavailable + find.update({ name: 'Jane' }); + const nested = db.tasks + .include( + t => t.owner, + q => q.where(t => t.name, parameter('name')) + ) + .where(t => t.id, parameter('id')); + expectTypeOf(nested).parameters.toEqualTypeOf<[string, number]>(); + expectTypeOf((await nested('John', 1))[0].owner).toEqualTypeOf<{ + id: number; + name: string; + } | null>(); + const variant = db.assets + .ofVariant('note') + .where(t => t.id, parameter('id')) + .limit(1); + expectTypeOf(variant).parameters.toEqualTypeOf<[number]>(); + expectTypeOf(variant.query(1).update({ text: 'hello' })).toEqualTypeOf< + Promise + >(); + // @ts-expect-error unbound variant writes are unavailable + variant.update({ text: 'hello' }); +}); diff --git a/libs/orm/src/dbset.ts b/libs/orm/src/dbset.ts index 92ffe4e8..5d4a0644 100644 --- a/libs/orm/src/dbset.ts +++ b/libs/orm/src/dbset.ts @@ -23,8 +23,20 @@ import type { VariantReadSchemas } from '@cleverbrush/knex-schema'; import { + type AttachParameters, + assertParametersBound, + type CheckParameterState, + COMPILED_READER, getPrimaryKeyColumns, getVariants, + isParameterizedQuery, + type MergeParameters, + type ParameterReader, + type ParameterState, + type ParametersOf, + type QueryView, + type ReadColumns, + type ReadPredicateMethods, type SchemaQueryBuilder, query as schemaQuery } from '@cleverbrush/knex-schema'; @@ -86,6 +98,24 @@ type EntityWrites< : never; }; +type EntityFluentMethod = + | 'orderBy' + | 'orderByRaw' + | 'limit' + | 'offset' + | 'transacting' + | 'withDeleted' + | 'onlyDeleted' + | 'unscoped' + | 'scoped'; +type EntityFluent = { + [K in Extract]: Q[K] extends ( + ...args: infer A + ) => unknown + ? (...args: A) => QueryView + : never; +}; + // These methods return scalars or detached read plans, not tracked entity rows. const untrackedResultMethods = new Set([ 'apply', @@ -118,18 +148,38 @@ const untrackedResultMethods = new Set([ export type EntityQuery< TEntity extends Entity, TResult, - Writable extends boolean = true + Writable extends boolean = true, + P extends ParameterState = [] > = ReadVariantMetadata> extends { discriminator: string; variants: Record; } - ? PolymorphicEntityQuery - : TableEntityQuery; + ? PolymorphicEntityQuery + : TableEntityQuery; /** A tracked-capable polymorphic read; projections use forVariant(). */ -export interface PolymorphicEntityQuery> - extends PolymorphicQueryBuilder>, +export interface PolymorphicEntityQuery< + TEntity extends Entity, + P extends ParameterState = [] +> extends Omit< + PolymorphicQueryBuilder< + EntitySchema, + VariantReadSchemas>, + P + >, + | keyof ReadPredicateMethods + | EntityFluentMethod + >, + ReadPredicateMethods< + ReadColumns, keyof EntityRelations>, + P, + PolymorphicEntityParameterReader + >, + EntityFluent< + PolymorphicQueryBuilder>, + PolymorphicEntityQuery + >, Pick< TableEntityQuery>, 'find' | 'findOrFail' | 'findMany' @@ -139,17 +189,32 @@ export interface PolymorphicEntityQuery> export interface TableEntityQuery< TEntity extends Entity, TResult, - Writable extends boolean = true + Writable extends boolean = true, + P extends ParameterState = [] > extends Omit< SchemaQueryBuilder< EntitySchema, EntityRowSchema, EntityRelations, - Writable + Writable, + P >, - 'include' | 'includeVariant' | WriteMethod + | 'include' + | 'includeVariant' + | WriteMethod + | keyof ReadPredicateMethods + | EntityFluentMethod >, - EntityWrites { + EntityFluent< + SchemaQueryBuilder>, + TableEntityQuery + >, + EntityWrites, + ReadPredicateMethods< + ReadColumns, keyof EntityRelations>, + P, + EntityParameterReader + > { /** Configure a discriminator branch with the canonical strongly typed query API. */ forVariant: SchemaAwareQuery> extends { forVariant: infer F; @@ -176,20 +241,26 @@ export interface TableEntityQuery< sel: (t: RelKeyTree) => K, customize?: ( query: SchemaAwareQuery> - ) => Child - ): EntityQuery< - TEntity, - TResult & { - -readonly [P in keyof Pick< - EntityRelations, - K - >]-?: EntityRelations[K] extends { - kind: 'hasMany' | 'belongsToMany'; - } - ? InferType[] - : InferType | null; - }, - false + ) => Child & + CheckParameterState< + MergeParameters>> + > + ): QueryView< + EntityQuery< + TEntity, + TResult & { + -readonly [P in keyof Pick< + EntityRelations, + K + >]-?: EntityRelations[K] extends { + kind: 'hasMany' | 'belongsToMany'; + } + ? InferType[] + : InferType | null; + }, + false, + AttachParameters, `relation:${K}`> + > >; /** @@ -255,6 +326,36 @@ export interface TableEntityQuery< ): Promise; } +/** @internal Typed ORM fluent return constructors. */ +interface EntityParameterReader< + TEntity extends Entity, + TResult, + Writable extends boolean +> extends ParameterReader { + readonly result: QueryView< + TableEntityQuery< + TEntity, + TResult, + Writable, + this['parameters'] extends ParameterState + ? this['parameters'] + : never + > + >; +} +interface PolymorphicEntityParameterReader< + TEntity extends Entity +> extends ParameterReader { + readonly result: QueryView< + PolymorphicEntityQuery< + TEntity, + this['parameters'] extends ParameterState + ? this['parameters'] + : never + > + >; +} + // --------------------------------------------------------------------------- // DbSet — public typed query starter // --------------------------------------------------------------------------- @@ -359,14 +460,29 @@ export interface DbSetOperations> { */ export interface VariantDbSet< TEntity extends Entity, - K extends string -> extends PolymorphicQueryBuilder< - EntitySchema, - Pick< - VariantReadSchemas>, - Extract>> - > - > { + K extends string, + P extends ParameterState = [] +> extends Omit< + PolymorphicQueryBuilder< + EntitySchema, + Pick< + VariantReadSchemas>, + Extract>> + >, + P + >, + | keyof ReadPredicateMethods + | EntityFluentMethod + >, + ReadPredicateMethods< + ReadColumns, keyof EntityRelations>, + P, + VariantParameterReader + >, + EntityFluent< + PolymorphicQueryBuilder>, + VariantDbSet + > { /** Look up a single row by PK, typed to this variant. */ find( pk: PrimaryKeyValueOf> @@ -407,7 +523,24 @@ export interface VariantDbSet< restore(): Promise[]>; /** Return a new variant view bound to `trx`. */ - withTransaction(trx: Knex.Transaction): VariantDbSet; + withTransaction( + trx: Knex.Transaction + ): QueryView>; +} + +interface VariantParameterReader< + TEntity extends Entity, + K extends string +> extends ParameterReader { + readonly result: QueryView< + VariantDbSet< + TEntity, + K, + this['parameters'] extends ParameterState + ? this['parameters'] + : never + > + >; } // --------------------------------------------------------------------------- @@ -430,9 +563,32 @@ function wrapQuery, TResult>( const proxy: EntityQuery = new Proxy( sqb as unknown as object, { + apply(target, _this, args) { + return Reflect.apply(target as Function, undefined, args).then( + (rows: unknown[]) => + onResults && sqb.returnsEntityRows + ? (onResults(rows) ?? rows) + : rows + ); + }, get(target, prop, receiver) { if (prop === '_sqb') return sqb; if (prop === '_entity') return entity; + if ( + isParameterizedQuery(sqb) && + [ + 'find', + 'findOrFail', + 'findMany', + 'insert', + 'update', + 'delete', + 'restore', + 'hardDelete' + ].includes(String(prop)) + ) { + return () => assertParametersBound(sqb); + } if ( prop === 'find' || @@ -485,7 +641,7 @@ function wrapQuery, TResult>( if (result === sqb) return proxy; if ( result && - typeof result.execute === 'function' && + typeof result[COMPILED_READER] === 'function' && typeof result.sameSource === 'function' && sqb.sameSource(result) ) @@ -801,9 +957,32 @@ function wrapVariantQuery< const proxy: VariantDbSet = new Proxy( sqb as unknown as object, { + apply(target, _this, args) { + return Reflect.apply(target as Function, undefined, args).then( + (rows: unknown[]) => + onResults && sqb.returnsEntityRows + ? (onResults(rows) ?? rows) + : rows + ); + }, get(target, prop, receiver) { if (prop === '_sqb') return sqb; if (prop === '_entity') return entity; + if ( + isParameterizedQuery(sqb) && + [ + 'find', + 'findOrFail', + 'findMany', + 'insert', + 'update', + 'delete', + 'restore', + 'hardDelete' + ].includes(String(prop)) + ) { + return () => assertParametersBound(sqb); + } // --- find* delegated to the shared helper --- if ( @@ -903,7 +1082,7 @@ function wrapVariantQuery< if (result === sqb) return proxy; if ( result && - typeof result.execute === 'function' && + typeof result[COMPILED_READER] === 'function' && typeof result.sameSource === 'function' && sqb.sameSource(result) ) diff --git a/websites/docs/app/knex-schema/page.tsx b/websites/docs/app/knex-schema/page.tsx index 079fd765..c221763d 100644 --- a/websites/docs/app/knex-schema/page.tsx +++ b/websites/docs/app/knex-schema/page.tsx @@ -413,6 +413,49 @@ const rows = await read.where(t => t.id, taskId);`) +
+

Parameterized compiled queries

+

+ Add a named parameter to make a PostgreSQL SELECT + callable. Argument types come from the selected schema + properties, in the order their names first appear. + Repeated names share one argument. +

+
+                         t.id, parameter('id'));
+const users = await findUser(10);
+
+const search = query(knex, UserSchema)
+    .where(t => t.firstName, parameter('name'))
+    .where(t => t.age, '>=', parameter('minimumAge'));
+await search('John', 18);
+await search('Jane', 30);
+
+const sql = search.toSQL('John', 18); // inspect without executing
+const bound = search.query('John', 18);
+await bound.orderBy(t => t.age).limit(10);`)
+                            }}
+                        />
+                    
+

+ The first call or SQL inspection caches SQL, binding + slots and decoding. Later calls bind fresh values and + execute again. Bound readers compose independently; + template derivatives own their compiled statements. + Relations, aliases, variants and caller-owned + transactions are supported. +

+ + Examples, null semantics and supported parameter + positions + +
+

Filtering and ordering schema-aware reads

diff --git a/websites/docs/app/orm/page.tsx b/websites/docs/app/orm/page.tsx index 58be4c79..a8eb1fca 100644 --- a/websites/docs/app/orm/page.tsx +++ b/websites/docs/app/orm/page.tsx @@ -539,6 +539,21 @@ npx cb-orm db push` {/* ── See Also ─────────────────────────────────────── */}

Reliable query composition

+

+ Named parameters make DbSet reads callable: use{' '} + + db.users.where(t => t.id, parameter('id')) + + , then call the result with an ID. SQL compiles once; + complete rows retain identity tracking and projections + stay detached. Use .query(...args) for a + bound ORM reader or .toSQL(...args) to + inspect SQL. See{' '} + + compiled query examples + + . +

Eager loading retains parent ordering and page size. Relation customization callbacks infer the foreign