diff --git a/.changeset/composable-typed-queries.md b/.changeset/composable-typed-queries.md new file mode 100644 index 00000000..1b396dd7 --- /dev/null +++ b/.changeset/composable-typed-queries.md @@ -0,0 +1,11 @@ +--- +'@cleverbrush/knex-schema': minor +'@cleverbrush/orm': minor +--- + +Add typed flat joined projections, aggregate expressions and scalar helpers +with optional output schemas, and composite keyset pagination. Preserve parent +ordering during eager loading and infer related-query customization types. +Existing aggregate and single-column cursor APIs remain available unchanged. +Export reusable SQL identifier validation, preserve schema inference throughout +bound query factories, and document the public query/ORM APIs for IDE tooltips. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c9af5d0..54d00131 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,32 @@ jobs: - name: Test & Typecheck run: npm run test + + query-integration: + name: PostgreSQL Query Integration + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: framework_queries + POSTGRES_USER: framework_test + POSTGRES_PASSWORD: framework_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U framework_test -d framework_queries" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + QUERY_TEST_DATABASE_URL: postgres://framework_test:framework_test@127.0.0.1:5432/framework_queries + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:queries:integration diff --git a/libs/knex-schema/COMPOSABLE_QUERIES.md b/libs/knex-schema/COMPOSABLE_QUERIES.md new file mode 100644 index 00000000..e86394bb --- /dev/null +++ b/libs/knex-schema/COMPOSABLE_QUERIES.md @@ -0,0 +1,188 @@ +# Composable read queries + +These APIs are additive. Existing single-schema selectors, nested eager loading, +legacy aggregate methods, and single-column cursor calls remain available. + +## Typed flat joins + +```ts +import { alias, eq, query } from '@cleverbrush/knex-schema'; + +const task = alias(TaskSchema, 'task'); +const owner = alias(UserSchema, 'owner'); +const rows = await query(knex, task) + .leftJoin(owner, t => eq(t.task.ownerId, t.owner.id)) + .where(t => t.task.projectId, projectId) + .orderBy(t => t.task.id, 'desc') + .select(t => ({ id: t.task.id, ownerName: t.owner.name })); +// ownerName includes null because owner is left-joined. +``` + +Aliases do not mutate schemas. Each source retains default scopes and soft-delete +filters; filtering the right-hand source does not turn a left join into an inner +join. `createQuery(knex)` also accepts aliased schemas. `eq`, `and`, and `or` +compose column-based join conditions. Duplicate aliases are errors. + +Both `and` and `or` accept multiple predicates and can be nested. Each group +becomes a parenthesized SQL expression, preserving the intended precedence: + +```ts +import { alias, and, eq, or, query } from '@cleverbrush/knex-schema'; + +const rows = await query(knex, alias(TaskSchema, 'task')) + .join(alias(UserSchema, 'owner'), t => or( + eq(t.task.ownerId, t.owner.id), + and( + eq(t.task.approverId, t.owner.id), + or( + eq(t.task.teamId, t.owner.teamId), + eq(t.task.creatorId, t.owner.id) + ) + ) + )) + .select(t => ({ id: t.task.id, ownerName: t.owner.name })); +``` + +This example assumes the illustrated ID properties exist on the schemas. +Empty `and()`/`or()` groups are rejected. Predicates describe SQL; they do not +execute a JavaScript callback for each returned row. + +`isSqlIdentifier(value)` is an exported type guard used by `alias()`. It accepts +one ASCII identifier (`task_owner2`), not qualified names (`public.tasks`), quoted +names, whitespace or non-string values. This deliberately conservative format +does not describe every valid PostgreSQL identifier and does not replace Knex +identifier quoting. Existing table/column metadata APIs are not restricted by it. + +The read-only aliased builder requires an explicit, non-empty projection and +supports `where`, `whereIn`, `whereNull`, `whereNotNull`, `orderBy`, `orderByRaw`, +`groupBy`, `having`, `limit`, `offset`, `first`, `execute`, `transacting`, and +awaiting the query. `apply`/`toKnexQuery` remain raw escape hatches whose effects +on result shape/cardinality are the caller's responsibility. Values remain bound +and identifiers quoted. Flat collection joins can repeat parents; they do not +deduplicate or fetch one related row at a time. Choose ORM eager loading for +nested related objects instead. + +For reusable connection/transaction handling, ordinary and aliased schemas retain +the same inference through `createQuery(knex)`, `withTransaction(trx)`, and +`transaction(callback)`. Only ordinary-schema calls accept a custom Knex base +query. These APIs and their JSDoc are also available through `@cleverbrush/orm`. + +## Aggregates with optional output schemas + +```ts +import { aggregate, number } from '@cleverbrush/knex-schema'; + +const count = await query(knex, TaskSchema).countValue(); // number +const total = await query(knex, TaskSchema) + .sumValue(t => t.estimate); // string | null + +const grouped = await query(knex, TaskSchema) + .groupBy(t => t.ownerId) + .select(t => ({ + ownerId: t.ownerId, + count: aggregate.count(), + total: aggregate.sum(t.estimate) + })); + +// Explicit floating-point conversion when appropriate for your domain. +const average = await query(knex, TaskSchema) + .avgValue(t => t.estimate, { + output: number().isFloat().coerce().nullable() + }); // number | null +``` + +| API | Default result | +| --- | --- | +| `countValue(options?)`, `countValue(column, options?)` | Safe number; counts rows or non-null values | +| `countDistinctValue(column, options?)` | Safe number | +| `sumValue(column, options?)`, `avgValue(column, options?)` | Database numeric text, or null | +| `minValue(column, options?)`, `maxValue(column, options?)` | Column's database representation, or null | + +The corresponding `aggregate.count/countDistinct/sum/avg/min/max` expressions +work in ordinary and aliased object projections. Expression options are the +second argument: `aggregate.count(undefined, { output: schema })` customizes +`COUNT(*)`. Aliased `having` also accepts aggregate expressions; its comparisons +use native SQL values, not decoded/text-formatted values. + +Counts reject malformed results and integers outside JavaScript's safe range. +Sum/average preserve PostgreSQL's result as text without changing global driver +parsers. This does not make floating-point source columns exact. Numeric/decimal/ +bigint extrema retain strings; because existing SQL overrides are not fully +represented in schema types, numeric extrema are conservatively typed as +`number | string | null`. Date extrema return `Date | null`. + +An optional **output schema replaces the default decoder**. Its synchronous +`parse` receives the raw driver value, including null, before default conversion. +Its schema output type determines the result, including nullable/optional +modifiers. PostgreSQL counts normally reach it as strings, so a number schema +needs explicit coercion. Validation failures reject the query. Custom parsers +may return bigint/domain decimal types; JSON serialization remains application +code. With custom parsers, the application owns their conversion/precision policy. + +Scalar helpers clone the source, ignore its limit/offset/order, and retain +filters, semantic joins, scopes, and transactions. They reject grouped, HAVING, +distinct, and already aggregated queries; use aggregate projections for those. +An empty scalar count is zero; other empty/all-null aggregates are null. An +empty grouped query returns no rows. Legacy `.count()`, `.sum()`, etc. keep their +existing behavior and signatures. + +## Eager-loading order + +```ts +const tasks = await query(knex, TaskSchema) + .orderBy(t => t.createdAt, 'desc') + .orderBy(t => t.id, 'desc') + .joinMany({ + foreignSchema: NoteSchema, + localColumn: t => t.id, + foreignColumn: t => t.taskId, + as: 'notes' + }) + .limit(20); +``` + +The final SELECT retains parent order, including bound raw ordering expressions. +Limits/offsets select parents, not expanded child rows. Internal fields are +removed from mapped results. Parent ordering, child ordering, and relation +filtering are separate operations. Add a unique tie-breaker for deterministic +ordering of tied parents. + +## Composite cursors + +```ts +const page = await query(knex, TaskSchema) + .where(t => t.projectId, projectId) + .select(t => ({ id: t.id, title: t.title })) + .paginateAfter({ + cursor: previousPage?.nextCursor, + limit: 50, + orderBy: [ + { column: t => t.createdAt, direction: 'desc' }, + { column: t => t.id, direction: 'desc' } + ] + }); +// { data, nextCursor: string | null, hasMore: boolean } +``` + +The opt-in `orderBy` form controls the full sort, replacing earlier ordering. +Mixed directions, projections, and eager loading are supported. Sort fields must +be non-null scalar columns and include a schema-declared primary/unique key. +Required eager relations filter parents before the cursor page limit is applied. +Offsets, flat joins, distinct/grouped/aggregate queries, malformed cursors, and +cursors from a different table/order are rejected. The source builder is not +mutated. The original `column`/`direction` form is unchanged. + +Cursors are versioned opaque strings preserving database timestamp precision +and numeric values without Date/number round-trips. Clients must not parse them. +Reapply access filters on every request and discard cursors when filters change: +they are positions, not authorization. They do not provide snapshot isolation; +sort-key updates can move records across the cursor. Sequential paging does not +support arbitrary page-number jumps; counts remain separate queries. Indexes +and representative query-plan measurements remain application work. + +## Integration verification + +From the repository root, run `npm run test:queries:integration` with +`QUERY_TEST_DATABASE_URL` pointing to a dedicated PostgreSQL test database. +The suite creates/drops only its randomly named fixture tables and fails if the +URL is missing. CI runs PostgreSQL 16 integration tests alongside unit/type tests. diff --git a/libs/knex-schema/README.md b/libs/knex-schema/README.md index 594e6958..0b5421de 100644 --- a/libs/knex-schema/README.md +++ b/libs/knex-schema/README.md @@ -464,6 +464,11 @@ The schema's `hasColumnName()` metadata is used to map `firstName` → `first_na ## API Reference +See [Composable read queries](./COMPOSABLE_QUERIES.md) for typed flat joins with +`alias`, all aggregate families with optional output schemas, preserved eager-load +ordering, and multi-column cursor pagination. These APIs preserve existing calls +and include runtime, type, and PostgreSQL integration coverage. + ### `query(knex, schema, baseQuery?)` Creates a `SchemaQueryBuilder`. `schema` must have `.hasTableName()` set. diff --git a/libs/knex-schema/integration/queries.test.ts b/libs/knex-schema/integration/queries.test.ts new file mode 100644 index 00000000..dcc6ea59 --- /dev/null +++ b/libs/knex-schema/integration/queries.test.ts @@ -0,0 +1,704 @@ +import { randomUUID } from 'node:crypto'; +import { + aggregate, + alias, + and, + array, + boolean, + createDb, + createQuery, + date, + defineEntity, + eq, + number, + object, + or, + query, + string +} from '@cleverbrush/orm'; +import Knex from 'knex'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const connection = process.env.QUERY_TEST_DATABASE_URL; +if (!connection) + throw new Error( + 'QUERY_TEST_DATABASE_URL is required; integration tests must not silently skip' + ); +const knex = Knex({ client: 'pg', connection, pool: { min: 0, max: 4 } }); +const prefix = `cb_query_${randomUUID().replaceAll('-', '')}`; +const tables = { + users: `${prefix}_users`, + tasks: `${prefix}_tasks`, + notes: `${prefix}_notes` +}; + +const User = object({ + id: number().primaryKey(), + name: string().hasColumnName('display_name'), + enabled: boolean(), + deletedAt: date().optional().hasColumnName('deleted_at') +}) + .hasTableName(tables.users) + .softDelete() + .defaultScope((q: any) => q.where('enabled', true)); +const Note = object({ + id: number().primaryKey(), + taskId: number().hasColumnName('task_id'), + body: string() +}).hasTableName(tables.notes); +const Task = object({ + id: number().primaryKey(), + ownerId: number().hasColumnName('owner_id'), + projectId: number().hasColumnName('project_id'), + title: string(), + amount: number().decimal(24, 6).optional(), + createdAt: date().hasColumnName('created_at'), + deletedAt: date().optional().hasColumnName('deleted_at'), + owner: User.optional(), + notes: array(Note).optional() +}) + .hasTableName(tables.tasks) + .softDelete(); +const taskEntity = defineEntity(Task) + .belongsTo( + t => t.owner, + t => t.ownerId, + t => t.id + ) + .hasMany( + t => t.notes, + t => t.id, + t => t.taskId + ); +const db = createDb(knex, { tasks: taskEntity }); +const orderBy = [ + { column: (t: any) => t.createdAt, direction: 'desc' as const }, + { column: (t: any) => t.id, direction: 'desc' as const } +]; + +beforeAll(async () => { + await knex.schema.createTable(tables.users, t => { + t.integer('id').primary(); + t.text('display_name').notNullable(); + t.boolean('enabled').notNullable(); + t.timestamp('deleted_at').nullable(); + }); + await knex.schema.createTable(tables.tasks, t => { + t.integer('id').primary(); + t.integer('owner_id').notNullable(); + t.integer('project_id').notNullable(); + t.text('title').notNullable(); + t.decimal('amount', 24, 6).nullable(); + t.specificType('created_at', 'timestamp(6)').notNullable(); + t.timestamp('deleted_at').nullable(); + }); + await knex.schema.createTable(tables.notes, t => { + t.integer('id').primary(); + t.integer('task_id').notNullable(); + t.text('body').notNullable(); + }); + await knex(tables.users).insert([ + { id: 1, display_name: 'Alice', enabled: true }, + { id: 2, display_name: 'Bob', enabled: false }, + { + id: 3, + display_name: 'Deleted', + enabled: true, + deleted_at: '2026-01-01' + } + ]); + await knex(tables.tasks).insert([ + { + id: 105, + owner_id: 1, + project_id: 1, + title: 'B', + amount: '0.100000', + created_at: '2026-01-01 10:00:00.000002' + }, + { + id: 104, + owner_id: 2, + project_id: 1, + title: 'A', + amount: '0.200000', + created_at: '2026-01-01 10:00:00.000002' + }, + { + id: 103, + owner_id: 3, + project_id: 1, + title: 'A', + amount: null, + created_at: '2026-01-01 10:00:00.000002' + }, + { + id: 102, + owner_id: 1, + project_id: 1, + title: 'C', + amount: '9007199254740993.000001', + created_at: '2026-01-01 10:00:00.000001' + }, + { + id: 101, + owner_id: 1, + project_id: 2, + title: 'Other project', + amount: '99', + created_at: '2026-01-01 09:59:00' + }, + { + id: 100, + owner_id: 1, + project_id: 1, + title: 'Deleted task', + amount: '1000', + created_at: '2026-01-01 09:58:00', + deleted_at: '2026-01-01' + } + ]); + await knex(tables.notes).insert([ + { id: 1, task_id: 104, body: 'one' }, + { id: 2, task_id: 104, body: 'two' }, + { id: 3, task_id: 103, body: 'three' } + ]); +}); + +afterAll(async () => { + // Only this suite's randomly named fixtures in its explicitly supplied test DB. + for (const table of [tables.notes, tables.tasks, tables.users]) + await knex.schema.dropTableIfExists(table); + await knex.destroy(); +}); + +describe('flat joins', () => { + it('executes ordinary and aliased queries through a bound transaction callback', async () => { + const result = await createQuery(knex).transaction(async tx => ({ + ordinary: await tx(Task) + .where(t => t.id, 105) + .select(t => ({ id: t.id })) + .first(), + joined: await tx(alias(Task, 'task')) + .leftJoin(alias(User, 'owner'), t => + eq(t.task.ownerId, t.owner.id) + ) + .where(t => t.task.id, 105) + .select(t => ({ id: t.task.id, ownerName: t.owner.name })) + .first() + })); + expect(result).toEqual({ + ordinary: { id: 105 }, + joined: { id: 105, ownerName: 'Alice' } + }); + }); + it('executes nested AND/OR predicates with their explicit grouping', async () => { + const rows = await query(knex, alias(Task, 'task')) + .join(alias(Task, 'peer'), t => + and( + or( + eq(t.task.ownerId, t.peer.ownerId), + eq(t.task.projectId, t.peer.projectId) + ), + or( + eq(t.task.id, t.peer.id), + and( + eq(t.task.ownerId, t.peer.projectId), + eq(t.task.projectId, t.peer.ownerId) + ) + ) + ) + ) + .where(t => t.task.projectId, 1) + .orderBy(t => t.task.id, 'desc') + .orderBy(t => t.peer.id, 'desc') + .select(t => ({ task: t.task.id, peer: t.peer.id })); + expect(rows).toEqual([ + { task: 105, peer: 105 }, + { task: 105, peer: 102 }, + { task: 104, peer: 104 }, + { task: 103, peer: 103 }, + { task: 102, peer: 105 }, + { task: 102, peer: 102 } + ]); + }); + it('preserves left joins when the right-hand schema has scopes and soft deletion', async () => { + const rows = await query(knex, alias(Task, 'task')) + .leftJoin(alias(User, 'owner'), t => eq(t.task.ownerId, t.owner.id)) + .where(t => t.task.projectId, 1) + .orderBy(t => t.task.id, 'desc') + .select(t => ({ id: t.task.id, owner: t.owner.name })); + expect(rows).toEqual([ + { id: 105, owner: 'Alice' }, + { id: 104, owner: null }, + { id: 103, owner: null }, + { id: 102, owner: 'Alice' } + ]); + }); + + it('groups joined rows with exact aggregates and numeric HAVING', async () => { + const rows = await query(knex, alias(Task, 'task')) + .join(alias(User, 'owner'), t => eq(t.task.ownerId, t.owner.id)) + .where(t => t.task.projectId, 1) + .groupBy(t => t.owner.name) + .having(() => aggregate.count(), '>', 1) + .select(t => ({ + owner: t.owner.name, + count: aggregate.count(), + total: aggregate.sum(t.task.amount) + })); + expect(rows).toEqual([ + { owner: 'Alice', count: 2, total: '9007199254740993.100001' } + ]); + }); +}); + +describe('eager ordering', () => { + it('keeps tied parent order and page size with one-to-many and one-to-one includes', async () => { + const rows = await db.tasks + .where(t => t.projectId, 1) + .orderBy(t => t.title) + .orderBy(t => t.id, 'desc') + .include(t => t.notes) + .include(t => t.owner) + .limit(2); + expect(rows.map(row => row.id)).toEqual([104, 103]); + expect(rows[0].notes).toHaveLength(2); + expect(rows[1].notes).toHaveLength(1); + expect(Object.keys(rows[0]).some(key => key.startsWith('__cb_'))).toBe( + false + ); + }); + + it('handles projected-away sort/FK fields, mapped aliases, raw bindings, offset and transactions', async () => { + await knex.transaction(async trx => { + const rows = await query(knex, Task) + .where(t => t.projectId, 1) + .orderByRaw('case when ?? = ? then 0 else 1 end, ?? desc', [ + 'id', + 102, + 'id' + ]) + .select(t => ({ taskId: t.id })) + .joinOne({ + foreignSchema: User, + localColumn: t => t.ownerId, + foreignColumn: t => t.id, + as: 'user', + required: false + }) + .offset(1) + .limit(2) + .transacting(trx); + expect(rows.map(row => row.taskId)).toEqual([105, 104]); + expect(Object.keys(rows[0]).sort()).toEqual(['taskId', 'user']); + }); + }); + + it('retains distinct/grouped parent cardinality', async () => { + for (const distinct of [true, false]) { + let q = query(knex, Task) + .where(t => t.projectId, 1) + .select(t => ({ owner_id: t.ownerId })) + .orderBy(t => t.ownerId); + q = distinct ? q.distinct() : q.groupBy(t => t.ownerId); + const rows = await q.joinOne({ + foreignSchema: User, + localColumn: t => t.ownerId, + foreignColumn: t => t.id, + as: 'user', + required: false + }); + expect(rows.map(row => row.owner_id)).toEqual([1, 2, 3]); + } + }); + + it.each([ + '"taskId" desc', + '1 desc' + ])('retains ordering by a projected alias or position: %s', async order => { + const rows = await query(knex, Task) + .where(t => t.projectId, 1) + .select(t => ({ taskId: t.id })) + .orderByRaw(order) + .joinMany({ + foreignSchema: Note, + localColumn: t => t.id, + foreignColumn: t => t.taskId, + as: 'notes' + }) + .limit(2); + expect(rows.map(row => row.taskId)).toEqual([105, 104]); + }); +}); + +describe('aggregate results', () => { + const tasks = () => db.tasks.where(t => t.projectId, 1); + it('counts rows, non-null values and distinct values without retaining page limits', async () => { + expect(await tasks().limit(1).offset(10).countValue()).toBe(4); + expect(await tasks().countValue(t => t.amount)).toBe(3); + expect(await tasks().countDistinctValue(t => t.ownerId)).toBe(3); + expect(await tasks().countValue({ output: string() })).toBe('4'); + }); + + it('counts parent rows through required relations and collection includes', async () => { + expect( + await tasks() + .include(t => t.notes) + .countValue() + ).toBe(4); + const visibleOwner = query(knex, Task) + .where(t => t.projectId, 1) + .joinOne({ + foreignSchema: User, + foreignQuery: query(knex, User), + localColumn: t => t.ownerId, + foreignColumn: t => t.id, + as: 'owner', + required: true + }); + expect(await visibleOwner.countValue()).toBe(2); + expect(await visibleOwner.sumValue(t => t.amount)).toBe( + '9007199254740993.100001' + ); + }); + + it('retains default-scope filters while ignoring default-scope pagination', async () => { + const Scoped = object({ id: number().primaryKey() }) + .hasTableName(tables.tasks) + .defaultScope((q: any) => + q + .where('project_id', 1) + .whereNull('deleted_at') + .limit(1) + .offset(1) + ); + expect(await query(knex, Scoped).countValue()).toBe(4); + const Grouped = Scoped.defaultScope((q: any) => q.groupBy('id')); + await expect(query(knex, Grouped).countValue()).rejects.toThrow( + 'ungrouped' + ); + }); + + it('preserves sum/average/decimal extrema and returns date/string extrema', async () => { + expect(await tasks().sumValue(t => t.amount)).toBe( + '9007199254740993.300001' + ); + const native = await knex(tables.tasks) + .where({ project_id: 1 }) + .whereNull('deleted_at') + .avg({ value: 'amount' }) + .first(); + expect(await tasks().avgValue(t => t.amount)).toBe(native!.value); + expect(await tasks().minValue(t => t.amount)).toBe('0.100000'); + expect(await tasks().maxValue(t => t.amount)).toBe( + '9007199254740993.000001' + ); + expect(await tasks().minValue(t => t.title)).toBe('A'); + expect(await tasks().maxValue(t => t.createdAt)).toBeInstanceOf(Date); + }); + + it('handles empty/all-null inputs and caller-supplied parsers', async () => { + const empty = () => query(knex, Task).where(t => t.id, -1); + expect(await empty().countValue()).toBe(0); + for (const method of [ + 'sumValue', + 'avgValue', + 'minValue', + 'maxValue' + ] as const) { + expect(await empty()[method]('amount')).toBe(null); + } + expect( + await query(knex, Task) + .where(t => t.id, 103) + .sumValue(t => t.amount) + ).toBe(null); + expect( + await query(knex, Task) + .where(t => t.id, 105) + .sumValue(t => t.amount, { + output: number().isFloat().coerce() + }) + ).toBe(0.1); + await expect( + empty().sumValue(t => t.amount, { output: string() }) + ).rejects.toThrow(); + }); + + it('decodes every grouped aggregate without treating rows as entities', async () => { + const rows = await query(knex, Task) + .where(t => t.projectId, 1) + .groupBy(t => t.ownerId) + .orderBy(t => t.ownerId) + .select(t => ({ + owner: t.ownerId, + count: aggregate.count(), + distinct: aggregate.countDistinct(t.id), + sum: aggregate.sum(t.amount), + avg: aggregate.avg(t.amount), + min: aggregate.min(t.title), + max: aggregate.max(t.title) + })); + expect(rows.map(row => [row.owner, row.count, row.distinct])).toEqual([ + [1, 2, 2], + [2, 1, 1], + [3, 1, 1] + ]); + expect(rows[2]).toEqual({ + owner: 3, + count: 1, + distinct: 1, + sum: null, + avg: null, + min: 'A', + max: 'A' + }); + await expect( + query(knex, Task) + .groupBy(t => t.ownerId) + .countValue() + ).rejects.toThrow('ungrouped'); + }); + + it('retains source state and transaction visibility', async () => { + const base = query(knex, Task) + .where(t => t.projectId, 1) + .select(t => ({ id: t.id })) + .limit(1); + const before = base.toQuery(); + expect(await base.countValue()).toBe(4); + expect(base.toQuery()).toBe(before); + await expect( + knex.transaction(async trx => { + await trx(tables.tasks).insert({ + id: 999, + owner_id: 1, + project_id: 1, + title: 'temporary', + amount: 1, + created_at: '2026-01-01' + }); + expect( + await db + .withTransaction(trx) + .tasks.where(t => t.projectId, 1) + .countValue() + ).toBe(5); + const rows = await query(knex, alias(Task, 'task')) + .where(t => t.task.id, 999) + .select(t => ({ id: t.task.id })) + .transacting(trx); + expect(rows).toEqual([{ id: 999 }]); + const bound = createQuery(knex).withTransaction(trx); + expect( + await bound(alias(Task, 'task')) + .where(t => t.task.id, 999) + .select(t => ({ id: t.task.id })) + ).toEqual([{ id: 999 }]); + throw new Error('test rollback'); + }) + ).rejects.toThrow('test rollback'); + expect(await tasks().countValue()).toBe(4); + }); + + it('does not attach aggregate DTOs to the tracked entity identity map', async () => { + const tracked = createDb( + knex, + { tasks: taskEntity }, + { tracking: true } + ); + const original = await tracked.tasks.find(105); + const rows = await tracked.tasks + .where(t => t.id, 105) + .groupBy(t => t.id) + .select(t => ({ id: t.id, title: aggregate.count() })) + .execute(); + expect(rows).toEqual([{ id: 105, title: 1 }]); + const scalarObject = await tracked.tasks + .where(t => t.id, 105) + .countValue({ + output: { parse: value => ({ id: 105, title: value }) } + }); + expect(scalarObject).toEqual({ id: 105, title: '1' }); + expect(original!.title).toBe('B'); + expect(await tracked.tasks.find(105)).toBe(original); + }); +}); + +describe('composite cursor pages', () => { + it('applies required relation filters before testing whether another page exists', async () => { + const base = () => + query(knex, Task) + .where(t => t.projectId, 1) + .joinOne({ + foreignSchema: User, + foreignQuery: query(knex, User), + localColumn: t => t.ownerId, + foreignColumn: t => t.id, + as: 'owner', + required: true + }); + const first = await base().paginateAfter({ limit: 1, orderBy }); + expect(first.data.map(t => t.id)).toEqual([105]); + expect(first.hasMore).toBe(true); + const last = await base().paginateAfter({ + limit: 1, + orderBy, + cursor: first.nextCursor + }); + expect(last.data.map(t => t.id)).toEqual([102]); + expect(last.hasMore).toBe(false); + }); + + it('does not skip tied timestamps and retains microsecond precision with projections and includes', async () => { + const base = () => + query(knex, Task) + .where(t => t.projectId, 1) + .select(t => ({ taskId: t.id })) + .joinMany({ + foreignSchema: Note, + localColumn: t => t.id, + foreignColumn: t => t.taskId, + as: 'notes' + }); + const first = await base().paginateAfter({ limit: 2, orderBy }); + expect(first.data.map(t => t.taskId)).toEqual([105, 104]); + const second = await base().paginateAfter({ + limit: 2, + orderBy, + cursor: first.nextCursor + }); + expect(second.data.map(t => t.taskId)).toEqual([103, 102]); + expect(second.hasMore).toBe(false); + expect(second.nextCursor).toBe(null); + expect(Object.keys(first.data[0]).sort()).toEqual(['notes', 'taskId']); + expect( + Buffer.from(first.nextCursor!, 'base64url').toString() + ).toContain('000002'); + }); + + it('supports mixed directions, empty pages and ordinary projections', async () => { + const mixed = [ + { column: (t: any) => t.createdAt, direction: 'desc' as const }, + { column: (t: any) => t.id, direction: 'asc' as const } + ]; + const base = () => db.tasks.where(t => t.projectId, 1); + const first = await base().paginateAfter({ limit: 2, orderBy: mixed }); + const second = await base().paginateAfter({ + limit: 2, + orderBy: mixed, + cursor: first.nextCursor + }); + expect([...first.data, ...second.data].map(t => t.id)).toEqual([ + 103, 104, 105, 102 + ]); + const empty = await db.tasks + .where(t => t.projectId, -1) + .paginateAfter({ limit: 1, orderBy }); + expect(empty).toEqual({ data: [], hasMore: false, nextCursor: null }); + const projected = await query(knex, Task) + .where(t => t.projectId, 1) + .select(t => t.id) + .paginateAfter({ limit: 2, orderBy }); + expect(Object.keys(projected.data[0])).toEqual(['id']); + }); + + it('rejects a cursor from a different sort and preserves authorization scope', async () => { + const first = await db.tasks + .where(t => t.projectId, 1) + .paginateAfter({ limit: 2, orderBy }); + await expect( + db.tasks.paginateAfter({ + limit: 2, + cursor: first.nextCursor, + orderBy: [{ column: t => t.id, direction: 'asc' }] + }) + ).rejects.toThrow('incompatible'); + const anotherProject = await db.tasks + .where(t => t.projectId, 2) + .paginateAfter({ limit: 2, cursor: first.nextCursor, orderBy }); + expect(anotherProject.data.map(t => t.id)).toEqual([101]); + }); + + it('keeps the existing single-column cursor API operational', async () => { + const page = await db.tasks + .where(t => t.projectId, 1) + .paginateAfter({ limit: 2, column: t => t.id, direction: 'desc' }); + expect(page.data.map(t => t.id)).toEqual([105, 104]); + expect(page.nextCursor).toBe('104'); + }); + + it('groups an existing OR filter before applying continuation predicates', async () => { + const base = () => + query(knex, Task) + .where(t => t.id, 105) + .orWhere(t => t.id, 104); + const first = await base().paginateAfter({ limit: 1, orderBy }); + const second = await base().paginateAfter({ + limit: 1, + orderBy, + cursor: first.nextCursor + }); + expect(first.data.map(t => t.id)).toEqual([105]); + expect(second.data.map(t => t.id)).toEqual([104]); + }); + + it('supports composite primary and unique keys without assuming an id field', async () => { + const Keyed = object({ + ownerId: number().hasColumnName('owner_id'), + id: number() + }) + .hasTableName(tables.tasks) + .hasPrimaryKey(['ownerId', 'id']); + const page = await query(knex, Keyed) + .where(t => t.ownerId, 1) + .paginateAfter({ + limit: 1, + orderBy: [ + { column: t => t.ownerId, direction: 'asc' }, + { column: t => t.id, direction: 'desc' } + ] + }); + expect(page.data[0].id).toBe(105); + const Unique = object({ id: number().unique() }).hasTableName( + tables.tasks + ); + expect( + ( + await query(knex, Unique).paginateAfter({ + limit: 1, + orderBy: [{ column: t => t.id, direction: 'desc' }] + }) + ).data[0].id + ).toBe(105); + }); + + it('retains numeric cursor precision beyond the safe integer range', async () => { + // Cursor metadata retains the exact numeric independently of the + // public projection, which does not even include the sort column. + const Exact = object({ + id: number().primaryKey(), + amount: number().decimal(24, 6) + }).hasTableName(tables.tasks); + const orderBy = [ + { column: 'amount' as const, direction: 'desc' as const }, + { column: 'id' as const, direction: 'desc' as const } + ]; + const base = () => + query(knex, Exact) + .whereIn(t => t.id, [102, 104, 105]) + .select(t => ({ id: t.id })); + const first = await base().paginateAfter({ limit: 1, orderBy }); + const second = await base().paginateAfter({ + limit: 2, + orderBy, + cursor: first.nextCursor + }); + expect(first.data).toEqual([{ id: 102 }]); + expect(second.data).toEqual([{ id: 104 }, { id: 105 }]); + expect( + Buffer.from(first.nextCursor!, 'base64url').toString() + ).toContain('9007199254740993.000001'); + }); +}); diff --git a/libs/knex-schema/package.json b/libs/knex-schema/package.json index d7bb3a5a..e65e3c6f 100644 --- a/libs/knex-schema/package.json +++ b/libs/knex-schema/package.json @@ -12,7 +12,8 @@ }, "description": "Type-safe schema-driven query builder for Knex using @cleverbrush/schema — strongly typed CRUD, eager loading, and column mapping for PostgreSQL", "files": [ - "dist" + "dist", + "COMPOSABLE_QUERIES.md" ], "homepage": "https://docs.cleverbrush.com/knex-schema", "keywords": [ diff --git a/libs/knex-schema/src/SchemaQueryBuilder.ts b/libs/knex-schema/src/SchemaQueryBuilder.ts index 81e6fd08..cc7b97c4 100644 --- a/libs/knex-schema/src/SchemaQueryBuilder.ts +++ b/libs/knex-schema/src/SchemaQueryBuilder.ts @@ -7,8 +7,26 @@ import { type ObjectSchemaBuilder } from '@cleverbrush/schema'; import type { Knex } from 'knex'; +import { + AliasedQueryBuilder, + type AliasTables, + isTableAlias, + type TableAlias +} from './aliased-query.js'; import { buildColumnMap } from './columns.js'; +import type { + AggregateOptions, + AggregateResult, + ExtremumResult, + ExtremumValue, + OutputSchema +} from './expressions.js'; import { getTableName, POLYMORPHIC_TYPE_BRAND } from './extension.js'; +import { scalarAggregate } from './operations/aggregate.js'; +import { + type CompositeCursorOptions, + compositeCursor +} from './operations/composite-cursor.js'; // Operations import { avgImpl, @@ -147,10 +165,22 @@ type QueryResultType = TLocalSchema extends { // SchemaQueryBuilder // --------------------------------------------------------------------------- +/** + * Build schema-aware SQL with mapped columns, projections and eager relations. + * Fluent configuration methods mutate this builder; create a fresh query for each + * independent operation. Await the builder or call execute() to obtain mapped rows. + * Use query() to infer both the schema and result types automatically. + */ export class SchemaQueryBuilder< TLocalSchema extends ObjectSchemaBuilder, TResult > { + /** + * Create a query over the schema's configured table. + * @param knex - Database connection or transaction used to execute the query. + * @param localSchema - Schema containing property/column and relation metadata. + * @param baseQuery - Optional existing Knex query to configure; it is not cloned. + */ constructor( knex: Knex, localSchema: TLocalSchema, @@ -166,6 +196,9 @@ export class SchemaQueryBuilder< explicitSelects: null, selectionMode: null, appliedProjection: null, + projectionColumns: null, + projectionDecoders: {}, + hiddenColumns: new Set(), includeDeleted: false, onlyDeleted: false, skipDefaultScope: false, @@ -181,46 +214,242 @@ export class SchemaQueryBuilder< // SELECT / DISTINCT / AGGREGATES // ======================================================================= + /** + * Choose columns, or use an object selector to infer a flat result shape. + * Object values can be schema descriptors or aggregate expressions. Column-list + * selection retains the existing result type; raw SQL cannot infer a new shape. + * @returns This builder, narrowed to the object projection when one is supplied. + */ select(...columns: (ColumnRef | Knex.Raw)[]): this; + /** + * Choose columns, or use an object selector to infer a flat result shape. + * Object values can be schema descriptors or aggregate expressions. Column-list + * selection retains the existing result type; raw SQL cannot infer a new shape. + * @returns This builder, narrowed to the object projection when one is supplied. + */ select>( selector: TSel ): SchemaQueryBuilder>>; + /** + * Choose columns, or use an object selector to infer a flat result shape. + * Object values can be schema descriptors or aggregate expressions. Column-list + * selection retains the existing result type; raw SQL cannot infer a new shape. + * @returns This builder, narrowed to the object projection when one is supplied. + */ select(...args: unknown[]): any { return selectImpl(this as any, ...args); } + /** + * Apply SQL DISTINCT to the selected columns, optionally adding columns. + * Property selectors are mapped to database names; SQL decides row equality. + */ distinct(...columns: (ColumnRef | Knex.Raw)[]): this { return (distinctImpl as any)(this, ...columns); } + /** + * Append a legacy SQL COUNT selection without executing the query. + * The driver controls the result shape/value type. Prefer countValue() for a + * checked scalar number, or aggregate.count() in a typed object projection. + */ count(column?: ColumnRef | Knex.Raw): this { return (countImpl as any)(this, column); } + /** + * Append a legacy COUNT(DISTINCT column) selection. + * Prefer countDistinctValue() for a checked scalar or aggregate.countDistinct() + * for an inferred grouped result; this legacy method retains the builder type. + */ countDistinct(column?: ColumnRef | Knex.Raw): this { return (countDistinctImpl as any)(this, column); } + /** + * Append a legacy MIN selection without changing the result type. + * Use minValue() for a scalar with explicit decoding, or aggregate.min() in a + * typed projection. SQL returns null for an empty/all-null input. + */ min(column: ColumnRef | Knex.Raw): this { return (minImpl as any)(this, column); } + /** + * Append a legacy MAX selection without changing the result type. + * Use maxValue() for a scalar with explicit decoding, or aggregate.max() in a + * typed projection. SQL returns null for an empty/all-null input. + */ max(column: ColumnRef | Knex.Raw): this { return (maxImpl as any)(this, column); } + /** + * Append a legacy SUM selection, leaving numeric conversion to the driver. + * Prefer sumValue() or aggregate.sum() to preserve exact numeric text by default. + */ sum(column: ColumnRef | Knex.Raw): this { return (sumImpl as any)(this, column); } + /** + * Append a legacy AVG selection, leaving numeric conversion to the driver. + * Prefer avgValue() or aggregate.avg() for a typed, precision-preserving result. + */ avg(column: ColumnRef | Knex.Raw): this { return (avgImpl as any)(this, column); } + /** + * Count matching rows, or non-null column values, without mutating this query. + * Ignores source ordering/limits/offsets while retaining filters and transactions. + * @param options - Optional output parser; receives the raw driver value. + * @returns A safe integer by default, including zero for an empty source. + * @throws If the default count overflows, the source is grouped/distinct, or parsing fails. + */ + countValue | undefined = undefined>( + options?: AggregateOptions + ): Promise>; + /** + * Count non-null column values without mutating the source query. + * Source paging/order is ignored; filters, scopes and transactions remain. + * @param column - Mapped schema property to count. + * @param options - Optional parser replacing safe-integer decoding. + * @throws If the default result overflows or the source is grouped/distinct. + */ + countValue | undefined = undefined>( + column: ColumnRef, + options?: AggregateOptions + ): Promise>; + /** + * Count matching rows, or non-null column values, without mutating this query. + * Ignores source ordering/limits/offsets while retaining filters and transactions. + * @param options - Optional output parser; receives the raw driver value. + * @returns A safe integer by default, including zero for an empty source. + * @throws If the default count overflows, the source is grouped/distinct, or parsing fails. + */ + countValue( + columnOrOptions?: ColumnRef | AggregateOptions, + options?: AggregateOptions + ): Promise { + const hasColumn = + typeof columnOrOptions === 'string' || + typeof columnOrOptions === 'function'; + return scalarAggregate( + this, + 'count', + hasColumn ? columnOrOptions : undefined, + hasColumn ? options : columnOrOptions + ); + } + + /** + * Count distinct non-null values in an unpaginated clone of this query. + * @param column - Schema property to count; SQL nulls do not contribute. + * @param options - Optional parser replacing default safe-integer conversion. + * @returns A safe integer, or the parser's inferred output type. + * @throws If the count is unsafe, the source is grouped/distinct, or parsing fails. + */ + countDistinctValue | undefined = undefined>( + column: ColumnRef, + options?: AggregateOptions + ): Promise> { + return scalarAggregate(this, 'countDistinct', column, options); + } + + /** + * Sum non-null values in an unpaginated clone, preserving numeric precision. + * @param column - Numeric schema property to sum. + * @param options - Optional parser receiving the raw driver value, including null. + * @returns Database numeric text, or null for empty/all-null input by default. + * An output parser replaces default decoding and controls the result type. + */ + sumValue | undefined = undefined>( + column: ColumnRef, + options?: AggregateOptions + ): Promise> { + return scalarAggregate(this, 'sum', column, options); + } + + /** + * Average non-null values in an unpaginated clone of this query. + * @param column - Numeric schema property to aggregate. + * @param options - Optional parser receiving the raw driver result, including null. + * @returns Exact database numeric text or null by default; a parser overrides this. + * @remarks Text preserves database precision, not precision already lost in floating-point storage. + */ + avgValue | undefined = undefined>( + column: ColumnRef, + options?: AggregateOptions + ): Promise> { + return scalarAggregate(this, 'avg', column, options); + } + + /** + * Find the smallest non-null column value without retaining source paging. + * @param column - Schema property to aggregate. + * @param options - Optional parser replacing default decoding, including null handling. + * @returns Null for empty/all-null input; otherwise the column representation. + * Dates return Date; numeric SQL overrides may return exact strings. + */ + minValue< + C extends ColumnRef, + S extends OutputSchema | undefined = undefined + >( + column: C, + options?: AggregateOptions + ): Promise< + AggregateResult< + S, + C extends (...args: any[]) => infer D + ? ExtremumValue + : C extends keyof InferType + ? ExtremumResult[C]> + : unknown + > + > { + return scalarAggregate(this, 'min', column, options); + } + + /** + * Find the largest non-null column value without retaining source paging. + * @param column - Schema property to aggregate. + * @param options - Optional parser replacing default decoding, including null handling. + * @returns Null for empty/all-null input; otherwise the column representation. + * Dates return Date; numeric SQL overrides may return exact strings. + */ + maxValue< + C extends ColumnRef, + S extends OutputSchema | undefined = undefined + >( + column: C, + options?: AggregateOptions + ): Promise< + AggregateResult< + S, + C extends (...args: any[]) => infer D + ? ExtremumValue + : C extends keyof InferType + ? ExtremumResult[C]> + : unknown + > + > { + return scalarAggregate(this, 'max', column, options); + } + + /** + * Append a raw SELECT expression with optional Knex value/identifier bindings. + * The caller owns its SQL and result shape; this does not infer a new result type. + */ selectRaw(sql: string, bindings?: any[]): this { return selectRawImpl(this as any, sql, bindings); } + /** + * Apply a named schema projection and narrow the selected property type. + * @param name - Projection registered with the schema's projection() extension. + * @throws If the projection is unknown or conflicts with a prior selection. + */ projected & string>( name: K ): SchemaQueryBuilder< @@ -230,10 +459,19 @@ export class SchemaQueryBuilder< return projectedImpl(this as any, name); } + /** + * Apply a named schema scope to this query. + * @param name - Scope registered with the schema's scope() extension. + * @throws If the requested scope is not registered. + */ scoped>(name: K): this { return scopedImpl(this as any, name as string); } + /** + * Disable the default read scope and include soft-deleted rows. + * Explicit filters already added to this builder remain in place. + */ unscoped(): this { return unscopedImpl(this as any); } @@ -242,55 +480,173 @@ export class SchemaQueryBuilder< // WHERE // ======================================================================= + /** + * Add an AND filter using mapped schema columns, a record, or raw Knex SQL. + * Selector/key and record forms map property names to database columns. Grouped + * callbacks receive a Knex builder and therefore use database column names. + * Values are bound; use the operator form for comparisons other than equality. + */ where(column: ColumnRef, operator: string, value: any): this; + /** + * Add an AND filter using mapped schema columns, a record, or raw Knex SQL. + * Selector/key and record forms map property names to database columns. Grouped + * callbacks receive a Knex builder and therefore use database column names. + * Values are bound; use the operator form for comparisons other than equality. + */ where(column: ColumnRef, value: any): this; + /** + * Add an AND filter using mapped schema columns, a record, or raw Knex SQL. + * Selector/key and record forms map property names to database columns. Grouped + * callbacks receive a Knex builder and therefore use database column names. + * Values are bound; use the operator form for comparisons other than equality. + */ where(raw: Knex.Raw, operator: string, value: any): this; + /** + * Add an AND filter using mapped schema columns, a record, or raw Knex SQL. + * Selector/key and record forms map property names to database columns. Grouped + * callbacks receive a Knex builder and therefore use database column names. + * Values are bound; use the operator form for comparisons other than equality. + */ where(callback: (builder: Knex.QueryBuilder) => void): this; + /** + * Add an AND filter using mapped schema columns, a record, or raw Knex SQL. + * Selector/key and record forms map property names to database columns. Grouped + * callbacks receive a Knex builder and therefore use database column names. + * Values are bound; use the operator form for comparisons other than equality. + */ where(record: Record): this; + /** + * Add an AND filter using mapped schema columns, a record, or raw Knex SQL. + * Selector/key and record forms map property names to database columns. Grouped + * callbacks receive a Knex builder and therefore use database column names. + * Values are bound; use the operator form for comparisons other than equality. + */ where(raw: Knex.Raw): this; + /** + * Add an AND filter using mapped schema columns, a record, or raw Knex SQL. + * Selector/key and record forms map property names to database columns. Grouped + * callbacks receive a Knex builder and therefore use database column names. + * Values are bound; use the operator form for comparisons other than equality. + */ where(columnOrRaw: any, ...args: any[]): this { return whereImpl(this as any, columnOrRaw, ...args); } + /** + * Add an AND condition; an explicit synonym for where(). + * Property references and record keys are mapped; raw callbacks use Knex columns. + */ andWhere( column: ColumnRef, operator: string, value: any ): this; + /** + * Add an AND condition; an explicit synonym for where(). + * Property references and record keys are mapped; raw callbacks use Knex columns. + */ andWhere(column: ColumnRef, value: any): this; + /** + * Add an AND condition; an explicit synonym for where(). + * Property references and record keys are mapped; raw callbacks use Knex columns. + */ andWhere(record: Record): this; + /** + * Add an AND condition; an explicit synonym for where(). + * Property references and record keys are mapped; raw callbacks use Knex columns. + */ andWhere(callback: (builder: Knex.QueryBuilder) => void): this; + /** + * Add an AND condition; an explicit synonym for where(). + * Property references and record keys are mapped; raw callbacks use Knex columns. + */ andWhere(raw: Knex.Raw): this; + /** + * Add an AND condition; an explicit synonym for where(). + * Property references and record keys are mapped; raw callbacks use Knex columns. + */ andWhere(columnOrRaw: any, ...args: any[]): this { return andWhereImpl(this as any, columnOrRaw, ...args); } + /** + * Add an OR condition using a mapped property, record, raw SQL or Knex group. + * Group mixed AND/OR conditions explicitly when SQL precedence would change intent. + */ orWhere( column: ColumnRef, operator: string, value: any ): this; + /** + * Add an OR condition using a mapped property, record, raw SQL or Knex group. + * Group mixed AND/OR conditions explicitly when SQL precedence would change intent. + */ orWhere(column: ColumnRef, value: any): this; + /** + * Add an OR condition using a mapped property, record, raw SQL or Knex group. + * Group mixed AND/OR conditions explicitly when SQL precedence would change intent. + */ orWhere(record: Record): this; + /** + * Add an OR condition using a mapped property, record, raw SQL or Knex group. + * Group mixed AND/OR conditions explicitly when SQL precedence would change intent. + */ orWhere(callback: (builder: Knex.QueryBuilder) => void): this; + /** + * Add an OR condition using a mapped property, record, raw SQL or Knex group. + * Group mixed AND/OR conditions explicitly when SQL precedence would change intent. + */ orWhere(raw: Knex.Raw): this; + /** + * Add an OR condition using a mapped property, record, raw SQL or Knex group. + * Group mixed AND/OR conditions explicitly when SQL precedence would change intent. + */ orWhere(columnOrRaw: any, ...args: any[]): this { return orWhereImpl(this as any, columnOrRaw, ...args); } + /** + * Add a negated condition using a mapped property, record or Knex group. + * Use whereNotIn()/whereNotBetween() for their dedicated SQL operators. + */ whereNot( column: ColumnRef, operator: string, value: any ): this; + /** + * Add a negated condition using a mapped property, record or Knex group. + * Use whereNotIn()/whereNotBetween() for their dedicated SQL operators. + */ whereNot(column: ColumnRef, value: any): this; + /** + * Add a negated condition using a mapped property, record or Knex group. + * Use whereNotIn()/whereNotBetween() for their dedicated SQL operators. + */ whereNot(record: Record): this; + /** + * Add a negated condition using a mapped property, record or Knex group. + * Use whereNotIn()/whereNotBetween() for their dedicated SQL operators. + */ whereNot(callback: (builder: Knex.QueryBuilder) => void): this; + /** + * Add a negated condition using a mapped property, record or Knex group. + * Use whereNotIn()/whereNotBetween() for their dedicated SQL operators. + */ whereNot(raw: Knex.Raw): this; + /** + * Add a negated condition using a mapped property, record or Knex group. + * Use whereNotIn()/whereNotBetween() for their dedicated SQL operators. + */ whereNot(columnOrRaw: any, ...args: any[]): this { return whereNotImpl(this as any, columnOrRaw, ...args); } + /** + * Require the mapped column to match a value list or a single-column subquery. + * An empty list matches no rows. Subqueries use Knex's database column names. + */ whereIn( column: ColumnRef, values: readonly any[] | Knex.QueryBuilder @@ -298,6 +654,10 @@ export class SchemaQueryBuilder< return (whereInImpl as any)(this, column, values); } + /** + * Exclude values returned by a list or single-column subquery. + * SQL null semantics apply; a null in the set is not equivalent to a missing value. + */ whereNotIn( column: ColumnRef, values: readonly any[] | Knex.QueryBuilder @@ -305,6 +665,9 @@ export class SchemaQueryBuilder< return (whereNotInImpl as any)(this, column, values); } + /** + * Add an OR membership condition against a list or single-column subquery. + */ orWhereIn( column: ColumnRef, values: readonly any[] | Knex.QueryBuilder @@ -312,6 +675,9 @@ export class SchemaQueryBuilder< return (orWhereInImpl as any)(this, column, values); } + /** + * Add an OR non-membership condition; SQL NOT IN null semantics apply. + */ orWhereNotIn( column: ColumnRef, values: readonly any[] | Knex.QueryBuilder @@ -319,22 +685,37 @@ export class SchemaQueryBuilder< return (orWhereNotInImpl as any)(this, column, values); } + /** + * Add an AND IS NULL condition for a mapped schema property. + */ whereNull(column: ColumnRef): this { return (whereNullImpl as any)(this, column); } + /** + * Add an AND IS NOT NULL condition for a mapped schema property. + */ whereNotNull(column: ColumnRef): this { return (whereNotNullImpl as any)(this, column); } + /** + * Add an OR IS NULL condition for a mapped schema property. + */ orWhereNull(column: ColumnRef): this { return (orWhereNullImpl as any)(this, column); } + /** + * Add an OR IS NOT NULL condition for a mapped schema property. + */ orWhereNotNull(column: ColumnRef): this { return (orWhereNotNullImpl as any)(this, column); } + /** + * Require the mapped column to lie within an inclusive [lower, upper] range. + */ whereBetween( column: ColumnRef, range: readonly [any, any] @@ -342,6 +723,9 @@ export class SchemaQueryBuilder< return (whereBetweenImpl as any)(this, column, range); } + /** + * Exclude the inclusive [lower, upper] range from a mapped column. + */ whereNotBetween( column: ColumnRef, range: readonly [any, any] @@ -349,26 +733,51 @@ export class SchemaQueryBuilder< return (whereNotBetweenImpl as any)(this, column, range); } + /** + * Match a mapped column against a SQL LIKE pattern. + * Percent and underscore remain wildcards; values are bound, not wildcard-escaped. + */ whereLike(column: ColumnRef, value: string): this { return (whereLikeImpl as any)(this, column, value); } + /** + * Match a mapped column using PostgreSQL's case-insensitive ILIKE operator. + * Percent and underscore remain pattern wildcards. + */ whereILike(column: ColumnRef, value: string): this { return (whereILikeImpl as any)(this, column, value); } + /** + * Append raw WHERE SQL with Knex bindings. Never interpolate untrusted values. + * Raw SQL uses database names and is outside schema-level result/type checking. + */ whereRaw(sql: string, ...bindings: any[]): this { return (whereRawImpl as any)(this, sql, ...bindings); } + /** + * Add an EXISTS filter from a Knex subquery or query-building callback. + * Use qualified database columns to correlate it with the parent query. + */ whereExists(callback: Knex.QueryCallback | Knex.QueryBuilder): this { return (whereExistsImpl as any)(this, callback); } + /** + * Add a NOT EXISTS filter from a Knex subquery or query-building callback. + */ whereNotExists(callback: Knex.QueryCallback | Knex.QueryBuilder): this { return (whereNotExistsImpl as any)(this, callback); } + /** + * Compare a JSON-path value inside a mapped JSON column. + * @param path - JSON path understood by the Knex database dialect. + * @param operator - SQL comparison operator forwarded to Knex. + * @param value - Bound comparison value. + */ whereJsonPath( column: ColumnRef, path: string, @@ -382,6 +791,10 @@ export class SchemaQueryBuilder< // ORDER BY // ======================================================================= + /** + * Append ordering by a mapped property or raw expression (ascending by default). + * Add a unique tie-breaker for stable pages. Eager loading retains parent order. + */ orderBy( column: ColumnRef | Knex.Raw, direction?: 'asc' | 'desc' @@ -389,6 +802,10 @@ export class SchemaQueryBuilder< return (orderByImpl as any)(this, column, direction); } + /** + * Append a raw ORDER BY expression with Knex bindings. + * Use database column names; parent ordering is retained during eager loading. + */ orderByRaw(sql: string, ...bindings: any[]): this { return (orderByRawImpl as any)(this, sql, ...bindings); } @@ -397,14 +814,24 @@ export class SchemaQueryBuilder< // GROUP BY / HAVING // ======================================================================= + /** + * Group rows by mapped schema properties or raw expressions. + * Combine with aggregate expressions in select() to infer grouped DTO results. + */ groupBy(...columns: (ColumnRef | Knex.Raw)[]): this { return (groupByImpl as any)(this, ...columns); } + /** + * Append raw GROUP BY SQL with optional Knex bindings. + */ groupByRaw(sql: string, ...bindings: any[]): this { return (groupByRawImpl as any)(this, sql, ...bindings); } + /** + * Filter SQL groups by a mapped column/raw expression, operator and bound value. + */ having( column: ColumnRef | Knex.Raw, operator: string, @@ -413,6 +840,9 @@ export class SchemaQueryBuilder< return (havingImpl as any)(this, column, operator, value); } + /** + * Append raw HAVING SQL with Knex bindings, for example aggregate comparisons. + */ havingRaw(sql: string, ...bindings: any[]): this { return havingRawImpl(this as any, sql, ...bindings); } @@ -421,16 +851,31 @@ export class SchemaQueryBuilder< // PAGINATION // ======================================================================= + /** + * Set the maximum number of parent rows to select; mutates this query. + * Included collections do not consume the parent limit. + */ limit(n: number): this { return limitImpl(this as any, n); } + /** + * Skip this many parent rows before applying the limit; mutates this query. + * Use deterministic ordering when navigating offset-based pages. + */ offset(n: number): this { return offsetImpl(this as any, n); } + /** + * Execute a one-based offset page and a separate matching-source count query. + * Mutates this builder's limit/offset and returns mapped rows plus page metadata. + * The count and page are separate reads, not a snapshot unless your transaction provides one. + */ async paginate(opts: { + /** One-based page number. */ page: number; + /** Maximum parent rows in a page. */ pageSize: number; }): Promise> { return paginateImpl(this as any, opts) as Promise< @@ -438,12 +883,48 @@ export class SchemaQueryBuilder< >; } - async paginateAfter(opts: { + /** + * Read a cursor page without running a total-count query. + * The orderBy form clones the source and preserves exact composite sort values; + * its non-null sort must contain a declared unique key. The legacy column form + * mutates this builder and defaults to id descending. Reapply access filters on + * every request: cursors are positions, not authorization or snapshots. + * @returns Mapped data, hasMore, and a nextCursor that is null on the last page. + * @throws For invalid composite cursors or unsupported composite query shapes. + */ + paginateAfter( + opts: CompositeCursorOptions + ): Promise>; + /** + * Read a cursor page without running a total-count query. + * The orderBy form clones the source and preserves exact composite sort values; + * its non-null sort must contain a declared unique key. The legacy column form + * mutates this builder and defaults to id descending. Reapply access filters on + * every request: cursors are positions, not authorization or snapshots. + * @returns Mapped data, hasMore, and a nextCursor that is null on the last page. + * @throws For invalid composite cursors or unsupported composite query shapes. + */ + paginateAfter(opts: { + /** Previous raw single-column position; omit for the first page. */ cursor?: any; + /** Maximum parent rows to return; one extra row determines hasMore. */ limit: number; + /** Unique sort property; defaults to id in the legacy API. */ column?: ColumnRef; + /** Sort/continuation direction; defaults to descending. */ direction?: 'asc' | 'desc'; - }): Promise> { + }): Promise>; + /** + * Read a cursor page without running a total-count query. + * The orderBy form clones the source and preserves exact composite sort values; + * its non-null sort must contain a declared unique key. The legacy column form + * mutates this builder and defaults to id descending. Reapply access filters on + * every request: cursors are positions, not authorization or snapshots. + * @returns Mapped data, hasMore, and a nextCursor that is null on the last page. + * @throws For invalid composite cursors or unsupported composite query shapes. + */ + async paginateAfter(opts: any): Promise> { + if ('orderBy' in opts) return compositeCursor(this as any, opts); return (paginateAfterImpl as any)(this, opts) as Promise< CursorPaginationResult >; @@ -453,14 +934,26 @@ export class SchemaQueryBuilder< // WRITE OPERATIONS // ======================================================================= + /** + * Insert one schema-shaped row and return its mapped database representation. + * Applies configured insert hooks, column mappings and timestamp defaults. + */ async insert(data: InsertType): Promise { return insertImpl(this as any, data) as Promise; } + /** + * Insert an array of schema-shaped rows and return their mapped representations. + * Returns an empty array for empty input; use bulkInsert() to control chunking. + */ async insertMany(data: InsertType[]): Promise { return insertManyImpl(this as any, data) as Promise; } + /** + * Configure an upsert conflict target using mapped properties. + * Call merge() or ignore() on the returned builder to insert the row. + */ onConflict( ...conflictColumns: ColumnRef[] ): import('./operations/insert.js').OnConflictBuilder< @@ -470,31 +963,53 @@ export class SchemaQueryBuilder< return (onConflictImpl as any)(this, ...conflictColumns); } + /** + * Insert one row or update it when the chosen conflict target already exists. + * @param opts - Conflict properties and optional subset of properties to update. + * @returns The inserted or updated row mapped to schema property names. + */ async upsert( data: InsertType, opts: { + /** Properties identifying an existing row on conflict. */ conflictColumns: ColumnRef[]; + /** Properties to update on conflict; omit to merge insert values. */ updateColumns?: ColumnRef[]; } ): Promise { return (upsertImpl as any)(this, data, opts); } + /** + * Insert rows in chunks, optionally ignoring or merging conflicts. + * @param opts - Chunk size (default 500), conflict policy and conflict properties. + * @returns Mapped rows returned by PostgreSQL; ignored conflicts produce no row. + */ async bulkInsert( rows: InsertType[], opts?: { + /** Requested rows per statement, capped by parameter limits; default 500. */ chunkSize?: number; + /** Optional PostgreSQL conflict policy applied to each chunk. */ onConflict?: 'ignore' | 'merge'; + /** Conflict target properties when a conflict policy is supplied. */ conflictColumns?: ColumnRef[]; } ): Promise { return (bulkInsertImpl as any)(this, rows, opts); } + /** + * Insert/update rows in chunks using the specified conflict properties. + * @param opts - Required conflict target and optional chunk size (default 500). + * @returns The database rows produced by each chunk, mapped to schema properties. + */ async bulkUpsert( rows: InsertType[], opts: { + /** Properties identifying an existing row on conflict. */ conflictColumns: ColumnRef[]; + /** Requested rows per statement, capped by parameter limits; default 500. */ chunkSize?: number; } ): Promise { @@ -505,13 +1020,24 @@ export class SchemaQueryBuilder< // UPDATE // ======================================================================= + /** + * Update rows matching this query's explicit filters and return mapped rows. + * Applies update hooks and timestamp metadata. Add a WHERE clause to avoid a + * table-wide update; this method does not track entity identity. + */ async update(data: Partial>): Promise { return updateImpl(this as any, data) as Promise; } + /** + * Apply per-row where/set pairs and return the total affected-row count. + * Maps both filter and update property names and applies configured update hooks. + */ async bulkUpdate( updates: ReadonlyArray<{ + /** Equality filters identifying the rows for this update. */ where: Partial>; + /** Schema properties to assign to those rows. */ set: Partial>; }> ): Promise { @@ -522,22 +1048,42 @@ export class SchemaQueryBuilder< // DELETE / SOFT DELETE // ======================================================================= + /** + * Delete rows matching explicit filters and return the affected-row count. + * Runs beforeDelete hooks; with soft-delete metadata it sets the deletion timestamp + * instead of removing rows. Add filters to avoid a table-wide write. + */ async delete(): Promise { return deleteImpl(this as any); } + /** + * Include soft-deleted rows in read results without removing explicit filters. + */ withDeleted(): this { return withDeletedImpl(this as any); } + /** + * Restrict reads to rows whose configured soft-delete column is non-null. + */ onlyDeleted(): this { return onlyDeletedImpl(this as any); } + /** + * Permanently delete rows matching explicit filters, even on a soft-delete schema. + * Runs beforeDelete hooks and returns the affected count. This cannot be undone + * without a transaction rollback or backup. + */ async hardDelete(): Promise { return hardDeleteImpl(this as any); } + /** + * Clear the deletion timestamp on rows matching explicit filters and return them. + * @throws If the schema has no soft-delete configuration. + */ async restore(): Promise { return restoreImpl(this as any) as Promise; } @@ -546,6 +1092,11 @@ export class SchemaQueryBuilder< // EAGER LOADING (JOIN) // ======================================================================= + /** + * Eager-load a related object under spec.as using mapped join columns. + * Required joins remove unmatched parents; optional joins return null. Unlike a + * flat join, related fields remain nested and are mapped with the foreign schema. + */ joinOne< TForeignSchema extends ObjectSchemaBuilder< any, @@ -572,6 +1123,10 @@ export class SchemaQueryBuilder< return joinOneImpl(this as any, spec); } + /** + * Eager-load a nested array without multiplying parent rows. + * The relation's own order/limit/offset controls children separately from parent paging. + */ joinMany< TForeignSchema extends ObjectSchemaBuilder< any, @@ -592,6 +1147,11 @@ export class SchemaQueryBuilder< return joinManyImpl(this as any, spec); } + /** + * Eager-load a relation registered on the schema by name. + * @param customize - Optional callback configuring the related query. + * Use an ORM DbSet when you need typed relation names and customization fields. + */ include( relationName: string, customize?: (q: SchemaQueryBuilder) => void @@ -599,6 +1159,11 @@ export class SchemaQueryBuilder< return includeImpl(this as any, relationName, customize); } + /** + * Eager-load a relation declared for one polymorphic discriminator value. + * Other variants are not populated with this relation. Use ORM entity declarations + * to retain known relation customization types. + */ includeVariant( variantKey: string, relationName: string, @@ -616,6 +1181,11 @@ export class SchemaQueryBuilder< // POLYMORPHIC VARIANTS // ======================================================================= + /** + * Add a filter applying only to the named polymorphic branch. + * Other discriminator values remain eligible. Maps the variant property to its + * CTI/STI storage column; throws for unknown variants or unsupported operators. + */ whereVariant( key: string, column: string, @@ -661,6 +1231,11 @@ export class SchemaQueryBuilder< return this; } + /** + * Choose which polymorphic variant bodies are loaded. + * This controls variant joins/selection, not a discriminator filter on base rows. + * @throws If the schema is not polymorphic. + */ selectVariants(keys: string[]): this { const state = getState(this); if (!getVariantConfig(this)) { @@ -677,6 +1252,11 @@ export class SchemaQueryBuilder< // ESCAPE HATCH // ======================================================================= + /** + * Configure the underlying mutable Knex query as an escape hatch. + * Raw changes do not infer a new result type; the caller owns column names, + * result shape and cardinality introduced by the callback. + */ apply(fn: (builder: Knex.QueryBuilder) => void): this { const state = getState(this); invalidateCache(this); @@ -688,6 +1268,10 @@ export class SchemaQueryBuilder< // TRANSACTION // ======================================================================= + /** + * Clone this query and its eager-relation queries onto an existing transaction. + * The source builder is unchanged; transaction commit/rollback stays with the caller. + */ transacting( trx: Knex.Transaction ): SchemaQueryBuilder { @@ -709,6 +1293,11 @@ export class SchemaQueryBuilder< : null; builderState.selectionMode = state.selectionMode; builderState.appliedProjection = state.appliedProjection; + builderState.projectionColumns = state.projectionColumns + ? { ...state.projectionColumns } + : null; + builderState.projectionDecoders = { ...state.projectionDecoders }; + builderState.hiddenColumns = new Set(state.hiddenColumns); builderState.includeDeleted = state.includeDeleted; builderState.onlyDeleted = state.onlyDeleted; builderState.skipDefaultScope = state.skipDefaultScope; @@ -728,22 +1317,48 @@ export class SchemaQueryBuilder< // EXECUTION // ======================================================================= + /** + * Render SQL for debugging without executing it. + * Bindings may appear as literal values; avoid logging sensitive inputs. + */ toQuery(): string { return getQuery(this).toQuery(); } + /** @internal ORM tracking must never attach aggregate or DTO rows as entities. */ + get returnsEntityRows(): boolean { + return getState(this).selectionMode === null; + } + + /** + * Expose the built Knex query without executing Framework row mapping. + * Direct execution returns raw database rows, potentially including internal fields. + * Treat mutations as an escape hatch rather than typed Framework configuration. + */ toKnexQuery(): Knex.QueryBuilder { return getQuery(this); } + /** + * Render the query as a debugging SQL string; equivalent to toQuery(). + */ toString(): string { return getQuery(this).toString(); } + /** + * Execute SQL and map all rows, including eager relations and aggregate decoders. + * @returns An empty array when no rows match. + * @throws Database errors and output-parser validation failures. + */ async execute(): Promise { return executeImpl(this) as Promise; } + /** + * Execute this query for its first mapped row, or undefined if none matches. + * Set ordering when the choice of first row matters. + */ async first(): Promise { const query = getQuery(this).first(); const row = await query; @@ -752,6 +1367,10 @@ export class SchemaQueryBuilder< return cleanAndMapRow(this, row) as TResult; } + /** + * Execute a selection and collect one mapped column's values into an array. + * @param column - Schema property whose database column should be read. + */ async pluck( column: ColumnRef ): Promise { @@ -763,6 +1382,10 @@ export class SchemaQueryBuilder< ) as TResult[K][]; } + /** + * Promise-compatible execution hook enabling await query(...). + * Runs execute() and forwards fulfillment/rejection; repeated awaits may execute again. + */ // biome-ignore lint/suspicious/noThenProperty: intentional thenable then( onfulfilled?: @@ -781,6 +1404,26 @@ registerSchemaQueryBuilder(SchemaQueryBuilder); // query() — main entry point // --------------------------------------------------------------------------- +/** + * Create a schema-aware query while preserving its schema and inferred row type. + * Pass a table alias for a flat multi-table query requiring an explicit projection; + * pass an ordinary schema for nested eager loading and schema-aware writes. + * @param knex - Knex connection or transaction. + * @param schema - Table schema or immutable alias(schema, name). + * @returns The appropriately typed, unexecuted query builder. + */ +export function query< + S extends ObjectSchemaBuilder, + N extends string +>(knex: Knex, schema: TableAlias): AliasedQueryBuilder>; +/** + * Create a schema-aware query while preserving its schema and inferred row type. + * Pass a table alias for a flat multi-table query requiring an explicit projection; + * pass an ordinary schema for nested eager loading and schema-aware writes. + * @param knex - Knex connection or transaction. + * @param schema - Table schema or immutable alias(schema, name). + * @returns The appropriately typed, unexecuted query builder. + */ export function query< TLocalSchema extends ObjectSchemaBuilder >( @@ -788,6 +1431,14 @@ export function query< schema: TLocalSchema ): SchemaQueryBuilder>; +/** + * Create a schema-aware query while preserving its schema and inferred row type. + * Pass a table alias for a flat multi-table query requiring an explicit projection; + * pass an ordinary schema for nested eager loading and schema-aware writes. + * @param knex - Knex connection or transaction. + * @param schema - Table schema or immutable alias(schema, name). + * @returns The appropriately typed, unexecuted query builder. + */ export function query< TLocalSchema extends ObjectSchemaBuilder >( @@ -796,14 +1447,27 @@ export function query< baseQuery: Knex.QueryBuilder ): SchemaQueryBuilder>; +/** + * Create a schema-aware query while preserving its schema and inferred row type. + * Pass a table alias for a flat multi-table query requiring an explicit projection; + * pass an ordinary schema for nested eager loading and schema-aware writes. + * @param knex - Knex connection or transaction. + * @param schema - Table schema or immutable alias(schema, name). + * @returns The appropriately typed, unexecuted query builder. + */ export function query< - TLocalSchema extends ObjectSchemaBuilder + S extends ObjectSchemaBuilder, + N extends string >( knex: Knex, - schema: TLocalSchema, + schema: S | TableAlias, baseQuery?: Knex.QueryBuilder -): SchemaQueryBuilder> { - return new SchemaQueryBuilder>( +): + | SchemaQueryBuilder> + | AliasedQueryBuilder> { + if (isTableAlias(schema)) + return new AliasedQueryBuilder>(knex, schema); + return new SchemaQueryBuilder>( knex, schema, baseQuery @@ -814,7 +1478,26 @@ export function query< // createQuery() — knex-bound factory // --------------------------------------------------------------------------- +/** + * A query factory bound to a connection or transaction. + * Ordinary schemas retain schema/result inference; aliases retain the table context. + * Use withTransaction() to reuse a transaction or transaction() to create one. + */ export interface BoundQuery { + /** + * Start a typed query on the bound connection using a schema or table alias. + * Only ordinary schema calls accept an existing Knex base query. + */ + < + S extends ObjectSchemaBuilder, + N extends string + >( + schema: TableAlias + ): AliasedQueryBuilder>; + /** + * Start a typed query on the bound connection using a schema or table alias. + * Only ordinary schema calls accept an existing Knex base query. + */ < TLocalSchema extends ObjectSchemaBuilder< any, @@ -828,6 +1511,10 @@ export interface BoundQuery { >( schema: TLocalSchema ): SchemaQueryBuilder>; + /** + * Start a typed query on the bound connection using a schema or table alias. + * Only ordinary schema calls accept an existing Knex base query. + */ < TLocalSchema extends ObjectSchemaBuilder< any, @@ -842,11 +1529,30 @@ export interface BoundQuery { schema: TLocalSchema, baseQuery: Knex.QueryBuilder ): SchemaQueryBuilder>; + /** + * Create a factory bound to an existing transaction without committing it. + */ withTransaction(trx: Knex.Transaction): BoundQuery; + /** + * Run a callback with a transaction-bound factory. + * Resolves to the callback result on commit; rejects and rolls back on failure. + */ transaction(callback: (db: BoundQuery) => Promise): Promise; } +/** + * Bind query() to a connection, retaining all schema/alias overloads. + * @param knexInstance - Connection or existing transaction to bind. + * @returns A callable factory with transaction helpers. + * @example + * const db = createQuery(knex); + * const rows = await db(TaskSchema).select(t => ({ id: t.id })); + */ export function createQuery(knexInstance: Knex): BoundQuery { + function boundQuery< + S extends ObjectSchemaBuilder, + N extends string + >(schema: TableAlias): AliasedQueryBuilder>; function boundQuery< TLocalSchema extends ObjectSchemaBuilder< any, @@ -860,22 +1566,28 @@ export function createQuery(knexInstance: Knex): BoundQuery { >( schema: TLocalSchema, baseQuery?: Knex.QueryBuilder - ): SchemaQueryBuilder> { + ): SchemaQueryBuilder>; + function boundQuery< + S extends ObjectSchemaBuilder, + N extends string + >( + schema: S | TableAlias, + baseQuery?: Knex.QueryBuilder + ): + | SchemaQueryBuilder> + | AliasedQueryBuilder> { + if (isTableAlias(schema)) return query(knexInstance, schema); return baseQuery ? query(knexInstance, schema, baseQuery) : query(knexInstance, schema); } - (boundQuery as BoundQuery).withTransaction = ( - trx: Knex.Transaction - ): BoundQuery => createQuery(trx as unknown as Knex); - - (boundQuery as BoundQuery).transaction = ( - callback: (db: BoundQuery) => Promise - ): Promise => - knexInstance.transaction(trx => - callback(createQuery(trx as unknown as Knex)) - ); - - return boundQuery as BoundQuery; + return Object.assign(boundQuery, { + withTransaction(trx: Knex.Transaction): BoundQuery { + return createQuery(trx); + }, + transaction(callback: (db: BoundQuery) => Promise): Promise { + return knexInstance.transaction(trx => callback(createQuery(trx))); + } + }) satisfies BoundQuery; } diff --git a/libs/knex-schema/src/aliased-query.ts b/libs/knex-schema/src/aliased-query.ts new file mode 100644 index 00000000..5ac2979f --- /dev/null +++ b/libs/knex-schema/src/aliased-query.ts @@ -0,0 +1,496 @@ +import type { InferType, ObjectSchemaBuilder } from '@cleverbrush/schema'; +import type { Knex } from 'knex'; +import { buildColumnMap } from './columns.js'; +import { + type AggregateExpression, + type AliasedColumn, + COLUMN, + compileAggregate, + isAggregate +} from './expressions.js'; +import { + ALLOWED_OPS, + getEffectiveBaseQuery, + getSchemaQueryBuilderCtor +} from './operations/helpers.js'; +import { isSqlIdentifier } from './sql-identifiers.js'; + +type TableSchema = ObjectSchemaBuilder; +const ALIAS = Symbol('table-alias'); +const PREDICATE = Symbol('join-predicate'); + +/** Immutable table identity; does not mutate the schema's column metadata. */ +export interface TableAlias { + readonly [ALIAS]: true; + /** + * The original immutable schema used to resolve columns and read scopes. + */ + readonly schema: S; + /** + * Literal SQL alias exposed as a key in joined selector callbacks. + */ + readonly name: N; +} + +/** + * Create an immutable table identity for typed flat joins and projections. + * The schema is not modified, so it can be joined repeatedly under different names. + * @param schema - Source table schema, including mapped columns and read scopes. + * @param name - Unique identifier within the query, checked by isSqlIdentifier(). + * @throws If name is not a supported single SQL identifier. + * @example + * const owner = alias(UserSchema, 'owner'); + */ +export function alias( + schema: S, + name: N +): TableAlias { + if (!isSqlIdentifier(name)) { + throw new Error('Table aliases must be non-empty SQL identifier names'); + } + return Object.freeze({ [ALIAS]: true as const, schema, name }); +} + +/** @internal Narrow a query source without losing its schema or alias literal. */ +export function isTableAlias( + value: S | TableAlias +): value is TableAlias; +export function isTableAlias( + value: unknown +): value is TableAlias; +export function isTableAlias(value: unknown): boolean { + return !!value && typeof value === 'object' && ALIAS in value; +} + +type Columns = { + [K in keyof InferType]-?: AliasedColumn< + | Exclude[K], undefined> + | (undefined extends InferType[K] ? null : never) + | (Nullable extends true ? null : never) + >; +}; + +/** + * Map a literal alias to its schema columns; optional/left-joined fields include SQL null. + */ +export type AliasTables< + S extends TableSchema, + N extends string, + Nullable extends boolean = false +> = Record>; +type Selection = Record | AggregateExpression>; +/** + * Infer the exact selected DTO fields from aliased columns and aggregate expressions. + */ +export type JoinedProjection = { + [K in keyof S]: S[K] extends AggregateExpression + ? T + : S[K] extends AliasedColumn + ? T + : never; +}; + +/** + * An opaque SQL condition composed with eq(), and() and or(); never evaluated per row in JavaScript. + */ +export interface JoinPredicate { + readonly [PREDICATE]: + | { op: 'eq'; left: AliasedColumn; right: AliasedColumn } + | { op: 'and' | 'or'; items: readonly JoinPredicate[] }; +} + +/** Equality between two schema-backed columns, never interpolated SQL strings. */ +export function eq( + left: AliasedColumn, + right: AliasedColumn +): JoinPredicate { + return { [PREDICATE]: { op: 'eq', left, right } }; +} + +/** + * Combine one or more predicates into a parenthesized SQL AND group. + * Groups may contain nested and()/or() calls at any depth. + * @throws If no predicates are supplied. + * @example + * and(eq(t.task.ownerId, t.owner.id), or(eq(t.task.id, t.owner.id), eq(t.task.ownerId, t.owner.managerId))) + */ +export function and(...items: JoinPredicate[]): JoinPredicate { + if (!items.length) throw new Error('and() requires at least one predicate'); + return { [PREDICATE]: { op: 'and', items } }; +} + +/** + * Combine one or more predicates into a parenthesized SQL OR group. + * Nesting preserves explicit grouping independently of SQL operator precedence. + * @throws If no predicates are supplied. + * @example + * or(eq(t.task.ownerId, t.owner.id), eq(t.task.approverId, t.owner.id)) + */ +export function or(...items: JoinPredicate[]): JoinPredicate { + if (!items.length) throw new Error('or() requires at least one predicate'); + return { [PREDICATE]: { op: 'or', items } }; +} + +/** Read-only flat query builder. Explicit projections avoid ambiguous SELECT *. */ +export class AliasedQueryBuilder { + private sql: Knex.QueryBuilder; + private tables = new Map(); + private selected = false; + private decoders: Record unknown> = {}; + + /** + * Create a read-only query for one aliased schema. + * Prefer query(knex, alias(schema, name)) so the table-context type is inferred. + * The source retains its schema's default scope and soft-delete filters. + */ + constructor( + private knex: Knex, + source: TableAlias + ) { + this.tables.set(source.name, source.schema); + this.sql = knex.from(this.source(source)); + } + + private source(table: TableAlias): Knex.QueryBuilder { + const Constructor = getSchemaQueryBuilderCtor(); + return getEffectiveBaseQuery(new Constructor(this.knex, table.schema)) + .clone() + .as(table.name); + } + + private tree(): TTables { + return Object.fromEntries( + [...this.tables].map(([name, schema]) => { + const { propToCol } = buildColumnMap(schema); + const properties = schema.introspect().properties; + return [ + name, + Object.fromEntries( + [...propToCol].map(([key, column]) => [ + key, + { + [COLUMN]: { + alias: name, + column, + schema: properties[key] + } + } + ]) + ) + ]; + }) + ) as TTables; + } + + private column(value: AliasedColumn): string { + if (!value || !(COLUMN in value)) + throw new TypeError('Expected an aliased column'); + const column = value[COLUMN]; + const schema = this.tables.get(column.alias); + if ( + !schema || + ![...buildColumnMap(schema).propToCol.values()].includes( + column.column + ) + ) { + throw new Error('Column does not belong to this query'); + } + return `${column.alias}.${column.column}`; + } + + private predicate(value: JoinPredicate): Knex.Raw { + if (!value || !(PREDICATE in value)) + throw new TypeError('Expected a join predicate'); + const node = value[PREDICATE]; + if (node.op === 'eq') { + return this.knex.raw('?? = ??', [ + this.column(node.left), + this.column(node.right) + ]); + } + return this.knex.raw( + `(${node.items.map(() => '?').join(` ${node.op} `)})`, + node.items.map(item => this.predicate(item)) + ); + } + + /** + * Add an inner join using a typed, composable column predicate. + * Unmatched rows are removed; one-to-many matches can repeat parent rows. + * @throws If the alias already exists or a predicate uses an unknown column. + */ + join( + table: N extends keyof TTables ? never : TableAlias, + on: (tables: TTables & AliasTables) => JoinPredicate + ): AliasedQueryBuilder, TResult> { + this.addJoin(table, on as any, false); + return this as any; + } + + /** + * Add a left join and make the new alias's projected fields nullable. + * The right-hand schema's read filters stay inside its source so unmatched parents + * are retained. Nested eq()/and()/or() conditions are supported. + */ + leftJoin( + table: N extends keyof TTables ? never : TableAlias, + on: (tables: TTables & AliasTables) => JoinPredicate + ): AliasedQueryBuilder, TResult> { + this.addJoin(table, on as any, true); + return this as any; + } + + private addJoin( + table: TableAlias, + on: (tables: TTables) => JoinPredicate, + left: boolean + ): void { + if (this.tables.has(table.name)) + throw new Error(`Duplicate table alias: ${table.name}`); + this.tables.set(table.name, table.schema); + try { + const predicate = this.predicate(on(this.tree())); + this.sql[left ? 'leftJoin' : 'join'](this.source(table), predicate); + } catch (error) { + this.tables.delete(table.name); + throw error; + } + } + + /** + * Add an AND comparison on a column from the joined table context. + * Omitting the operator means equality. Values are bound and columns quoted. + * @throws If the operator or selected column is unsupported. + */ + where( + column: (tables: TTables) => AliasedColumn, + value: unknown + ): this; + /** + * Add an AND comparison on a column from the joined table context. + * Omitting the operator means equality. Values are bound and columns quoted. + * @throws If the operator or selected column is unsupported. + */ + where( + column: (tables: TTables) => AliasedColumn, + operator: string, + value: unknown + ): this; + /** + * Add an AND comparison on a column from the joined table context. + * Omitting the operator means equality. Values are bound and columns quoted. + * @throws If the operator or selected column is unsupported. + */ + where( + column: (tables: TTables) => AliasedColumn, + ...args: [value: unknown] | [operator: string, value: unknown] + ): this { + const operator = args.length === 1 ? '=' : args[0].toLowerCase(); + if (!ALLOWED_OPS.has(operator)) + throw new Error(`Unsupported operator: ${operator}`); + this.sql.where( + this.column(column(this.tree())), + operator, + (args.length === 1 ? args[0] : args[1]) as any + ); + return this; + } + + /** + * Require an aliased column to match one of the bound values; an empty list matches no rows. + */ + whereIn( + column: (tables: TTables) => AliasedColumn, + values: readonly unknown[] + ): this { + this.sql.whereIn(this.column(column(this.tree())), values as any[]); + return this; + } + + /** + * Add IS NULL for an aliased column, including a missing left-joined row. + */ + whereNull(column: (tables: TTables) => AliasedColumn): this { + this.sql.whereNull(this.column(column(this.tree()))); + return this; + } + + /** + * Add IS NOT NULL for an aliased column; this can exclude unmatched left joins. + */ + whereNotNull(column: (tables: TTables) => AliasedColumn): this { + this.sql.whereNotNull(this.column(column(this.tree()))); + return this; + } + + /** + * Append ordering on an aliased column, ascending unless a direction is supplied. + */ + orderBy( + column: (tables: TTables) => AliasedColumn, + direction: 'asc' | 'desc' = 'asc' + ): this { + this.sql.orderBy(this.column(column(this.tree())), direction); + return this; + } + + /** + * Append raw ordering with Knex bindings; the caller owns aliases and SQL syntax. + */ + orderByRaw(sql: string, bindings: readonly Knex.RawBinding[] = []): this { + this.sql.orderByRaw(sql, bindings); + return this; + } + + /** + * Group joined rows by aliased columns before evaluating aggregate projections. + */ + groupBy(...columns: Array<(tables: TTables) => AliasedColumn>): this { + this.sql.groupBy( + columns.map(column => this.column(column(this.tree()))) + ); + return this; + } + + /** + * Filter groups by an aliased column or aggregate expression. + * Comparisons use native SQL aggregate values, not output-schema conversions or + * text casts, so numeric comparisons retain database semantics. + */ + having( + value: ( + tables: TTables + ) => AggregateExpression | AliasedColumn, + operator: string, + right: unknown + ): this { + if (!ALLOWED_OPS.has(operator.toLowerCase())) + throw new Error('Unsupported HAVING operator'); + const expression = value(this.tree()); + const left = isAggregate(expression) + ? compileAggregate(this.knex, expression, c => + this.column(c as AliasedColumn) + ).native + : this.knex.raw('??', [this.column(expression)]); + this.sql.havingRaw(`? ${operator} ?`, [left, right as any]); + return this; + } + + /** + * Choose the exact flat DTO shape from aliased columns and aggregate expressions. + * Aggregates are decoded after execution; left-joined fields include null. + * @throws If the projection is empty or select() was already called. + * @returns This builder with an inferred result type for the selected fields. + */ + select( + selector: (tables: TTables) => S + ): AliasedQueryBuilder> { + if (this.selected) + throw new Error('Only one object projection per query'); + const columns: Record = {}; + for (const [key, value] of Object.entries(selector(this.tree()))) { + if (isAggregate(value)) { + const compiled = compileAggregate(this.knex, value, c => + this.column(c as AliasedColumn) + ); + columns[key] = compiled.sql; + this.decoders[key] = compiled.decode; + } else columns[key] = this.column(value); + } + if (!Object.keys(columns).length) + throw new Error('A non-empty projection is required'); + this.sql.select(columns); + this.selected = true; + return this as any; + } + + /** + * Set the maximum number of flat result rows; repeated joined parents each count as a row. + */ + limit(limit: number): this { + this.sql.limit(limit); + return this; + } + /** + * Skip flat result rows; pair with deterministic ordering for repeatable pages. + */ + offset(offset: number): this { + this.sql.offset(offset); + return this; + } + + /** Raw escape hatch; callers own any effects on result shape or cardinality. */ + apply(callback: (query: Knex.QueryBuilder) => void): this { + callback(this.sql); + return this; + } + + /** + * Clone this builder onto an existing transaction, preserving its tables/projection. + * Does not modify the source or commit/roll back the transaction. + */ + transacting(trx: Knex.Transaction): AliasedQueryBuilder { + const [name, schema] = this.tables.entries().next().value!; + const copy = new AliasedQueryBuilder( + trx as unknown as Knex, + alias(schema, name) + ); + copy.sql = this.sql.clone().transacting(trx); + copy.tables = new Map(this.tables); + copy.selected = this.selected; + copy.decoders = { ...this.decoders }; + return copy; + } + + /** + * Return a clone of the underlying Knex query after validating the projection. + * Executing the clone bypasses aggregate output decoding. + * @throws If no explicit projection has been selected. + */ + toKnexQuery(): Knex.QueryBuilder { + if (!this.selected) + throw new Error( + 'Aliased queries require an explicit select projection' + ); + return this.sql.clone(); + } + /** + * Render debugging SQL without execution; interpolated bindings may contain sensitive values. + */ + toQuery(): string { + return this.toKnexQuery().toQuery(); + } + + private decode(row: Record): TResult { + const result = { ...row }; + for (const [key, decode] of Object.entries(this.decoders)) + result[key] = decode(row[key]); + return result as TResult; + } + /** + * Execute the flat query and decode aggregate outputs for each returned row. + * @throws Database errors, missing projection errors and output-parser failures. + */ + async execute(): Promise { + return (await this.toKnexQuery()).map((row: Record) => + this.decode(row) + ); + } + /** + * Execute a limited clone and return its first decoded DTO, or undefined when no row matches. + */ + async first(): Promise { + const row = await this.toKnexQuery().first(); + return row === undefined ? undefined : this.decode(row); + } + /** + * Enable awaiting the builder by executing and forwarding its decoded result or error. + */ + // biome-ignore lint/suspicious/noThenProperty: intentional query thenable + then( + resolve?: ((rows: TResult[]) => T | PromiseLike) | null, + reject?: ((error: any) => E | PromiseLike) | null + ): Promise { + return this.execute().then(resolve, reject); + } +} diff --git a/libs/knex-schema/src/columns.ts b/libs/knex-schema/src/columns.ts index a014952a..4f620d2e 100644 --- a/libs/knex-schema/src/columns.ts +++ b/libs/knex-schema/src/columns.ts @@ -96,6 +96,11 @@ export function resolveColumnRef( label: string, knex: Knex ): string | Knex.Raw; +/** + * Resolve a schema property key/accessor to its mapped database column. + * With a Knex instance, nested JSON paths become bound SQL expressions. Without + * Knex, nested paths throw. Unknown string names pass through as database names. + */ export function resolveColumnRef( ref: ColumnRef, schema: ObjectSchemaBuilder, @@ -246,7 +251,13 @@ export function resolvePropertyKey( * @public */ export interface PrimaryKeyColumns { + /** + * Schema property names in primary-key declaration order; this order defines composite-key tuples. + */ readonly propertyKeys: readonly string[]; + /** + * Corresponding mapped SQL column names in the same order as propertyKeys. + */ readonly columnNames: readonly string[]; } diff --git a/libs/knex-schema/src/composable-query.test-d.ts b/libs/knex-schema/src/composable-query.test-d.ts new file mode 100644 index 00000000..a03fa6f4 --- /dev/null +++ b/libs/knex-schema/src/composable-query.test-d.ts @@ -0,0 +1,166 @@ +import type { Knex as KnexTypes } from 'knex'; +import Knex from 'knex'; +import { expectTypeOf, test } from 'vitest'; +import { + aggregate, + alias, + createQuery, + date, + eq, + isSqlIdentifier, + number, + object, + query, + string +} from './index.js'; + +const db = Knex({ client: 'pg' }); +const User = object({ id: number().primaryKey(), name: string() }).hasTableName( + 'users' +); +const Task = object({ + id: number().primaryKey(), + ownerId: number(), + amount: number(), + createdAt: date() +}).hasTableName('tasks'); + +declare const trx: KnexTypes.Transaction; + +test('factories retain schema, alias and result inference through every call shape', async () => { + const bound = createQuery(db); + const transactional = bound.withTransaction(trx); + const plain = query(db, Task); + expectTypeOf(plain).not.toBeAny(); + expectTypeOf(await plain.select(t => ({ id: t.id }))).toEqualTypeOf< + { id: number }[] + >(); + expectTypeOf( + await query(db, Task, db('tasks')).select(t => ({ amount: t.amount })) + ).toEqualTypeOf<{ amount: number }[]>(); + for (const factory of [bound, transactional]) { + const ordinary = factory(Task); + expectTypeOf(ordinary).not.toBeAny(); + expectTypeOf( + await ordinary.select(t => ({ createdAt: t.createdAt })) + ).toEqualTypeOf<{ createdAt: Date }[]>(); + expectTypeOf( + await factory(Task, db('tasks')).select(t => ({ id: t.id })) + ).toEqualTypeOf<{ id: number }[]>(); + const aliased = factory(alias(Task, 'task')); + expectTypeOf(aliased).not.toBeAny(); + const rows = await aliased + .leftJoin(alias(User, 'owner'), t => eq(t.task.ownerId, t.owner.id)) + .select(t => ({ id: t.task.id, ownerName: t.owner.name })); + expectTypeOf(rows).toEqualTypeOf< + { id: number; ownerName: string | null }[] + >(); + expectTypeOf(rows[0].id).not.toBeAny(); + // @ts-expect-error Unselected fields are unavailable. + rows[0].amount; + // @ts-expect-error Invalid schema fields must not become any. + factory(Task).select(t => ({ missing: t.missing })); + // @ts-expect-error Aliased tables retain their own fields. + factory(alias(Task, 'task')).select(t => ({ missing: t.task.name })); + // @ts-expect-error An aliased query does not accept a custom base query. + factory(alias(Task, 'task'), db('tasks')); + } + expectTypeOf( + await bound.transaction(async tx => + tx(Task).select(t => ({ id: t.id })) + ) + ).toEqualTypeOf<{ id: number }[]>(); + expectTypeOf( + await bound.transaction(async tx => + tx(alias(Task, 'task')).select(t => ({ id: t.task.id })) + ) + ).toEqualTypeOf<{ id: number }[]>(); + const unknownName: unknown = 'task'; + if (isSqlIdentifier(unknownName)) + expectTypeOf(unknownName).toEqualTypeOf(); +}); + +test('flat join projection inference and nullable right-hand fields', async () => { + const task = alias(Task, 'task'); + const owner = alias(User, 'owner'); + const result = await query(db, task) + .leftJoin(owner, t => eq(t.task.ownerId, t.owner.id)) + .select(t => ({ id: t.task.id, owner: t.owner.name })); + expectTypeOf(result).toEqualTypeOf< + { id: number; owner: string | null }[] + >(); + // @ts-expect-error unselected property + result[0].amount; + // @ts-expect-error wrong table property + query(db, task).select(t => ({ name: t.task.name })); + // @ts-expect-error duplicate alias + query(db, task).join(task, t => eq(t.task.id, t.task.id)); + const bound = createQuery(db); + expectTypeOf( + await bound(owner).select(t => ({ name: t.owner.name })) + ).toEqualTypeOf<{ name: string }[]>(); +}); + +test('aggregate defaults and supplied output schemas infer accurately', async () => { + expectTypeOf(await query(db, Task).countValue()).toEqualTypeOf(); + expectTypeOf(await query(db, Task).sumValue(t => t.amount)).toEqualTypeOf< + string | null + >(); + expectTypeOf( + await query(db, Task).maxValue(t => t.createdAt) + ).toEqualTypeOf(); + expectTypeOf( + await query(db, Task).maxValue('createdAt') + ).toEqualTypeOf(); + expectTypeOf(await query(db, Task).minValue('amount')).toEqualTypeOf< + number | string | null + >(); + expectTypeOf(await query(db, Task).minValue(t => t.amount)).toEqualTypeOf< + number | string | null + >(); + expectTypeOf( + await query(db, Task).countValue({ output: string() }) + ).toEqualTypeOf(); + const results = await query(db, Task) + .groupBy(t => t.ownerId) + .select(t => ({ + ownerId: t.ownerId, + count: aggregate.count(), + sum: aggregate.sum(t.amount), + avg: aggregate.avg(t.amount, { + output: number().coerce().nullable() + }), + min: aggregate.min(t.createdAt) + })); + expectTypeOf(results).toEqualTypeOf< + { + ownerId: number; + count: number; + sum: string | null; + avg: number | null; + min: Date | null; + }[] + >(); +}); + +test('both cursor call shapes remain typed', async () => { + const queryBuilder = query(db, Task).select(t => ({ id: t.id })); + const page = await queryBuilder.paginateAfter({ + limit: 5, + orderBy: [ + { column: t => t.createdAt, direction: 'desc' }, + { column: t => t.id, direction: 'asc' } + ] + }); + expectTypeOf(page.data).toEqualTypeOf<{ id: number }[]>(); + await queryBuilder.paginateAfter({ + limit: 5, + column: t => t.id, + cursor: '5' + }); + await queryBuilder.paginateAfter({ + limit: 5, + // @ts-expect-error invalid cursor column + orderBy: [{ column: t => t.missing, direction: 'asc' }] + }); +}); diff --git a/libs/knex-schema/src/composable-query.test.ts b/libs/knex-schema/src/composable-query.test.ts new file mode 100644 index 00000000..b3a3dbed --- /dev/null +++ b/libs/knex-schema/src/composable-query.test.ts @@ -0,0 +1,207 @@ +import Knex from 'knex'; +import { afterAll, describe, expect, it } from 'vitest'; +import { compileAggregate } from './expressions.js'; +import { + aggregate, + alias, + and, + date, + eq, + number, + object, + or, + query, + string +} from './index.js'; + +const db = Knex({ client: 'pg' }); +afterAll(() => db.destroy()); +const User = object({ + id: number().primaryKey(), + name: string().hasColumnName('display_name') +}).hasTableName('users'); +const Task = object({ + id: number().primaryKey(), + ownerId: number().hasColumnName('owner_id'), + amount: number().decimal(20, 4), + createdAt: date().hasColumnName('created_at') +}).hasTableName('tasks'); + +describe('typed aliases and aggregate SQL', () => { + it('quotes columns and binds values without modifying schema metadata', () => { + const before = Task.introspect(); + const compiled = query(db, alias(Task, 'task')) + .join(alias(User, 'owner'), t => + or(eq(t.task.ownerId, t.owner.id), eq(t.task.id, t.owner.id)) + ) + .where(t => t.owner.name, "O'Reilly") + .orderBy(t => t.task.id, 'desc') + .select(t => ({ id: t.task.id, name: t.owner.name })) + .toKnexQuery() + .toSQL(); + expect(compiled.sql).toContain('"owner"."display_name" as "name"'); + expect(compiled.sql).toContain('"task"."owner_id" = "owner"."id"'); + expect(compiled.bindings).toEqual(["O'Reilly"]); + expect(Task.introspect()).toEqual(before); + }); + + it('preserves multiple predicates at every nested AND/OR level', () => { + const compiled = query(db, alias(Task, 'task')) + .join(alias(Task, 'peer'), t => + and( + or( + eq(t.task.ownerId, t.peer.ownerId), + eq(t.task.id, t.peer.id) + ), + or( + eq(t.task.id, t.peer.id), + and( + eq(t.task.ownerId, t.peer.id), + eq(t.task.id, t.peer.ownerId) + ) + ) + ) + ) + .select(t => ({ id: t.task.id })) + .toKnexQuery() + .toSQL(); + expect(compiled.sql).toContain( + 'on (("task"."owner_id" = "peer"."owner_id" or "task"."id" = "peer"."id") and ("task"."id" = "peer"."id" or ("task"."owner_id" = "peer"."id" and "task"."id" = "peer"."owner_id")))' + ); + expect(compiled.bindings).toEqual([]); + expect(() => and()).toThrow('at least one'); + expect(() => or()).toThrow('at least one'); + }); + + it('supports two aliases of one schema and rejects duplicate aliases', () => { + const users = query(db, alias(User, 'first')); + const result = users.leftJoin(alias(User, 'second'), t => + eq(t.first.id, t.second.id) + ); + expect( + result + .select(t => ({ a: t.first.name, b: t.second.name })) + .toQuery() + ).toContain('left join'); + expect(() => + (users as any).join(alias(User, 'first'), () => null) + ).toThrow('Duplicate'); + expect(() => query(db, alias(User, 'u')).toKnexQuery()).toThrow( + 'explicit select' + ); + }); + + it('supports grouped aggregate aliases and SQL-native HAVING comparisons', () => { + const compiled = query(db, alias(Task, 'task')) + .groupBy(t => t.task.ownerId) + .having(t => aggregate.sum(t.task.amount), '>', 10) + .select(t => ({ + owner: t.task.ownerId, + total: aggregate.sum(t.task.amount), + count: aggregate.count() + })) + .toKnexQuery() + .toSQL(); + expect(compiled.sql).toContain( + 'cast(sum("task"."amount") as text) as "total"' + ); + expect(compiled.sql).toContain('having sum("task"."amount") > ?'); + expect(compiled.bindings).toEqual([10]); + }); + + it('adds aggregate expressions to existing object selectors', () => { + const sql = query(db, Task) + .groupBy(t => t.ownerId) + .select(t => ({ + owner: t.ownerId, + total: aggregate.sum(t.amount), + count: aggregate.countDistinct(t.id) + })) + .toQuery(); + expect(sql).toContain('count(distinct "id")'); + expect(sql).toContain('group by "owner_id"'); + }); +}); + +describe('aggregate decoders', () => { + const count = compileAggregate(db, aggregate.count(), () => '').decode; + it.each([ + '0', + '3', + 5, + 12n, + String(Number.MAX_SAFE_INTEGER) + ])('decodes safe count %s', value => { + expect(count(value)).toBe(Number(value)); + }); + it.each([ + '9007199254740993', + -1, + '1.5', + '', + null, + NaN, + {}, + Infinity + ])('rejects unsafe/malformed count %s', value => { + expect(() => count(value)).toThrow(); + }); + it('lets output schemas replace default decoding, including overflow policy', () => { + const compiled = compileAggregate( + db, + aggregate.count(undefined, { output: string() }), + () => '' + ); + expect(compiled.decode('9007199254740993')).toBe('9007199254740993'); + expect(() => compiled.decode(null)).toThrow(); + }); +}); + +describe('composite cursor guards', () => { + it('rejects non-unique, nullable, grouped and malformed requests before querying', async () => { + const Nullable = object({ + id: number().primaryKey().nullable() + }).hasTableName('nullable_keys'); + await expect( + query(db, Nullable).paginateAfter({ + limit: 2, + orderBy: [{ column: t => t.id, direction: 'asc' }] + }) + ).rejects.toThrow('non-null'); + await expect( + query(db, Task).paginateAfter({ + limit: 2, + orderBy: [{ column: t => t.createdAt, direction: 'asc' }] + }) + ).rejects.toThrow('unique key'); + await expect( + query(db, Task).paginateAfter({ + limit: 0, + orderBy: [{ column: t => t.id, direction: 'asc' }] + }) + ).rejects.toThrow('limit'); + await expect( + query(db, Task) + .offset(2) + .paginateAfter({ + limit: 2, + orderBy: [{ column: t => t.id, direction: 'asc' }] + }) + ).rejects.toThrow('offsets'); + await expect( + query(db, Task) + .groupBy(t => t.id) + .paginateAfter({ + limit: 2, + orderBy: [{ column: t => t.id, direction: 'asc' }] + }) + ).rejects.toThrow('grouped'); + await expect( + query(db, Task).paginateAfter({ + cursor: 'not-a-cursor', + limit: 2, + orderBy: [{ column: t => t.id, direction: 'asc' }] + }) + ).rejects.toThrow('Invalid cursor'); + }); +}); diff --git a/libs/knex-schema/src/entity.ts b/libs/knex-schema/src/entity.ts index ca22e669..93ff5896 100644 --- a/libs/knex-schema/src/entity.ts +++ b/libs/knex-schema/src/entity.ts @@ -48,7 +48,13 @@ export interface RelationInfo< any > = ObjectSchemaBuilder > { + /** + * Relation cardinality used to infer nested object or collection results. + */ readonly kind: TKind; + /** + * Foreign schema retained at the type level for relation callback completion. + */ readonly foreign: TForeign; } @@ -197,6 +203,11 @@ export class Entity< /** @internal Phantom slot to retain `TVariantUnion` in inferred types. */ private declare readonly __variantUnion__: TVariantUnion; + /** + * Wrap a schema as an entity definition with typed relation metadata. + * Prefer defineEntity(schema) for inference. Relation/variant methods return new + * definitions; constructing an Entity does not query or create database tables. + */ constructor(schema: TSchema) { this.schema = schema; } @@ -226,7 +237,7 @@ export class Entity< * via `opts.foreign` only when peeling fails. * * @param navSel Selector of nav property: `t => t.author` - * @param localSel Selector of local-side join key: `l => l.id` + * @param _localSel Selector of local-side join key: `l => l.id` * @param remoteSel Selector of remote-side FK on foreign schema: `r => r.userId` * @param opts Optional `{ optional?: boolean }` (default false). */ @@ -270,7 +281,7 @@ export class Entity< * Declare a one-to-many relation where the FK lives on the FOREIGN table. * * @param navSel Selector of nav array property: `t => t.posts` - * @param localSel Selector of local-side join key: `l => l.id` + * @param _localSel Selector of local-side join key: `l => l.id` * @param remoteSel Selector of remote-side FK on foreign schema: `r => r.userId` */ hasMany< @@ -311,7 +322,7 @@ export class Entity< * * @param navSel Selector of nav property (foreign schema, usually `.optional()`). * @param localSel Selector of local-side FK property: `l => l.userId` - * @param remoteSel Selector of foreign-side PK property: `r => r.id` + * @param _remoteSel Selector of foreign-side PK property: `r => r.id` */ belongsTo< TKey extends keyof SchemaProps & string, diff --git a/libs/knex-schema/src/expressions.ts b/libs/knex-schema/src/expressions.ts new file mode 100644 index 00000000..04aae2be --- /dev/null +++ b/libs/knex-schema/src/expressions.ts @@ -0,0 +1,278 @@ +import { + type InferType, + type PropertyDescriptor, + type SchemaTypeBrand, + SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR +} from '@cleverbrush/schema'; +import type { Knex } from 'knex'; + +/** A synchronous schema parser. Framework schemas implement this interface. */ +export interface OutputSchema { + /** + * Synchronously validate/convert a raw driver value, including null; throw to reject the query. + */ + parse(value: unknown): T; +} + +/** A supplied schema replaces default decoding and receives the raw SQL value. */ +export interface AggregateOptions< + S extends OutputSchema | undefined = undefined +> { + /** + * Replace the default decoder with this parser; its output type becomes the aggregate result type. + */ + output?: S; +} + +/** Schema brands retain nullable/optional output modifiers unlike parse() alone. */ +export type AggregateResult = + S extends OutputSchema + ? S extends { readonly [K in SchemaTypeBrand]: unknown } + ? InferType + : ReturnType + : Fallback; + +export const COLUMN = Symbol('query-column'); +export const EXPRESSION = Symbol('query-expression'); + +/** A schema-backed SQL column belonging to one explicit table alias. */ +export interface AliasedColumn { + readonly [COLUMN]: { + alias: string; + column: string; + schema: any; + }; + /** + * Type-only marker carrying column nullability and value type; not a runtime row value. + */ + readonly __value?: T; +} + +export type SelectableColumn = + | PropertyDescriptor + | AliasedColumn; + +export type ColumnValue = + C extends AliasedColumn + ? T + : C extends PropertyDescriptor + ? InferType + : never; + +/** Numeric SQL overrides can return exact strings rather than JS numbers. */ +export type ExtremumValue = ExtremumResult>; + +/** Widen numeric extrema for SQL overrides; all other columns retain their type. */ +export type ExtremumResult = + | (NonNullable extends number ? number | string : NonNullable) + | null; + +export type AggregateKind = + | 'count' + | 'countDistinct' + | 'sum' + | 'avg' + | 'min' + | 'max'; + +/** An aggregate description; evaluated by SQL, never once per application row. */ +export interface AggregateExpression { + readonly [EXPRESSION]: { + kind: AggregateKind; + column?: SelectableColumn; + output?: OutputSchema; + }; + /** + * Type-only marker describing the decoded result of this SQL expression. + */ + readonly __result?: T; +} + +export function isAggregate(value: unknown): value is AggregateExpression { + return !!value && typeof value === 'object' && EXPRESSION in value; +} + +export function isColumn(value: unknown): value is SelectableColumn { + return ( + !!value && + typeof value === 'object' && + (COLUMN in value || SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR in value) + ); +} + +export function createAggregate( + kind: AggregateKind, + column?: SelectableColumn, + options?: AggregateOptions +): AggregateExpression { + if (column !== undefined && !isColumn(column)) { + throw new TypeError(`${kind}: expected a schema property descriptor`); + } + if (kind !== 'count' && !column) { + throw new TypeError(`${kind}: a column is required`); + } + return Object.freeze({ + [EXPRESSION]: { kind, column, output: options?.output } + }); +} + +/** Typed aggregate expressions for scalar or grouped object projections. */ +export const aggregate = { + /** + * Describe COUNT(*) or COUNT(column) for a typed projection or HAVING clause. + * @param column - Omit to count rows; supply a column to count its non-null values. + * @param options - Optional raw-value parser; use undefined as column for custom COUNT(*). + * @returns An expression decoded as a safe number unless a parser is supplied. + * @throws During execution if the default result exceeds the safe integer range. + */ + count | undefined = undefined>( + column?: SelectableColumn, + options?: AggregateOptions + ) { + return createAggregate>( + 'count', + column, + options + ); + }, + /** + * Describe COUNT(DISTINCT column), excluding nulls. + * @param options - Optional parser replacing checked safe-integer decoding. + * @returns A typed aggregate expression, not an executed query. + */ + countDistinct | undefined = undefined>( + column: SelectableColumn, + options?: AggregateOptions + ) { + return createAggregate>( + 'countDistinct', + column, + options + ); + }, + /** + * Describe SUM(column) with exact database numeric text as the default result. + * Empty/all-null inputs produce null. An output parser receives the raw driver + * value and takes responsibility for precision and null handling. + */ + sum | undefined = undefined>( + column: SelectableColumn, + options?: AggregateOptions + ) { + return createAggregate>( + 'sum', + column, + options + ); + }, + /** + * Describe AVG(column), preserving database numeric text or null by default. + * An output parser can explicitly convert it to a number or domain decimal type. + * Text output cannot recover precision already lost in floating-point storage. + */ + avg | undefined = undefined>( + column: SelectableColumn, + options?: AggregateOptions + ) { + return createAggregate>( + 'avg', + column, + options + ); + }, + /** + * Describe MIN(column), returning the column representation or null. + * Numeric/decimal/bigint SQL overrides preserve exact strings; dates return Date. + * An optional output parser replaces this decoding policy. + */ + min< + C extends SelectableColumn, + S extends OutputSchema | undefined = undefined + >(column: C, options?: AggregateOptions) { + return createAggregate>>( + 'min', + column, + options + ); + }, + /** + * Describe MAX(column), returning the column representation or null. + * Numeric/decimal/bigint SQL overrides preserve exact strings; dates return Date. + * An optional output parser replaces this decoding policy. + */ + max< + C extends SelectableColumn, + S extends OutputSchema | undefined = undefined + >(column: C, options?: AggregateOptions) { + return createAggregate>>( + 'max', + column, + options + ); + } +}; + +export function columnSchema(column: SelectableColumn): any { + return COLUMN in column + ? column[COLUMN].schema + : column[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR].getSchema(); +} + +export function compileAggregate( + knex: Knex, + value: AggregateExpression, + resolve: (column: SelectableColumn) => string | Knex.Raw +): { sql: Knex.Raw; native: Knex.Raw; decode: (value: unknown) => unknown } { + const { kind, column, output } = value[EXPRESSION]; + const argument = column ? knex.raw('??', [resolve(column)]) : knex.raw('*'); + const call = + kind === 'countDistinct' + ? knex.raw('count(distinct ?)', [argument]) + : knex.raw(`${kind}(?)`, [argument]); + // PostgreSQL numeric/int8 results stay exact even with custom pg parsers. + const schema = column ? columnSchema(column).introspect() : undefined; + const sqlType: string = schema?.extensions?.columnType ?? ''; + const exactExtremum = + /^(numeric|decimal|bigint|int8|bigserial)(\b|\()/i.test(sqlType); + const textResult = + !output && + (kind === 'sum' || + kind === 'avg' || + kind === 'count' || + kind === 'countDistinct' || + exactExtremum); + return { + native: call, + sql: textResult ? knex.raw('cast(? as text)', [call]) : call, + decode(raw) { + if (output) return output.parse(raw); + if (kind === 'count' || kind === 'countDistinct') { + if ( + (typeof raw !== 'string' && + typeof raw !== 'number' && + typeof raw !== 'bigint') || + !/^\d+$/.test(String(raw)) + ) { + throw new TypeError('Invalid SQL count result'); + } + const count = Number(raw); + if (!Number.isSafeInteger(count) || count < 0) { + throw new RangeError( + 'SQL count exceeds the safe integer range; supply an output schema' + ); + } + return count; + } + if (raw === null) return null; + if (textResult) return String(raw); + if (schema?.type === 'date') { + const date = + raw instanceof Date ? raw : new Date(raw as string); + if (!Number.isFinite(date.getTime())) + throw new TypeError('Invalid SQL date result'); + return date; + } + return raw; + } + }; +} diff --git a/libs/knex-schema/src/extension.ts b/libs/knex-schema/src/extension.ts index eafe3fbc..5a421518 100644 --- a/libs/knex-schema/src/extension.ts +++ b/libs/knex-schema/src/extension.ts @@ -1146,14 +1146,41 @@ const extended = withExtensions( ddlExtension ); +/** + * Create a string schema with database column, key, reference and mapping extensions. + */ export const string = extended.string; +/** + * Create a numeric schema with database type, precision, key and reference extensions. + */ export const number = extended.number; +/** + * Create a boolean schema with database column mapping and DDL metadata extensions. + */ export const boolean = extended.boolean; +/** + * Create a date schema with database column mapping and DDL metadata extensions. + */ export const date = extended.date; +/** + * Create an object schema with table, relation, scope, projection and lifecycle extensions. + */ export const object = extended.object; +/** + * Create an array schema from its element schema; use object elements for collection navigation properties. + */ export const array = extended.array; +/** + * Create a union schema using the database-extended schema factory. + */ export const union = extended.union; +/** + * Create a function schema using the database-extended schema factory; this does not create a SQL function. + */ export const func = extended.func; +/** + * Create an unconstrained schema with database extensions; prefer a specific schema when value typing matters. + */ export const any = extended.any; // --------------------------------------------------------------------------- diff --git a/libs/knex-schema/src/index.ts b/libs/knex-schema/src/index.ts index 2f2387ee..e925f107 100644 --- a/libs/knex-schema/src/index.ts +++ b/libs/knex-schema/src/index.ts @@ -1,5 +1,13 @@ // @cleverbrush/knex-schema — Type-safe schema-driven query builder for Knex +export type { + AliasTables, + JoinedProjection, + JoinPredicate, + TableAlias +} from './aliased-query.js'; +// Types +export { AliasedQueryBuilder, alias, and, eq, or } from './aliased-query.js'; export type { PrimaryKeyColumns, RowVersionColumn, @@ -33,6 +41,14 @@ export type { } from './entity.js'; // Entity wrapper export { defineEntity, Entity } from './entity.js'; +export type { + AggregateExpression, + AggregateOptions, + AggregateResult, + AliasedColumn, + OutputSchema +} from './expressions.js'; +export { aggregate } from './expressions.js'; // Schema extension (hasColumnName / hasTableName + DDL/ORM) export { any, @@ -73,6 +89,7 @@ export { tableExistsInDb, validateEntitiesAgainstDatabase } from './migration.js'; +export type { CompositeCursorOptions } from './operations/composite-cursor.js'; // Raw query execution export { rawQuery } from './raw.js'; export type { BoundQuery } from './SchemaQueryBuilder.js'; @@ -88,8 +105,7 @@ export { loadSnapshot, writeSnapshot } from './snapshot.js'; - -// Types +export { isSqlIdentifier } from './sql-identifiers.js'; export type { AddColumnDiff, AddForeignKeyDiff, diff --git a/libs/knex-schema/src/operations/aggregate.ts b/libs/knex-schema/src/operations/aggregate.ts new file mode 100644 index 00000000..063ee36c --- /dev/null +++ b/libs/knex-schema/src/operations/aggregate.ts @@ -0,0 +1,136 @@ +import { ObjectSchemaBuilder } from '@cleverbrush/schema'; +import type { Knex } from 'knex'; +import { resolveColumnRef } from '../columns.js'; +import { + type AggregateKind, + type AggregateOptions, + compileAggregate, + createAggregate, + type SelectableColumn +} from '../expressions.js'; +import type { SchemaQueryBuilder } from '../SchemaQueryBuilder.js'; +import type { ColumnRef } from '../types.js'; +import { + buildQuery, + getEffectiveBaseQuery, + getSchemaQueryBuilderCtor +} from './helpers.js'; +import { getState } from './state.js'; + +/** Clone Framework metadata as well as Knex state; terminal helpers never mutate the source. */ +export function cloneQuery( + builder: SchemaQueryBuilder +): SchemaQueryBuilder { + const state = getState(builder); + const Constructor = getSchemaQueryBuilderCtor(); + const copy = new Constructor( + state.knex, + state.localSchema, + state.baseQuery.clone() + ); + Object.assign(getState(copy), state, { + baseQuery: state.baseQuery.clone(), + specs: state.specs.map(spec => ({ + ...spec, + foreignQuery: spec.foreignQuery.clone() + })), + explicitSelects: state.explicitSelects + ? [...state.explicitSelects] + : null, + projectionColumns: state.projectionColumns + ? { ...state.projectionColumns } + : null, + projectionDecoders: { ...state.projectionDecoders }, + hiddenColumns: new Set(state.hiddenColumns), + variantWhereFilters: [...state.variantWhereFilters], + variantRelationIncludes: [...state.variantRelationIncludes], + enabledVariants: state.enabledVariants + ? new Set(state.enabledVariants) + : null, + cachedBuiltQuery: null + }); + return copy; +} + +/** Knex clause inspection is isolated here and covered by SQL regression tests. */ +export function statements(query: Knex.QueryBuilder): any[] { + return (query as any)._statements; +} + +export function assertScalarSource(query: Knex.QueryBuilder): void { + if ( + statements(query).some( + s => + s.grouping === 'group' || + s.grouping === 'having' || + s.distinct || + s.distinctOn || + s.type === 'aggregate' || + s.type === 'aggregateRaw' + ) + ) { + throw new Error( + 'Scalar aggregates require an ungrouped, non-distinct source; use aggregate expressions for grouped results' + ); + } +} + +export async function scalarAggregate< + S extends ObjectSchemaBuilder +>( + builder: SchemaQueryBuilder, + kind: AggregateKind, + column?: ColumnRef, + options?: AggregateOptions +): Promise { + const copy = cloneQuery(builder); + const state = getState(copy); + // Materialize scopes before checking query shape/removing paging. A default + // scope may itself supply pagination, selection, or grouping clauses. + state.baseQuery = getEffectiveBaseQuery(copy).clone(); + state.skipDefaultScope = true; + state.includeDeleted = true; + state.onlyDeleted = false; + assertScalarSource(state.baseQuery); + if (Object.keys(state.projectionDecoders).length) { + throw new Error( + 'Cannot apply a scalar aggregate to an aggregate projection' + ); + } + const tree = ObjectSchemaBuilder.getPropertiesFor(state.localSchema); + const descriptor: SelectableColumn | undefined = + column === undefined + ? undefined + : typeof column === 'function' + ? column(tree as any) + : tree[column]; + if (column !== undefined && !descriptor) + throw new Error('Unknown aggregate column'); + const value = createAggregate(kind, descriptor, options); + const compiled = compileAggregate(state.knex, value, () => { + const resolved = resolveColumnRef( + column as ColumnRef, + state.localSchema, + kind, + state.knex + ); + return resolved; + }); + // Preserve filtering joins (including required eager joins), but aggregate + // the parent source rather than multiplying it by included collections. + state.baseQuery + .clearSelect() + .clearOrder() + .clear('limit') + .clear('offset') + .select(`${state.tableName}.*`); + state.explicitSelects = null; + state.projectionColumns = null; + state.selectionMode = null; + const source = buildQuery(copy).clearOrder().as('__cb_aggregate_source'); + const row = await state.knex + .from(source) + .select({ value: compiled.sql }) + .first(); + return compiled.decode(row.value); +} diff --git a/libs/knex-schema/src/operations/composite-cursor.ts b/libs/knex-schema/src/operations/composite-cursor.ts new file mode 100644 index 00000000..6a36e639 --- /dev/null +++ b/libs/knex-schema/src/operations/composite-cursor.ts @@ -0,0 +1,263 @@ +import { Buffer } from 'node:buffer'; +import type { ObjectSchemaBuilder } from '@cleverbrush/schema'; +import { + buildColumnMap, + getPrimaryKeyColumns, + resolvePropertyKey +} from '../columns.js'; +import type { SchemaQueryBuilder } from '../SchemaQueryBuilder.js'; +import type { ColumnRef, CursorPaginationResult } from '../types.js'; +import { cloneQuery, statements } from './aggregate.js'; +import { cleanAndMapRow, getEffectiveBaseQuery, getQuery } from './helpers.js'; +import { privateColumn } from './ordering.js'; +import { getState } from './state.js'; + +/** Opt-in, non-null, uniquely ordered keyset pagination. */ +export interface CompositeCursorOptions< + S extends ObjectSchemaBuilder +> { + /** Opaque nextCursor from a compatible page; null/undefined starts a new stream. */ + cursor?: string | null; + /** Positive safe integer page size; one extra row determines whether more exist. */ + limit: number; + /** Complete non-null sort, including a declared primary/unique key; replaces prior ordering. */ + orderBy: readonly { + /** Required, non-null scalar schema property participating in the sort. */ + column: ColumnRef; + /** Direction for this component; mixed directions are supported. */ + direction: 'asc' | 'desc'; + }[]; +} + +export async function compositeCursor( + builder: SchemaQueryBuilder, + options: CompositeCursorOptions +): Promise> { + if ( + !Number.isSafeInteger(options.limit) || + options.limit < 1 || + options.limit >= Number.MAX_SAFE_INTEGER + ) { + throw new Error('Cursor limit must be a positive safe integer'); + } + if (!Array.isArray(options.orderBy) || !options.orderBy.length) { + throw new Error('Composite cursors require a non-empty orderBy'); + } + const copy = cloneQuery(builder); + const state = getState(copy); + state.baseQuery = getEffectiveBaseQuery(copy).clone(); + state.skipDefaultScope = true; + state.includeDeleted = true; + state.onlyDeleted = false; + const sql = state.baseQuery; + if ( + (sql as any)._single.offset != null || + Object.keys(state.projectionDecoders).length || + statements(sql).some( + s => + ['join', 'group', 'having', 'union'].includes(s.grouping) || + s.distinct || + s.distinctOn || + s.type === 'aggregate' || + s.type === 'aggregateRaw' + ) + ) { + throw new Error( + 'Composite cursors do not support offsets, flat joins, distinct, grouped or aggregate queries' + ); + } + const { properties, extensions } = state.localSchema.introspect() as any; + const { propToCol, colToProp } = buildColumnMap(state.localSchema); + const order = options.orderBy.map(item => { + if (item.direction !== 'asc' && item.direction !== 'desc') + throw new Error('Invalid cursor direction'); + const key = resolvePropertyKey( + item.column, + state.localSchema, + 'cursor' + ); + const property = properties[key]; + const info = property?.introspect(); + if ( + !info || + !['number', 'string', 'date', 'boolean'].includes(info.type) || + !info.isRequired || + info.isNullable + ) { + throw new Error( + 'Cursor columns must be declared non-null scalar fields' + ); + } + return { + key, + column: propToCol.get(key)!, + direction: item.direction, + type: info.type + }; + }); + if (new Set(order.map(item => item.column)).size !== order.length) + throw new Error('Duplicate cursor columns'); + const normalize = (keys: string[]) => + keys.map(key => propToCol.get(key) ?? key); + const uniqueKeys: string[][] = [ + [...getPrimaryKeyColumns(state.localSchema).columnNames], + ...Object.entries(properties) + .filter(([, p]: [string, any]) => p.getExtension('unique')) + .map(([key]) => [propToCol.get(key)!]), + ...(extensions?.uniques ?? []).map((entry: any) => + normalize(entry.columns) + ), + ...(extensions?.indexes ?? []) + .filter((entry: any) => entry.unique) + .map((entry: any) => normalize(entry.columns)) + ].filter(keys => keys.length); + if ( + !uniqueKeys.some(keys => + keys.every(key => order.some(item => item.column === key)) + ) + ) { + throw new Error( + 'Cursor order must contain a schema-declared primary or unique key' + ); + } + const identity = JSON.stringify([ + state.tableName, + order.map(({ column, direction, type }) => [column, direction, type]) + ]); + let values: string[] | undefined; + if (options.cursor != null) { + try { + if ( + typeof options.cursor !== 'string' || + !/^[A-Za-z0-9_-]+$/.test(options.cursor) + ) + throw new Error(); + const payload = JSON.parse( + Buffer.from(options.cursor, 'base64url').toString('utf8') + ); + if ( + payload.v !== 1 || + payload.order !== identity || + !Array.isArray(payload.values) || + payload.values.length !== order.length || + payload.values.some((v: unknown) => typeof v !== 'string') + ) + throw new Error(); + values = payload.values; + for (let i = 0; i < order.length; i++) { + const value = values![i]; + if ( + (order[i].type === 'number' && + !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test( + value + )) || + (order[i].type === 'boolean' && + !['true', 'false'].includes(value)) || + (order[i].type === 'date' && + !Number.isFinite(Date.parse(value))) + ) + throw new Error(); + } + } catch { + throw new Error('Invalid cursor or incompatible cursor ordering'); + } + } + const requiredRelations = state.specs.filter( + spec => spec.type === 'one' && spec.required + ); + if (values || requiredRelations.length) { + const filters = statements(sql).filter(s => s.grouping === 'where'); + sql.clear('where'); + if (filters.length) + sql.where(function () { + (this as any)._statements.push(...filters); + }); + } + // Required eager relations filter parents. Apply that existence condition + // before limit+1 so an unmatched parent cannot truncate the cursor stream. + const requiredAlias = privateColumn([state.tableName], 'required'); + for (const spec of requiredRelations) { + sql.whereExists( + state.knex + .from(spec.foreignQuery.clone().as(requiredAlias)) + .select(state.knex.raw('1')) + .whereRaw('?? = ??', [ + `${requiredAlias}.${spec.foreignColumn}`, + `${state.tableName}.${spec.localColumn}` + ]) + ); + } + if (values) { + const cursorValues = values; + sql.where(function () { + order.forEach((item, index) => { + this.orWhere(function () { + for (let before = 0; before < index; before++) { + this.where(order[before].column, cursorValues[before]); + } + this.where( + item.column, + item.direction === 'desc' ? '<' : '>', + cursorValues[index] + ); + }); + }); + }); + } + sql.clearOrder().clear('limit'); + const hidden: string[] = []; + if (!statements(sql).some(s => s.grouping === 'columns')) + sql.select(`${state.tableName}.*`); + for (const item of order) { + sql.orderBy(item.column, item.direction); + const key = privateColumn( + [ + ...colToProp.keys(), + ...Object.keys(state.projectionColumns ?? {}), + ...hidden, + ...state.hiddenColumns + ], + 'cursor' + ); + const value = state.knex.raw('cast(?? as text)', [item.column]); + sql.select({ [key]: value }); + state.explicitSelects?.push(key); + // Object projections must carry hidden cursor values through eager wrapping. + if (state.projectionColumns) state.projectionColumns[key] = value; + hidden.push(key); + state.hiddenColumns.add(key); + } + sql.limit(options.limit + 1); + const rows: Record[] = await getQuery(copy); + for (const row of rows) { + if (hidden.some(key => typeof row[key] !== 'string')) { + throw new Error( + 'Cursor sort value is null or not losslessly encoded' + ); + } + } + const hasMore = rows.length > options.limit; + const page = rows.slice(0, options.limit); + const last = page.at(-1); + const nextCursor = + hasMore && last + ? Buffer.from( + JSON.stringify({ + v: 1, + order: identity, + values: hidden.map(key => { + if (typeof last[key] !== 'string') + throw new Error( + 'Cursor sort value is null or not losslessly encoded' + ); + return last[key]; + }) + }) + ).toString('base64url') + : null; + return { + data: page.map(row => cleanAndMapRow(copy, row)), + nextCursor, + hasMore + }; +} diff --git a/libs/knex-schema/src/operations/helpers.ts b/libs/knex-schema/src/operations/helpers.ts index cbaf6b9e..f47c5ad9 100644 --- a/libs/knex-schema/src/operations/helpers.ts +++ b/libs/knex-schema/src/operations/helpers.ts @@ -26,6 +26,7 @@ import type { ResolvedVariantRelationSpec, ValidatedSpec } from '../types.js'; +import { compileOrder, privateColumn } from './ordering.js'; import { getState } from './state.js'; // --------------------------------------------------------------------------- @@ -751,32 +752,74 @@ export function buildQuery( if (state.specs.length === 0) { return queryBase; } + if (Object.keys(state.projectionDecoders).length) { + throw new Error( + 'Aggregate projections cannot include entity relations; use a flat grouped query' + ); + } const knex = state.knex; const specs = state.specs; const requiredLocalColumns = [...new Set(specs.map(s => s.localColumn))]; - let cteQuery = queryBase; + const cteQuery = queryBase.clone(); let extraColumns: string[] = []; if (state.explicitSelects !== null) { - const selectedSet = new Set(state.explicitSelects); + const selectedSet = new Set( + state.projectionColumns + ? Object.keys(state.projectionColumns) + : state.explicitSelects + ); extraColumns = requiredLocalColumns.filter( col => !selectedSet.has(col) ); if (extraColumns.length > 0) { - cteQuery = queryBase.clone(); for (const col of extraColumns) { cteQuery.column(col); } } } + const order = compileOrder( + knex, + queryBase, + state.projectionColumns, + state.explicitSelects + ); + const ordinal = privateColumn( + [ + ...buildColumnMap(state.localSchema).colToProp.keys(), + ...Object.keys(state.projectionColumns ?? {}), + ...state.hiddenColumns + ], + 'order' + ); + if (order) { + // DENSE_RANK, unlike ROW_NUMBER, does not turn equal DISTINCT rows + // into different rows. The outer join cannot discard the requested order. + if ( + !(cteQuery as any)._statements.some( + (s: any) => s.grouping === 'columns' + ) + ) { + cteQuery.select(`${state.tableName}.*`); + } + cteQuery.select( + knex.raw('dense_rank() over (?) as ??', [order, ordinal]) + ); + state.hiddenColumns.add(ordinal); + } const resultQuery = knex.queryBuilder().with('originalQuery', cteQuery); - if (extraColumns.length > 0 && state.explicitSelects !== null) { - for (const col of state.explicitSelects) { + if ( + state.projectionColumns || + (extraColumns.length > 0 && state.explicitSelects !== null) + ) { + for (const col of state.projectionColumns + ? Object.keys(state.projectionColumns) + : state.explicitSelects!) { resultQuery.select( knex.raw(':originalQuery:.:col: as :col:', { originalQuery: 'originalQuery', @@ -805,6 +848,8 @@ export function buildQuery( } } + if (order) resultQuery.orderBy(`originalQuery.${ordinal}`, 'asc'); + return resultQuery; } @@ -856,8 +901,13 @@ export function cleanAndMapRow( const manySpecs = state.specs.filter( (s): s is ValidatedSpec & { type: 'many' } => s.type === 'many' ); - const cleaned = clearRow(row, oneSpecs, manySpecs); - return mapRow(builder, cleaned); + const cleaned = clearRow({ ...row }, oneSpecs, manySpecs); + for (const key of state.hiddenColumns) delete cleaned[key]; + for (const [key, decode] of Object.entries(state.projectionDecoders)) { + if (Object.hasOwn(cleaned, key)) cleaned[key] = decode(cleaned[key]); + } + // An explicit DTO alias is already the public property name. + return state.projectionColumns ? cleaned : mapRow(builder, cleaned); } export function mapObjectToColumns( diff --git a/libs/knex-schema/src/operations/insert.ts b/libs/knex-schema/src/operations/insert.ts index 5961a244..83b18033 100644 --- a/libs/knex-schema/src/operations/insert.ts +++ b/libs/knex-schema/src/operations/insert.ts @@ -81,11 +81,18 @@ export interface OnConflictMergeOptions { ) => void; } +/** + * Configure one-row conflict handling after SchemaQueryBuilder.onConflict(); merge()/ignore() execute the insert. + */ export class OnConflictBuilder { readonly #knex: Knex; readonly #localSchema: TLocalSchema; readonly #conflictColumns: string[]; + /** + * Create conflict handling for a schema and resolved conflict columns. + * Prefer query(...).onConflict(...) to map property references automatically. + */ constructor( knex: Knex, localSchema: TLocalSchema, @@ -97,15 +104,33 @@ export class OnConflictBuilder { this.#conflictColumns = conflictColumns; } + /** + * Insert a row or merge values into the conflicting row. + * Without an explicit update payload, merges inserted values; a supplied payload + * can contain raw or helper-built expressions. options.where guards the update. + * Uses the configured connection/transaction and returns the mapped returned row. + */ async merge( data: InsertType, options?: OnConflictMergeOptions ): Promise; + /** + * Insert a row or merge values into the conflicting row. + * Without an explicit update payload, merges inserted values; a supplied payload + * can contain raw or helper-built expressions. options.where guards the update. + * Uses the configured connection/transaction and returns the mapped returned row. + */ async merge( data: InsertType, updateData?: OnConflictUpdateData, options?: OnConflictMergeOptions ): Promise; + /** + * Insert a row or merge values into the conflicting row. + * Without an explicit update payload, merges inserted values; a supplied payload + * can contain raw or helper-built expressions. options.where guards the update. + * Uses the configured connection/transaction and returns the mapped returned row. + */ async merge( data: InsertType, updateData?: @@ -132,6 +157,10 @@ export class OnConflictBuilder { ) as Promise; } + /** + * Insert the row but do nothing on conflict. + * @returns The mapped inserted row, or undefined when PostgreSQL returns no row. + */ async ignore(data: InsertType): Promise { return this.#execute(data, 'ignore'); } diff --git a/libs/knex-schema/src/operations/ordering.test.ts b/libs/knex-schema/src/operations/ordering.test.ts new file mode 100644 index 00000000..ba8a4060 --- /dev/null +++ b/libs/knex-schema/src/operations/ordering.test.ts @@ -0,0 +1,39 @@ +import Knex from 'knex'; +import { afterAll, describe, expect, it } from 'vitest'; +import { compileOrder } from './ordering.js'; + +const knex = Knex({ client: 'pg' }); +afterAll(() => knex.destroy()); + +describe('eager ordering compilation', () => { + it('expands output aliases/positions while retaining raw SQL bindings', () => { + const source = knex('tasks').orderByRaw( + 'coalesce(??, ?), ?? desc nulls last, 1 asc', + ['title', 'a,b', 'taskId'] + ); + const compiled = compileOrder(knex, source, { taskId: 'id' })!.toSQL(); + expect(compiled.sql).toBe( + 'order by coalesce("title", ?), "id" desc nulls last, "id" asc' + ); + expect(compiled.bindings).toEqual(['a,b']); + }); + + it('does not rewrite commas/alias text inside literals or qualified columns', () => { + const source = knex('tasks').orderByRaw( + 'coalesce("title", \', taskId\'), length($tag$, taskId$tag$), tasks."taskId", "taskId" desc' + ); + expect(compileOrder(knex, source, { taskId: 'id' })!.toSQL().sql).toBe( + 'order by coalesce("title", \', taskId\'), length($tag$, taskId$tag$), tasks."taskId", "id" desc' + ); + }); + + it('resolves positions in ordinary selections and leaves unsorted queries alone', () => { + expect(compileOrder(knex, knex('tasks'))).toBe(null); + expect( + compileOrder(knex, knex('tasks').orderByRaw('2 desc'), null, [ + 'title', + 'id' + ])!.toSQL().sql + ).toBe('order by "id" desc'); + }); +}); diff --git a/libs/knex-schema/src/operations/ordering.ts b/libs/knex-schema/src/operations/ordering.ts new file mode 100644 index 00000000..d9253183 --- /dev/null +++ b/libs/knex-schema/src/operations/ordering.ts @@ -0,0 +1,71 @@ +import type { Knex } from 'knex'; +import { statements } from './aggregate.js'; + +/** Compile only ORDER BY, retaining Knex's identifier quoting and value bindings. */ +export function compileOrder( + knex: Knex, + source: Knex.QueryBuilder, + projection: Record | null = null, + selected: readonly string[] | null = null +): Knex.Raw | null { + const order = statements(source).filter(s => s.grouping === 'order'); + if (!order.length) return null; + const isolated = knex.queryBuilder(); + (isolated as any)._statements = order; + const compiled = isolated.toSQL(); + const prefix = 'select * '; + if (!compiled.sql.startsWith(prefix)) + throw new Error('Unsupported Knex ordering compiler'); + // SELECT aliases and positional references are visible to a query's ORDER + // BY, but not a window's ORDER BY. Expand known projection references to + // physical columns without touching expressions, literals or bindings. + const columns = projection ? Object.values(projection) : selected; + const clause = compiled.sql.slice(`${prefix}order by `.length); + const terms = splitOrderTerms(clause).map(term => { + const match = + /^("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_]*|\d+)(\s+(?:asc|desc))?(\s+nulls\s+(?:first|last))?$/i.exec( + term.trim() + ); + if (!match) return term; + const name = match[1].startsWith('"') + ? match[1].slice(1, -1).replaceAll('""', '"') + : match[1].toLowerCase(); + const column = /^\d+$/.test(match[1]) + ? columns?.[Number(match[1]) - 1] + : projection && Object.hasOwn(projection, name) + ? projection[name] + : undefined; + if (typeof column !== 'string') return term; + return `${knex.ref(column).toSQL().sql}${match[2] ?? ''}${match[3] ?? ''}`; + }); + return knex.raw(`order by ${terms.join(', ')}`, compiled.bindings as any[]); +} + +/** Split only top-level SQL commas; quoted SQL fragments are opaque. */ +function splitOrderTerms(sql: string): string[] { + const tokens = + /\$(\w*)\$[\s\S]*?\$\1\$|'(?:''|\\.|[^'])*'|"(?:""|[^"])*"|--[^\n]*|\/\*[\s\S]*?\*\/|[(),]/g; + const terms: string[] = []; + let depth = 0; + let start = 0; + for (const token of sql.matchAll(tokens)) { + if (token[0] === '(') depth++; + else if (token[0] === ')') depth--; + else if (token[0] === ',' && depth === 0) { + terms.push(sql.slice(start, token.index)); + start = token.index + 1; + } + } + terms.push(sql.slice(start)); + return terms; +} + +export function privateColumn( + existing: Iterable, + label: string +): string { + const used = new Set(existing); + let index = 0; + while (used.has(`__cb_${label}_${index}`)) index++; + return `__cb_${label}_${index}`; +} diff --git a/libs/knex-schema/src/operations/select.ts b/libs/knex-schema/src/operations/select.ts index 8896b2d9..223631bc 100644 --- a/libs/knex-schema/src/operations/select.ts +++ b/libs/knex-schema/src/operations/select.ts @@ -6,6 +6,7 @@ import { } from '@cleverbrush/schema'; import type { Knex } from 'knex'; import { buildColumnMap } from '../columns.js'; +import { compileAggregate, isAggregate } from '../expressions.js'; import { getProjections } from '../extension.js'; import type { SchemaQueryBuilder } from '../SchemaQueryBuilder.js'; import type { ColumnRef } from '../types.js'; @@ -40,11 +41,26 @@ export function selectImpl( state.selectionMode = 'projection'; state.appliedProjection = ''; - const aliasMap: Record = {}; + const aliasMap: Record = {}; state.explicitSelects ??= []; for (const [alias, descriptor] of Object.entries( result as Record )) { + if (isAggregate(descriptor)) { + const compiled = compileAggregate( + state.knex, + descriptor, + column => + resolveColumn( + builder, + () => column, + `select(selector).${alias}` + ) + ); + aliasMap[alias] = compiled.sql; + state.projectionDecoders[alias] = compiled.decode; + continue; + } if ( !descriptor || typeof descriptor !== 'object' || @@ -65,6 +81,7 @@ export function selectImpl( aliasMap[alias] = col as string; state.explicitSelects.push(col as string); } + state.projectionColumns = aliasMap; state.baseQuery.select(aliasMap); return builder; } diff --git a/libs/knex-schema/src/operations/state.ts b/libs/knex-schema/src/operations/state.ts index c257aa10..18d18c57 100644 --- a/libs/knex-schema/src/operations/state.ts +++ b/libs/knex-schema/src/operations/state.ts @@ -24,6 +24,10 @@ export interface QueryBuilderState { /** Name of the projection currently applied, for error messages. */ appliedProjection: string | null; + /** Output aliases and decoders for typed object projections. */ + projectionColumns: Record | null; + projectionDecoders: Record unknown>; + hiddenColumns: Set; /** When true, soft-delete filter is NOT applied. */ includeDeleted: boolean; diff --git a/libs/knex-schema/src/public-api-docs.test.ts b/libs/knex-schema/src/public-api-docs.test.ts new file mode 100644 index 00000000..506bc058 --- /dev/null +++ b/libs/knex-schema/src/public-api-docs.test.ts @@ -0,0 +1,145 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +/** Inspect emitted declarations, since those are what installed consumers read. */ +function declaration(path: string): ts.SourceFile { + const url = new URL(path, import.meta.url); + return ts.createSourceFile( + fileURLToPath(url), + readFileSync(url, 'utf8'), + ts.ScriptTarget.Latest, + true + ); +} + +function hasSummary(node: ts.Node): boolean { + const docs = (node as ts.Node & { jsDoc?: readonly ts.JSDoc[] }).jsDoc; + return !!docs?.some(doc => doc.comment); +} + +function isInternal(node: ts.Node): boolean { + return ts.getJSDocTags(node).some(tag => tag.tagName.text === 'internal'); +} + +describe('published query/ORM API documentation', () => { + it('preserves JSDoc for exported functions and every public class member', () => { + const missing: string[] = []; + for (const entry of [ + '../dist/index.d.ts', + '../../orm/dist/index.d.ts' + ]) { + const index = declaration(entry); + for (const statement of index.statements) { + if ( + !ts.isExportDeclaration(statement) || + !statement.exportClause || + !ts.isNamedExports(statement.exportClause) || + !statement.moduleSpecifier || + !ts.isStringLiteral(statement.moduleSpecifier) || + !statement.moduleSpecifier.text.startsWith('.') + ) + continue; + const names = new Set( + statement.exportClause.elements.map( + e => (e.propertyName ?? e.name).text + ) + ); + const moduleUrl = new URL( + statement.moduleSpecifier.text.replace(/\.js$/, '.d.ts'), + new URL(entry, import.meta.url) + ); + check(declaration(moduleUrl.href), names); + } + } + // Reachable through onConflict(), even though not a root named export. + check( + declaration('../dist/operations/insert.d.ts'), + new Set(['OnConflictBuilder']) + ); + expect(missing).toEqual([]); + + function check(source: ts.SourceFile, names: Set): void { + for (const node of source.statements) { + if ( + ts.isVariableStatement(node) && + node.declarationList.declarations.some(d => + names.has(d.name.getText(source)) + ) && + !isInternal(node) && + !hasSummary(node) + ) { + missing.push( + node.declarationList.declarations[0].name.getText( + source + ) + ); + } + if ( + (ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node)) && + node.name && + names.has(node.name.text) && + !isInternal(node) + ) { + if (!hasSummary(node)) missing.push(node.name.text); + if (ts.isClassDeclaration(node)) { + for (const member of node.members) { + if ( + ts.getCombinedModifierFlags(member) & + (ts.ModifierFlags.Private | + ts.ModifierFlags.Protected) || + (member.name && + ts.isPrivateIdentifier(member.name)) || + isInternal(member) + ) + continue; + if (!hasSummary(member)) + missing.push( + `${node.name.text}.${member.name?.getText(source) ?? 'constructor'}` + ); + } + } + } + } + } + }); + + it('documents aggregate expressions, bound factory overloads and cursor options', () => { + for (const [path, name] of [ + ['../dist/expressions.d.ts', 'aggregate'], + ['../dist/SchemaQueryBuilder.d.ts', 'BoundQuery'], + [ + '../dist/operations/composite-cursor.d.ts', + 'CompositeCursorOptions' + ] + ]) { + const source = declaration(path); + const missing: string[] = []; + function visit(node: ts.Node): void { + if ( + (ts.isMethodSignature(node) || + ts.isPropertySignature(node) || + ts.isCallSignatureDeclaration(node)) && + !isInternal(node) && + !hasSummary(node) + ) { + missing.push(node.getText(source).split('\n')[0]); + } + ts.forEachChild(node, visit); + } + const node = source.statements.find( + n => + (ts.isInterfaceDeclaration(n) && n.name.text === name) || + (ts.isVariableStatement(n) && + n.declarationList.declarations.some( + d => d.name.getText(source) === name + )) + ); + expect(node, name).toBeDefined(); + visit(node!); + expect(missing, name).toEqual([]); + } + }); +}); diff --git a/libs/knex-schema/src/sql-identifiers.test.ts b/libs/knex-schema/src/sql-identifiers.test.ts new file mode 100644 index 00000000..5d82666f --- /dev/null +++ b/libs/knex-schema/src/sql-identifiers.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { alias, isSqlIdentifier, number, object } from './index.js'; + +describe('isSqlIdentifier', () => { + it.each([ + 'task', + '_task', + 'task_owner2', + 'TaskOwner', + 'select' + ])('accepts the supported single-name format: %s', name => + expect(isSqlIdentifier(name)).toBe(true)); + it.each([ + '', + '2tasks', + 'public.tasks', + 'task owner', + 'task-owner', + 'task;drop', + 'task"owner', + '"task"', + 'tâche', + 'task\n', + null, + undefined, + 42, + {}, + { toString: () => 'task' } + ])('rejects invalid names without coercing input: %s', value => { + expect(isSqlIdentifier(value)).toBe(false); + }); + it('makes alias creation use the same validation', () => { + const schema = object({ id: number() }).hasTableName('tasks'); + for (const name of [ + 'task.owner', + '', + undefined, + { toString: () => 'task' } + ]) { + expect(() => alias(schema, name as string)).toThrow( + 'SQL identifier' + ); + } + expect(alias(schema, 'task_2').name).toBe('task_2'); + }); +}); diff --git a/libs/knex-schema/src/sql-identifiers.ts b/libs/knex-schema/src/sql-identifiers.ts new file mode 100644 index 00000000..ee74e4c0 --- /dev/null +++ b/libs/knex-schema/src/sql-identifiers.ts @@ -0,0 +1,18 @@ +/** + * Test whether a value is a supported, single SQL identifier, such as a table + * alias. Accepts ASCII letters/underscores followed by letters/digits/underscores. + * + * This deliberately excludes qualified names, quoted identifiers and Unicode; + * it is not a validator for every identifier accepted by a database dialect. + * Keywords are accepted because query builders quote identifiers. Always keep + * using identifier bindings (`??`), even after this check succeeds. + * + * @param value - Untrusted value to test without coercing it to a string. + * @returns Whether the value is a string in Framework's supported format. + * @example + * isSqlIdentifier('task_owner'); // true + * isSqlIdentifier('public.tasks'); // false: qualified, not a single name + */ +export function isSqlIdentifier(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); +} diff --git a/libs/knex-schema/src/types.ts b/libs/knex-schema/src/types.ts index cf2cdc7e..9832f2af 100644 --- a/libs/knex-schema/src/types.ts +++ b/libs/knex-schema/src/types.ts @@ -7,10 +7,14 @@ import type { PropertyDescriptorTree } from '@cleverbrush/schema'; import type { Knex } from 'knex'; +import type { AggregateExpression } from './expressions.js'; // --------------------------------------------------------------------------- // Utility: extract string keys from an ObjectSchemaBuilder's inferred type // --------------------------------------------------------------------------- +/** + * String property names available on a schema-inferred row, before SQL column-name mapping. + */ export type SchemaKeys< T extends ObjectSchemaBuilder > = keyof InferType & string; @@ -34,6 +38,9 @@ type SchemaBase< ? ObjectSchemaBuilder : never; +/** + * Reference a schema property by key or typed descriptor selector; nested selectors may address JSON paths. + */ export type ColumnRef< T extends ObjectSchemaBuilder > = @@ -45,6 +52,9 @@ export type ColumnRef< // --------------------------------------------------------------------------- // JoinOne specification — single related object (N:1 / belongsTo / hasOne) // --------------------------------------------------------------------------- +/** + * Configure a single nested eager relation, including its join keys, requiredness and foreign query. + */ export interface JoinOneSpec< TLocalSchema extends ObjectSchemaBuilder, TForeignSchema extends ObjectSchemaBuilder< @@ -80,6 +90,9 @@ export interface JoinOneSpec< // --------------------------------------------------------------------------- // JoinMany specification — collection of related objects (1:N / hasMany) // --------------------------------------------------------------------------- +/** + * Configure a nested eager collection with child-level ordering/paging independent of parent paging. + */ export interface JoinManySpec< TLocalSchema extends ObjectSchemaBuilder, TForeignSchema extends ObjectSchemaBuilder< @@ -162,26 +175,77 @@ export type WithJoinedMany< // --------------------------------------------------------------------------- // Internal validated spec (after runtime validation) // --------------------------------------------------------------------------- +/** + * Normalized one-object relation specification with resolved SQL names and an executable foreign query. + */ export interface ValidatedJoinOneSpec { + /** + * Resolved SQL join column on the parent table. + */ localColumn: string; + /** + * Resolved SQL join column on the related table. + */ foreignColumn: string; + /** + * Result property receiving the nested related object. + */ as: string; + /** + * Whether unmatched parent rows are excluded by an inner join. + */ required: boolean; + /** + * Prepared Knex query describing eligible related rows. + */ foreignQuery: Knex.QueryBuilder; + /** + * Optional related-field conversions applied after SQL returns. + */ mappers?: Record any) | string>; } +/** + * Normalized collection relation specification consumed by the eager-loading compiler. + */ export interface ValidatedJoinManySpec { + /** + * Resolved parent SQL column matched to each child group. + */ localColumn: string; + /** + * Resolved child SQL column used to group related rows. + */ foreignColumn: string; + /** + * Result property receiving the array of related rows. + */ as: string; + /** + * Prepared Knex query describing eligible children. + */ foreignQuery: Knex.QueryBuilder; + /** + * Maximum children per parent, or null for no child limit. + */ limit: number | null; + /** + * Children skipped per parent, or null for no offset. + */ offset: number | null; + /** + * Resolved child ordering, or null to leave child order unspecified. + */ orderBy: { column: string; direction: 'asc' | 'desc' } | null; + /** + * Optional child-field conversions applied after SQL returns. + */ mappers?: Record any) | string>; } +/** + * Discriminated normalized eager-relation specification used by row-mapping helpers. + */ export type ValidatedSpec = | ({ type: 'one' } & ValidatedJoinOneSpec) | ({ type: 'many' } & ValidatedJoinManySpec); @@ -189,6 +253,9 @@ export type ValidatedSpec = // --------------------------------------------------------------------------- // InsertType — all properties optional (DB may generate some, e.g. SERIAL id) // --------------------------------------------------------------------------- +/** + * Schema-shaped insert payload with optional properties so database-generated/defaulted values may be omitted. + */ export type InsertType< T extends ObjectSchemaBuilder > = InferType>; @@ -351,7 +418,9 @@ type DescriptorPropertySchema = * @public */ export type SelectProjection> = { - [K in keyof R]: InferType>; + [K in keyof R]: R[K] extends AggregateExpression + ? T + : InferType>; }; /** @@ -365,7 +434,10 @@ export type SelectSelector< T extends ObjectSchemaBuilder > = ( tree: PropertyDescriptorTree, SchemaBase> -) => Record>; +) => Record< + string, + PropertyDescriptor | AggregateExpression +>; // --------------------------------------------------------------------------- // Pagination result types @@ -408,6 +480,9 @@ export type VariantStorageType = 'cti' | 'sti'; /** @internal Resolved form of a variant relation stored on {@link ResolvedVariantSpec}. */ export interface ResolvedVariantRelationSpec { + /** + * Physical column name reported by PostgreSQL. + */ name: string; type: 'hasMany' | 'hasOne' | 'belongsTo' | 'belongsToMany'; schema: any; @@ -471,42 +546,105 @@ export interface RelationSpec { /** Column information read from the database. */ export interface DatabaseColumnInfo { name: string; + /** + * Database type name used for schema comparison. + */ type: string; + /** + * Whether SQL NULL is accepted by this column. + */ nullable: boolean; + /** + * Database default expression, or null when no default is declared. + */ defaultValue: string | null; + /** + * Declared character limit, or null when the type has no such limit. + */ maxLength: number | null; + /** + * Declared numeric precision, or null when unavailable/not applicable. + */ numericPrecision: number | null; } /** Index information read from the database. */ export interface DatabaseIndexInfo { + /** + * Physical database index name. + */ name: string; + /** + * Indexed column names in database-reported order. + */ columns: string[]; + /** + * Whether this index enforces uniqueness. + */ unique: boolean; + /** + * Database-provided SQL definition of the index. + */ definition: string; } /** Foreign key information read from the database. */ export interface DatabaseForeignKeyInfo { + /** + * Physical foreign-key constraint name. + */ constraintName: string; + /** + * Referencing SQL column on the inspected table. + */ columnName: string; + /** + * Referenced table name. + */ foreignTable: string; + /** + * Referenced SQL column name. + */ foreignColumn: string; + /** + * Database ON DELETE action, such as CASCADE or NO ACTION. + */ deleteRule: string; + /** + * Database ON UPDATE action for referenced-key changes. + */ updateRule: string; } /** Check constraint information read from the database. */ export interface DatabaseCheckInfo { + /** + * Physical CHECK constraint name. + */ name: string; + /** + * Database-provided CHECK expression/definition. + */ definition: string; } /** Full database table state from introspection. */ export interface DatabaseTableState { + /** + * Column metadata keyed by physical SQL column name. + */ columns: Record; + /** + * Indexes discovered for the table. + */ indexes: DatabaseIndexInfo[]; + /** + * Foreign-key constraints discovered for the table. + */ foreignKeys: DatabaseForeignKeyInfo[]; + /** + * CHECK constraints discovered for the table. + */ checks: DatabaseCheckInfo[]; } @@ -524,6 +662,9 @@ export interface DatabaseTableState { * @public */ export interface SchemaSnapshot { + /** + * Snapshot format version; currently 1. + */ version: 1; /** Map of table name → database state derived from entity schemas. */ tables: Record; @@ -535,44 +676,116 @@ export interface SchemaSnapshot { /** A column to add in a migration. */ export interface AddColumnDiff { + /** + * Physical SQL name of the new column. + */ name: string; + /** + * SQL type to create, including any declared length/precision. + */ type: string; + /** + * Whether the new column permits SQL NULL. + */ nullable: boolean; + /** + * Optional default value or expression metadata for the new column. + */ defaultValue?: any; + /** + * Optional referenced table/column for a new foreign key. + */ references?: { table: string; column: string }; + /** + * Optional ON DELETE action for the reference. + */ onDelete?: string; + /** + * Optional ON UPDATE action for the reference. + */ onUpdate?: string; } /** Changes to apply to an existing column. */ export interface AlterColumnDiff { + /** + * Physical SQL name of the column to alter. + */ name: string; + /** + * Changed attributes with their previous and desired values. + */ changes: Record; } /** An index to add in a migration. */ export interface AddIndexDiff { + /** + * Physical column names forming the index, in order. + */ columns: string[]; + /** + * Optional explicit index name; otherwise migration generation chooses one. + */ name?: string; + /** + * Whether the new index must enforce uniqueness. + */ unique?: boolean; } /** A foreign key to add in a migration. */ export interface AddForeignKeyDiff { + /** + * Referencing SQL column on the table being altered. + */ column: string; + /** + * Referenced table name. + */ foreignTable: string; + /** + * Referenced SQL column name. + */ foreignColumn: string; + /** + * Optional ON DELETE action. + */ onDelete?: string; + /** + * Optional ON UPDATE action. + */ onUpdate?: string; } /** Schema diff result between the code-first model and the live database. */ export interface MigrationDiff { + /** + * New column definitions to create. + */ addColumns: AddColumnDiff[]; + /** + * Physical column names to remove; reviewing these avoids unintended data loss. + */ dropColumns: string[]; + /** + * Existing columns whose attributes must change. + */ alterColumns: AlterColumnDiff[]; + /** + * New indexes to create. + */ addIndexes: AddIndexDiff[]; + /** + * Physical index names to remove. + */ dropIndexes: string[]; + /** + * New foreign-key constraints to create. + */ addForeignKeys: AddForeignKeyDiff[]; + /** + * Physical foreign-key constraint names to remove. + */ dropForeignKeys: string[]; } diff --git a/libs/knex-schema/tsconfig.build.json b/libs/knex-schema/tsconfig.build.json index 7954e377..2082ba2d 100644 --- a/libs/knex-schema/tsconfig.build.json +++ b/libs/knex-schema/tsconfig.build.json @@ -12,5 +12,5 @@ "declarationMap": false }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.test-d.ts"] } diff --git a/libs/knex-schema/tsconfig.typecheck.json b/libs/knex-schema/tsconfig.typecheck.json new file mode 100644 index 00000000..c24a9fd6 --- /dev/null +++ b/libs/knex-schema/tsconfig.typecheck.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { "noEmit": true, "strict": true }, + "include": ["src/**/*.test-d.ts"], + "exclude": [] +} diff --git a/libs/knex-schema/vitest.config.mts b/libs/knex-schema/vitest.config.mts new file mode 100644 index 00000000..c2329925 --- /dev/null +++ b/libs/knex-schema/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + typecheck: { + enabled: true, + include: ['src/**/*.test-d.ts'], + tsconfig: './tsconfig.typecheck.json' + } + } +}); diff --git a/libs/orm/README.md b/libs/orm/README.md index 568115e2..35226880 100644 --- a/libs/orm/README.md +++ b/libs/orm/README.md @@ -344,7 +344,37 @@ npx cb-orm validate --- -## See also +## Composable queries + +ORM re-exports `alias`, `eq`, `and`, `or`, and `aggregate`. Use +`query(db.knex, alias(TaskSchema, 'task'))` for flat DTO joins and `include` for +nested relations. Transaction-bound contexts expose their transaction as `db.knex`. + +`countValue`, `countDistinctValue`, `sumValue`, `avgValue`, `minValue`, and +`maxValue` are available on entity queries. An optional `{ output: schema }` +replaces default decoding and controls the inferred output, including nulls. +Aggregate/DTO projections are not attached as entities in tracked contexts. + +```ts +const tasks = await db.tasks + .orderBy(t => t.createdAt, 'desc') + .orderBy(t => t.id, 'desc') + .include(t => t.owner, owners => { + owners.where(t => t.name, 'Alice'); // foreign schema, not any + }) + .limit(20); +``` + +Eager loading retains parent order and page size. Filtering an included relation +does not necessarily filter parents. Callback types infer the declared foreign +schema, including variant queries when the relation schema is known. + +`paginateAfter({ limit, cursor, orderBy: [...] })` supports non-null scalar sorts +with a declared unique tie-breaker; single-column cursor calls are unchanged. +See the [complete query guide](../knex-schema/COMPOSABLE_QUERIES.md) for defaults, +precision policy, grouped aggregates, cursor restrictions, and migration examples. + +## Related packages - [`@cleverbrush/knex-schema`](../knex-schema) — the underlying schema DSL and query builder diff --git a/libs/orm/src/dbcontext.ts b/libs/orm/src/dbcontext.ts index 1078aa1b..1ac50861 100644 --- a/libs/orm/src/dbcontext.ts +++ b/libs/orm/src/dbcontext.ts @@ -297,11 +297,19 @@ export function createDb( entities: TMap, opts: { tracking: true } ): TrackedDbContext; +/** + * Create a database context with a fresh typed query starter for each entity. + * @param knex - Database connection or transaction. + * @param entities - Named entity definitions exposed as context properties. + * @param opts - Enable tracking to add identity-map and explicit saveChanges() behavior. + * @returns A non-tracking context by default; the tracking overload adds unit-of-work APIs. + */ export function createDb( knex: Knex, entities: TMap, opts?: { tracking?: false | undefined } ): DbContext; +/** Create the tracking or non-tracking context selected by opts.tracking. */ export function createDb( knex: Knex, entities: TMap, diff --git a/libs/orm/src/dbset.ts b/libs/orm/src/dbset.ts index 5364b0f8..2369ce8d 100644 --- a/libs/orm/src/dbset.ts +++ b/libs/orm/src/dbset.ts @@ -23,11 +23,14 @@ import { type SchemaQueryBuilder, query as schemaQuery } from '@cleverbrush/knex-schema'; +import type { InferType } from '@cleverbrush/schema'; import type { Knex } from 'knex'; import { EntityNotFoundError } from './errors.js'; + import type { EntityResult, + RelatedSchema, RelKeyTree, SaveGraph, VariantInsertPayload, @@ -43,6 +46,15 @@ import { updateVariant as _updateVariant } from './variant-write.js'; +const scalarAggregateMethods = new Set([ + 'countValue', + 'countDistinctValue', + 'sumValue', + 'avgValue', + 'minValue', + 'maxValue' +]); + // --------------------------------------------------------------------------- // EntityQuery — public typed query handle // --------------------------------------------------------------------------- @@ -70,7 +82,12 @@ export interface EntityQuery, TResult> */ include & string>( sel: (t: RelKeyTree) => K, - customize?: (q: SchemaQueryBuilder) => void + customize?: ( + q: SchemaQueryBuilder< + RelatedSchema, + InferType> + > + ) => void ): EntityQuery>; /** @@ -83,7 +100,14 @@ export interface EntityQuery, TResult> includeVariant( variantKey: TVariant, relationName: TRel, - customize?: (q: SchemaQueryBuilder) => void + customize?: ( + q: TRel extends keyof EntityRelations + ? SchemaQueryBuilder< + RelatedSchema, + InferType> + > + : SchemaQueryBuilder + ) => void ): EntityQuery< TEntity, TRel extends keyof EntityRelations & string @@ -232,15 +256,33 @@ export interface VariantDbSet< > { // Re-declared so that `this` resolves to `VariantDbSet` // rather than the raw `SchemaQueryBuilder` (Omit doesn't preserve `this`). + /** + * Add an AND filter on this polymorphic branch using a mapped property, record or Knex callback. + */ where( column: ColumnRef>, operator: string, value: any ): this; + /** + * Add an AND filter on this polymorphic branch using a mapped property, record or Knex callback. + */ where(column: ColumnRef>, value: any): this; + /** + * Add an AND filter on this polymorphic branch using a mapped property, record or Knex callback. + */ where(raw: Knex.Raw, operator: string, value: any): this; + /** + * Add an AND filter on this polymorphic branch using a mapped property, record or Knex callback. + */ where(callback: (builder: Knex.QueryBuilder) => void): this; + /** + * Add an AND filter on this polymorphic branch using a mapped property, record or Knex callback. + */ where(record: Record): this; + /** + * Add an AND filter on this polymorphic branch using a mapped property, record or Knex callback. + */ where(raw: Knex.Raw): this; /** * Eager-load a relation declared on `TEntity`. Identical to @@ -248,7 +290,12 @@ export interface VariantDbSet< */ include & string>( sel: (t: RelKeyTree) => R, - customize?: (q: SchemaQueryBuilder) => void + customize?: ( + q: SchemaQueryBuilder< + RelatedSchema, + InferType> + > + ) => void ): VariantDbSet; /** Look up a single row by PK, typed to this variant. */ @@ -417,6 +464,8 @@ function wrapQuery, TResult>( // Wrap Promise results to auto-attach tracked entities. if ( onResults != null && + sqb.returnsEntityRows && + !scalarAggregateMethods.has(prop) && result != null && typeof (result as any).then === 'function' ) { @@ -910,6 +959,8 @@ function wrapVariantQuery< if (result === sqb) return proxy; if ( onResults != null && + sqb.returnsEntityRows && + !scalarAggregateMethods.has(prop) && result != null && typeof (result as any).then === 'function' ) { diff --git a/libs/orm/src/errors.ts b/libs/orm/src/errors.ts index 893d5f65..8307a051 100644 --- a/libs/orm/src/errors.ts +++ b/libs/orm/src/errors.ts @@ -9,9 +9,18 @@ * @public */ export class EntityNotFoundError extends Error { + /** + * Table/entity label identifying which lookup failed. + */ readonly entity: string; + /** + * Requested primary-key value, or ordered tuple for a composite key. + */ readonly pk: unknown; + /** + * Describe a failed entity lookup while retaining its entity label and requested key. + */ constructor(entity: string, pk: unknown) { super( `Entity "${entity}" not found for primary key ${JSON.stringify(pk)}` @@ -37,6 +46,9 @@ export class ConcurrencyError extends Error { /** The row-version value the ORM expected. */ readonly expected: unknown; + /** + * Describe an optimistic-concurrency failure with the key and row-version value expected by the caller. + */ constructor(entity: string, pk: unknown, expected: unknown) { super( `Concurrency conflict on "${entity}" (pk=${JSON.stringify(pk)}): ` + @@ -57,10 +69,22 @@ export class ConcurrencyError extends Error { * @public */ export class InvariantViolationError extends Error { + /** + * Table/entity label whose tracked identity invariant was violated. + */ readonly entity: string; + /** + * Primary-key value(s) identifying the affected tracked entity. + */ readonly pk: unknown; + /** + * Immutable identity/discriminator property that was changed. + */ readonly field: string; + /** + * Describe an invalid tracked identity change, preserving its entity, key and field for diagnostics. + */ constructor(entity: string, pk: unknown, field: string, detail: string) { super( `Invariant violation on "${entity}" (pk=${JSON.stringify(pk)}): ${detail}` @@ -85,6 +109,9 @@ export class PendingChangesError extends Error { /** Number of dirty / added / deleted entries. */ readonly pendingCount: number; + /** + * Describe disposal with unsaved changes; pendingCount is the number of dirty entries and summary explains them. + */ constructor(pendingCount: number, summary: string) { super( `DbContext disposed with ${pendingCount} pending change(s). ` + diff --git a/libs/orm/src/index.ts b/libs/orm/src/index.ts index a26814f1..e58fda81 100644 --- a/libs/orm/src/index.ts +++ b/libs/orm/src/index.ts @@ -30,6 +30,7 @@ export type { EntityResultByVariant, ExtractBranch, HasVariants, + RelatedSchema, RelKeyTree, ResolvedRel, SaveGraph, diff --git a/libs/orm/src/query-types.test-d.ts b/libs/orm/src/query-types.test-d.ts new file mode 100644 index 00000000..a52747f4 --- /dev/null +++ b/libs/orm/src/query-types.test-d.ts @@ -0,0 +1,62 @@ +import Knex from 'knex'; +import { expectTypeOf, test } from 'vitest'; +import { + alias, + createDb, + createQuery, + defineEntity, + eq, + isSqlIdentifier, + number, + object, + query, + 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 db = createDb(Knex({ client: 'pg' }), { tasks }); + +test('include customizers know their relation schema', () => { + db.tasks.include( + t => t.owner, + owners => { + owners.where(t => t.name, 'Alice'); + // @ts-expect-error field belongs to tasks, not users + owners.where(t => t.ownerId, 1); + } + ); +}); + +test('new terminal helpers propagate through ORM', async () => { + expectTypeOf(await db.tasks.countValue()).toEqualTypeOf(); + expectTypeOf( + await db.tasks.countValue({ output: string() }) + ).toEqualTypeOf(); +}); + +test('ORM re-exports retain typed factories and identifier narrowing', async () => { + const knex = Knex({ client: 'pg' }); + const bound = createQuery(knex); + expectTypeOf(query(knex, Task)).not.toBeAny(); + expectTypeOf( + await bound(alias(Task, 'task')) + .leftJoin(alias(User, 'owner'), t => eq(t.task.ownerId, t.owner.id)) + .select(t => ({ id: t.task.id, ownerName: t.owner.name })) + ).toEqualTypeOf<{ id: number; ownerName: string | null }[]>(); + // @ts-expect-error The ORM re-export must still reject unknown columns. + query(knex, Task).select(t => ({ missing: t.missing })); + const name: unknown = 'tasks'; + if (isSqlIdentifier(name)) expectTypeOf(name).toEqualTypeOf(); +}); diff --git a/libs/orm/src/result-types.ts b/libs/orm/src/result-types.ts index 825351e0..7029d7a3 100644 --- a/libs/orm/src/result-types.ts +++ b/libs/orm/src/result-types.ts @@ -74,6 +74,12 @@ export type RelKeyTree> = { readonly [K in keyof EntityRelations & string]: K; }; +/** Schema of an entity's declared navigation property. */ +export type RelatedSchema< + TEntity extends Entity, + K extends keyof EntityRelations +> = EntityRelations[K] extends RelationInfo ? S : never; + /** * Result type after `.include('rel')` is applied. * diff --git a/libs/orm/tsconfig.build.json b/libs/orm/tsconfig.build.json index 7954e377..2082ba2d 100644 --- a/libs/orm/tsconfig.build.json +++ b/libs/orm/tsconfig.build.json @@ -12,5 +12,5 @@ "declarationMap": false }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.test-d.ts"] } diff --git a/libs/orm/tsconfig.typecheck.json b/libs/orm/tsconfig.typecheck.json new file mode 100644 index 00000000..c24a9fd6 --- /dev/null +++ b/libs/orm/tsconfig.typecheck.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { "noEmit": true, "strict": true }, + "include": ["src/**/*.test-d.ts"], + "exclude": [] +} diff --git a/libs/orm/vitest.config.mts b/libs/orm/vitest.config.mts new file mode 100644 index 00000000..c2329925 --- /dev/null +++ b/libs/orm/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + typecheck: { + enabled: true, + include: ['src/**/*.test-d.ts'], + tsconfig: './tsconfig.typecheck.json' + } + } +}); diff --git a/package.json b/package.json index 45d76c1a..8edf3da8 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "publish:beta": "npm run clean && npm run build && changeset version --snapshot beta && changeset publish --tag beta --no-git-tag", "run_scheduler": "node ./libs/scheduler/dist/index.js", "test": "vitest --run --typecheck", + "test:queries:integration": "vitest run --config vitest.queries.config.mts", "test:coverage": "vitest --run --coverage && node scripts/update-coverage-badges.js", "bench": "vitest bench --run", "bench:json": "BENCH_JSON=bench-results.json vitest bench --run --project benchmarks && node scripts/relativize-bench-paths.js bench-results.json", diff --git a/vitest.queries.config.mts b/vitest.queries.config.mts new file mode 100644 index 00000000..6339951b --- /dev/null +++ b/vitest.queries.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['libs/knex-schema/integration/**/*.test.ts'], + testTimeout: 15000, + hookTimeout: 30000 + } +}); diff --git a/websites/docs/app/knex-schema/page.tsx b/websites/docs/app/knex-schema/page.tsx index 04668be9..9a3b6fe9 100644 --- a/websites/docs/app/knex-schema/page.tsx +++ b/websites/docs/app/knex-schema/page.tsx @@ -90,6 +90,68 @@ export default function KnexSchemaPage() { +
+

Composable read queries

+

+ Use alias(schema, name) with typed flat + joins and object projections for DTOs. Left-joined + fields include null; source scopes and soft deletion are + retained. +

+
 eq(t.task.ownerId, t.owner.id))
+    .select(t => ({ id: t.task.id, ownerName: t.owner.name }));`)
+                        }}
+                    />
+                    

Aggregates and optional output schemas

+

+ Count helpers return checked numbers; sum and average + preserve database numeric text or null. Min/max follow + the column representation. Optional output schemas + replace decoding and receive raw driver values, + including null. Typed aggregate expressions support + grouped results. +

+
 t.estimate, {
+    output: number().isFloat().coerce().nullable()
+});`)
+                        }}
+                    />
+                    

Ordering and composite cursors

+

+ Eager loading preserves final parent order and page + size. Composite cursors retain timestamp precision and + require non-null scalar columns with a declared unique + tie-breaker. Reapply access filters; cursors are not + authorization. +

+
 t.createdAt, direction: 'desc' },
+        { column: t => t.id, direction: 'desc' }
+    ]
+});`)
+                        }}
+                    />
+                    

+ Existing APIs remain unchanged. Read the{' '} + + complete query guide + {' '} + for examples, precision policy, and restrictions. +

+
+ {/* ── Quick Start ──────────────────────────────────── */}

Quick Start

diff --git a/websites/docs/app/orm/page.tsx b/websites/docs/app/orm/page.tsx index 5ae24a9f..e52c1e89 100644 --- a/websites/docs/app/orm/page.tsx +++ b/websites/docs/app/orm/page.tsx @@ -504,6 +504,23 @@ npx cb-orm db push`
{/* ── See Also ─────────────────────────────────────── */} +
+

Reliable query composition

+

+ Eager loading retains parent ordering and page size. + Relation customization callbacks infer the foreign + schema. Scalar aggregate helpers accept optional output + schemas; typed projections support grouped aggregates. + DTOs are not tracked as entities. +

+

+ ORM re-exports flat-join aliases and aggregate + expressions. Composite cursors support non-null scalar + sort fields with a declared unique tie-breaker. Read the{' '} + query guide for examples and + compatibility details. +

+

See Also