diff --git a/packages/appkit/src/database/runtime/data-path.ts b/packages/appkit/src/database/runtime/data-path.ts new file mode 100644 index 00000000..b7d1941b --- /dev/null +++ b/packages/appkit/src/database/runtime/data-path.ts @@ -0,0 +1,107 @@ +import { DEFAULT_LIMIT, type FilterOperator, MAX_LIMIT } from "../contract"; +import type { AppKitTable, ColumnMeta } from "../schema-builder"; + +export type IdValue = string | number; +export type ScalarValue = string | number | boolean | null; + +/** Operator object form, e.g. `{ eq: 1, gt: [2, 3] }` */ +export type FilterOps = Partial< + Record +>; + +export type WhereValue = + | ScalarValue + | ScalarValue[] + | FilterOps + | RelationPredicate; +export type WhereClause = Record; + +/** Relation predicate: `{ some: {...} }` → EXISTS, `{ none: {...} }` → NOT EXISTS. */ +export interface RelationPredicate { + some?: WhereClause; + none?: WhereClause; +} + +export type OrderDirection = "asc" | "desc"; +export type OrderSpec = Record; + +export interface IncludeOptions { + select?: string[]; + where?: WhereClause; + order?: OrderSpec; + limit?: number; +} + +export type IncludeSpec = Record; + +export interface QuerySpec { + where?: WhereClause; + order?: OrderSpec; + select?: string[]; + include?: IncludeSpec; + limit?: number; + offset?: number; +} + +export type Row = Record; + +/** Backend-agnostic data access. */ +export interface DataPath { + select(table: AppKitTable, spec: QuerySpec): Promise; + findOne( + table: AppKitTable, + id: IdValue, + spec?: Pick, + ): Promise; + count(table: AppKitTable, where?: WhereClause): Promise; + insert(table: AppKitTable, values: Row): Promise; + update(table: AppKitTable, id: IdValue, values: Row): Promise; + upsert(table: AppKitTable, values: Row, onConflict: string[]): Promise; + delete(table: AppKitTable, id: IdValue): Promise; + getColumn(table: AppKitTable, id: IdValue, column: string): Promise; + raw( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise; + transaction(fn: (tx: DataPath) => Promise): Promise; +} + +export class DataPathError extends Error { + readonly statusCode: number; + constructor(message: string, statusCode = 400) { + super(message); + this.name = "DataPathError"; + this.statusCode = statusCode; + } +} + +export function clampLimit(limit: number): number { + if (!Number.isInteger(limit) || limit < 0) { + throw new DataPathError("limit must be a non-negative integer"); + } + + return Math.min(limit, MAX_LIMIT); +} + +export function limitOrDefault(limit?: number): number { + return limit === undefined ? DEFAULT_LIMIT : clampLimit(limit); +} + +export function primaryKeyMeta(table: AppKitTable): ColumnMeta { + const pk = Object.values(table.$columns).find((c) => c.primaryKey); + if (!pk) + throw new DataPathError(`Table "${table.$name}" has no primary key`, 500); + + return pk; +} + +export function isRelationPredicate( + value: unknown, +): value is RelationPredicate { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + ("some" in value || "none" in value) + ); +} diff --git a/packages/appkit/src/database/runtime/engine/column.ts b/packages/appkit/src/database/runtime/engine/column.ts new file mode 100644 index 00000000..bbd5cd87 --- /dev/null +++ b/packages/appkit/src/database/runtime/engine/column.ts @@ -0,0 +1,12 @@ +import type { AnyPgColumn } from "drizzle-orm/pg-core"; +import type { AppKitTable } from "../../schema-builder"; +import { DataPathError } from "../data-path"; + +export function colOf(table: AppKitTable, key: string): AnyPgColumn { + const meta = table.$columns[key]; + + if (!meta || !meta.engineColumn) + throw new DataPathError(`Unknown column "${table.$name}.${key}"`); + + return meta.engineColumn as unknown as AnyPgColumn; +} diff --git a/packages/appkit/src/database/runtime/engine/data-path.ts b/packages/appkit/src/database/runtime/engine/data-path.ts new file mode 100644 index 00000000..0645a16d --- /dev/null +++ b/packages/appkit/src/database/runtime/engine/data-path.ts @@ -0,0 +1,182 @@ +import { eq, type SQL, sql } from "drizzle-orm"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { PgTable } from "drizzle-orm/pg-core"; +import type { Pool } from "pg"; +import { + type AppKitTable, + buildEngineRelations, + type Schema, +} from "@/database/schema-builder"; +import { + type DataPath, + DataPathError, + type IdValue, + limitOrDefault, + primaryKeyMeta, + type QuerySpec, + type Row, + type WhereClause, +} from "../data-path"; +import { defaultColumns } from "../projection"; +import { colOf } from "./column"; +import { + selectToColumns, + translateInclude, + translateOrder, + translateWhere, +} from "./translate"; + +export type AnyDb = NodePgDatabase>; + +/** Create a Drizzle engine database from a pool and schema. */ +export function createEngineDb(pool: Pool, schema: Schema): AnyDb { + const relations = buildEngineRelations(schema.$tables); + return drizzle(pool, { + schema: { ...(schema.$engine as Record), ...relations }, + }) as unknown as AnyDb; +} + +/** The minimal relational-query-builder surface we use off `db.query`. */ +interface RQB { + findMany(config: Record): Promise; + findFirst(config: Record): Promise; +} + +function queryKeyOf(tables: Record, table: AppKitTable) { + const exact = Object.entries(tables).find( + ([, candidate]) => candidate === table, + ); + if (exact) return exact[0]; + + const byName = Object.entries(tables).find( + ([, candidate]) => candidate.$name === table.$name, + ); + return byName?.[0] ?? table.$name; +} + +function rqb( + db: AnyDb, + tables: Record, + table: AppKitTable, +): RQB { + const queryKey = queryKeyOf(tables, table); + const q = (db.query as unknown as Record)[queryKey]; + if (!q) + throw new DataPathError( + `Table "${table.$name}" is not registered in the Drizzle schema`, + 500, + ); + + return q; +} + +function columnsFor( + table: AppKitTable, + select?: string[], +): Record { + return select ? selectToColumns(table, select) : defaultColumns(table); +} + +export function createEngineDataPath(db: AnyDb, schema: Schema): DataPath { + const tables = schema.$tables; + + // the opaque engine handle back to a real PgTable + const pgOf = (t: AppKitTable): PgTable => t.$engine as unknown as PgTable; + + const whereSql = ( + table: AppKitTable, + where?: WhereClause, + ): SQL | undefined => + where ? translateWhere(table, tables, where) : undefined; + + return { + async select(table: AppKitTable, spec: QuerySpec) { + return rqb(db, tables, table).findMany({ + where: whereSql(table, spec.where), + orderBy: spec.order ? translateOrder(table, spec.order) : undefined, + limit: limitOrDefault(spec.limit), + offset: spec.offset, + columns: columnsFor(table, spec.select), + with: spec.include + ? translateInclude(table, tables, spec.include) + : undefined, + }); + }, + async findOne( + table: AppKitTable, + id: IdValue, + spec?: Pick, + ) { + const pk = primaryKeyMeta(table); + const row = await rqb(db, tables, table).findFirst({ + where: eq(colOf(table, pk.columnName), id), + columns: columnsFor(table, spec?.select), + with: spec?.include + ? translateInclude(table, tables, spec.include) + : undefined, + }); + return row ?? null; + }, + + async count(table: AppKitTable, where?: WhereClause) { + return db.$count(pgOf(table), whereSql(table, where)); + }, + + async insert(table: AppKitTable, values: Row) { + const [row] = await db.insert(pgOf(table)).values(values).returning(); + return row as Row; + }, + + async update(table: AppKitTable, id: IdValue, values: Row) { + const pk = primaryKeyMeta(table); + const [row] = await db + .update(pgOf(table)) + .set(values) + .where(eq(colOf(table, pk.columnName), id)) + .returning(); + return row ?? null; + }, + + async upsert(table: AppKitTable, values: Row, onConflict: string[]) { + const target = onConflict.map((c) => colOf(table, c)); + const [row] = await db + .insert(pgOf(table)) + .values(values) + .onConflictDoUpdate({ target, set: values }) + .returning(); + return row as Row; + }, + + async delete(table: AppKitTable, id: IdValue) { + const pk = primaryKeyMeta(table); + const deleted = await db + .delete(pgOf(table)) + .where(eq(colOf(table, pk.columnName), id)) + .returning({ id: colOf(table, pk.columnName) }); + return deleted.length > 0; + }, + + async getColumn(table: AppKitTable, id: IdValue, column: string) { + const pk = primaryKeyMeta(table); + const row = await rqb(db, tables, table).findFirst({ + where: eq(colOf(table, pk.columnName), id), + columns: { [column]: true }, + }); + return row ? row[column] : null; + }, + + async raw( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise { + const result = await db.execute(sql(strings, ...values)); + + return ((result as { rows?: unknown }).rows ?? + (result as unknown)) as T[]; + }, + + async transaction(fn: (tx: DataPath) => Promise) { + return db.transaction(async (tx) => fn(createEngineDataPath(tx, schema))); + }, + }; +} diff --git a/packages/appkit/src/database/runtime/engine/translate.ts b/packages/appkit/src/database/runtime/engine/translate.ts new file mode 100644 index 00000000..0be7a2d9 --- /dev/null +++ b/packages/appkit/src/database/runtime/engine/translate.ts @@ -0,0 +1,184 @@ +import { + and, + asc, + desc, + eq, + gt, + gte, + ilike, + inArray, + isNotNull, + isNull, + like, + lt, + lte, + ne, + or, + type SQL, + sql, +} from "drizzle-orm"; +import type { AnyPgColumn, PgTable } from "drizzle-orm/pg-core"; +import { IN_CAP, MAX_INCLUDES } from "../../contract"; +import type { AppKitTable } from "../../schema-builder"; +import { + clampLimit, + DataPathError, + type FilterOps, + type IncludeOptions, + type IncludeSpec, + isRelationPredicate, + type OrderSpec, + type RelationPredicate, + type WhereClause, +} from "../data-path"; +import { defaultColumns } from "../projection"; +import { colOf } from "./column"; + +type Schema = Record; + +/** A filter operator -> a Drizzle condition fragment. */ +const OPS: Record SQL> = { + eq: (c, v) => (v === null ? isNull(c) : eq(c, v)), + neq: (c, v) => (v === null ? isNotNull(c) : ne(c, v)), + gt: (c, v) => gt(c, v), + gte: (c, v) => gte(c, v), + lt: (c, v) => lt(c, v), + lte: (c, v) => lte(c, v), + like: (c, v) => like(c, String(v)), + ilike: (c, v) => ilike(c, String(v)), + in: (c, v) => inList(c, v), + is: (c, v) => (v === null || v === "null" ? isNull(c) : isNotNull(c)), +}; + +function inList(c: AnyPgColumn, v: unknown): SQL { + const arr = Array.isArray(v) ? v : [v]; + if (arr.length > IN_CAP) { + throw new DataPathError(`in.(…) list exceeds IN_CAP (${IN_CAP})`); + } + return inArray(c, arr); +} + +function tableByName(schema: Schema, name: string): AppKitTable | undefined { + return schema[name] ?? Object.values(schema).find((t) => t.$name === name); +} + +/** Agnostic where clause translation. */ +export function translateWhere( + table: AppKitTable, + schema: Schema, + clause: WhereClause, +): SQL | undefined { + const conds: SQL[] = []; + for (const [key, value] of Object.entries(clause)) { + if (key === "and" || key === "or") { + const groups = (value as WhereClause[]) + .map((g) => translateWhere(table, schema, g)) + .filter((s): s is SQL => Boolean(s)); + if (groups.length > 0) { + const combined = key === "and" ? and(...groups) : or(...groups); + if (combined) conds.push(combined); + } + continue; + } + + if (isRelationPredicate(value)) { + conds.push(translateRelationPredicate(table, schema, key, value)); + continue; + } + + const col = colOf(table, key); + if (Array.isArray(value)) { + conds.push(inList(col, value)); + } else if (value !== null && typeof value === "object") { + for (const [op, opVal] of Object.entries(value as FilterOps)) { + const fn = OPS[op]; + if (!fn) throw new DataPathError(`Unknown filter operator: ${op}`); + conds.push(fn(col, opVal)); + } + } else { + conds.push(value === null ? isNull(col) : eq(col, value)); + } + } + return conds.length > 0 ? and(...conds) : undefined; +} + +/** `{ some|none }` → correlated `EXISTS` / `NOT EXISTS` (no row multiplication). */ +function translateRelationPredicate( + table: AppKitTable, + schema: Schema, + relationName: string, + predicate: RelationPredicate, +): SQL { + const relation = table.$relations.find((r) => r.name === relationName); + if (!relation) throw new DataPathError(`Unknown relation: ${relationName}`); + const child = tableByName(schema, relation.targetTable); + if (!child) + throw new DataPathError(`Unknown child table: ${relation.targetTable}`); + const parentCol = colOf(table, relation.localColumn); + const childCol = colOf(child, relation.targetColumn); + const inner = predicate.some ?? predicate.none; + const innerSql = inner ? translateWhere(child, schema, inner) : undefined; + const correlation = sql`${childCol} = ${parentCol}`; + const whereSql = innerSql ? and(correlation, innerSql) : correlation; + const subquery = sql`select 1 from ${child.$engine as unknown as PgTable} where ${whereSql}`; + return predicate.none + ? sql`not exists (${subquery})` + : sql`exists (${subquery})`; +} + +/** Our `OrderSpec` → Drizzle `orderBy` array. */ +export function translateOrder(table: AppKitTable, order: OrderSpec): SQL[] { + return Object.entries(order).map(([col, direction]) => + direction === "desc" ? desc(colOf(table, col)) : asc(colOf(table, col)), + ); +} + +/** A `select` list → Drizzle relational `columns` (include mode; validates each column). */ +export function selectToColumns( + table: AppKitTable, + select: string[], +): Record { + const columns: Record = {}; + for (const col of select) { + colOf(table, col); + columns[col] = true; + } + + return columns; +} + +/** Our `IncludeSpec` → Drizzle relational `with` config (recursive per-relation options). */ +export function translateInclude( + table: AppKitTable, + schema: Schema, + include: IncludeSpec, +): Record { + const keys = Object.keys(include); + if (keys.length > MAX_INCLUDES) { + throw new DataPathError(`include exceeds MAX_INCLUDES (${MAX_INCLUDES})`); + } + const withConfig: Record = {}; + for (const [relName, opt] of Object.entries(include)) { + if (opt === false) continue; + const rel = table.$relations.find((r) => r.name === relName); + if (!rel) + throw new DataPathError(`Unknown relation "${table.$name}.${relName}"`); + const child = tableByName(schema, rel.targetTable); + if (!child) + throw new DataPathError(`Unknown child table: ${rel.targetTable}`); + if (opt === true) { + withConfig[relName] = { columns: defaultColumns(child) }; + continue; + } + const o = opt as IncludeOptions; + const cfg: Record = {}; + cfg.columns = o.select + ? selectToColumns(child, o.select) + : defaultColumns(child); + if (o.where) cfg.where = translateWhere(child, schema, o.where); + if (o.order) cfg.orderBy = translateOrder(child, o.order); + if (o.limit !== undefined) cfg.limit = clampLimit(o.limit); + withConfig[relName] = cfg; + } + return withConfig; +} diff --git a/packages/appkit/src/database/runtime/index.ts b/packages/appkit/src/database/runtime/index.ts new file mode 100644 index 00000000..ca575178 --- /dev/null +++ b/packages/appkit/src/database/runtime/index.ts @@ -0,0 +1,11 @@ +export * from "./data-path"; +export { colOf } from "./engine/column"; +export type { AnyDb } from "./engine/data-path"; +export { createEngineDataPath, createEngineDb } from "./engine/data-path"; +export { + selectToColumns, + translateInclude, + translateOrder, + translateWhere, +} from "./engine/translate"; +export { defaultColumns, stripPrivate } from "./projection"; diff --git a/packages/appkit/src/database/runtime/projection.ts b/packages/appkit/src/database/runtime/projection.ts new file mode 100644 index 00000000..cfab6a45 --- /dev/null +++ b/packages/appkit/src/database/runtime/projection.ts @@ -0,0 +1,27 @@ +import { type AppKitTable, privateColumnNames } from "../schema-builder"; +import type { Row } from "./data-path"; + +/** Default relational `columns` map that DROPS private columns. + * Used when a query supplies no explicit `select`, so `.private()` columns are excluded + * by default. + */ +export function defaultColumns(table: AppKitTable): Record { + const priv = new Set(privateColumnNames(table)); + const columns: Record = {}; + + for (const meta of Object.values(table.$columns)) { + if (!priv.has(meta.columnName)) columns[meta.columnName] = true; + } + + return columns; +} + +/** Strip private columns from a row. */ +export function stripPrivate(table: AppKitTable, row: Row): Row { + const priv = privateColumnNames(table); + if (priv.length === 0) return row; + + const out = { ...row }; + for (const c of priv) delete out[c]; + return out; +} diff --git a/packages/appkit/src/database/runtime/tests/column.test.ts b/packages/appkit/src/database/runtime/tests/column.test.ts new file mode 100644 index 00000000..b287b0d9 --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/column.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { defineSchema, id, text } from "../../schema-builder"; +import { colOf } from "../index"; + +const schema = defineSchema((t) => { + const users = t.table("users", { id: id(), name: text() }); + return { users }; +}); + +describe("colOf", () => { + it("returns the engine column behind a known key", () => { + const col = colOf(schema.$tables.users, "id"); + expect(col).toBe(schema.$tables.users.$columns.id.engineColumn); + }); + + it("throws on an unknown column", () => { + expect(() => colOf(schema.$tables.users, "nope")).toThrow( + /Unknown column "users.nope"/, + ); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts b/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts new file mode 100644 index 00000000..2e2b17ef --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts @@ -0,0 +1,62 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { DataPath, IdValue, QuerySpec, Row } from "../index"; + +/** + * Type-level tests for the DataPath contract. Verified by `tsc` during + * `pnpm typecheck`; the `it()` wrappers exist only so vitest registers the + * file as a suite (same approach as contract/tests/registry.test.ts). + */ + +describe("IdValue", () => { + it("is a string | number union", () => { + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe("Row", () => { + it("is an open record of unknown values", () => { + expectTypeOf().toEqualTypeOf>(); + }); +}); + +describe("QuerySpec", () => { + it("accepts a fully-populated spec", () => { + const spec = { + where: { id: 1 }, + order: { name: "asc" }, + select: ["id", "name"], + include: { posts: true }, + limit: 10, + offset: 5, + } satisfies QuerySpec; + expectTypeOf(spec).toMatchTypeOf(); + }); + + it("accepts an empty spec (all fields optional)", () => { + const spec = {} satisfies QuerySpec; + expectTypeOf(spec).toMatchTypeOf(); + }); +}); + +describe("DataPath", () => { + it("exposes the full data-access surface", () => { + expectTypeOf().toHaveProperty("select"); + expectTypeOf().toHaveProperty("findOne"); + expectTypeOf().toHaveProperty("count"); + expectTypeOf().toHaveProperty("insert"); + expectTypeOf().toHaveProperty("update"); + expectTypeOf().toHaveProperty("upsert"); + expectTypeOf().toHaveProperty("delete"); + expectTypeOf().toHaveProperty("getColumn"); + expectTypeOf().toHaveProperty("raw"); + expectTypeOf().toHaveProperty("transaction"); + }); + + it("resolves rows from select and a nullable row from findOne", () => { + expectTypeOf().returns.resolves.toEqualTypeOf(); + expectTypeOf< + DataPath["findOne"] + >().returns.resolves.toEqualTypeOf(); + expectTypeOf().returns.resolves.toEqualTypeOf(); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/data-path.test.ts b/packages/appkit/src/database/runtime/tests/data-path.test.ts new file mode 100644 index 00000000..84aeb751 --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/data-path.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_LIMIT, MAX_LIMIT } from "../../contract"; +import { defineSchema, id, text } from "../../schema-builder"; +import { + clampLimit, + DataPathError, + isRelationPredicate, + limitOrDefault, + primaryKeyMeta, +} from "../data-path"; + +const schema = defineSchema((t) => { + const users = t.table("users", { id: id(), name: text() }); + const notes = t.table("notes", { body: text() }); + return { users, notes }; +}); + +describe("primaryKeyMeta", () => { + it("returns the primary key meta", () => { + expect(primaryKeyMeta(schema.$tables.users).columnName).toBe("id"); + }); + + it("throws (500) when the table has no primary key", () => { + try { + primaryKeyMeta(schema.$tables.notes); + throw new Error("expected primaryKeyMeta to throw"); + } catch (err) { + expect(err).toBeInstanceOf(DataPathError); + expect((err as DataPathError).statusCode).toBe(500); + } + }); +}); + +describe("isRelationPredicate", () => { + it("is true for { some } / { none }", () => { + expect(isRelationPredicate({ some: {} })).toBe(true); + expect(isRelationPredicate({ none: {} })).toBe(true); + }); + + it("is false for operator objects, arrays, scalars and null", () => { + expect(isRelationPredicate({ eq: 1 })).toBe(false); + expect(isRelationPredicate([1, 2])).toBe(false); + expect(isRelationPredicate("x")).toBe(false); + expect(isRelationPredicate(null)).toBe(false); + }); +}); + +describe("DataPathError", () => { + it("defaults to status 400", () => { + const err = new DataPathError("bad"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("DataPathError"); + expect(err.statusCode).toBe(400); + }); + + it("accepts a custom status code", () => { + expect(new DataPathError("boom", 500).statusCode).toBe(500); + }); +}); + +describe("limit helpers", () => { + it("uses DEFAULT_LIMIT when no limit is supplied", () => { + expect(limitOrDefault()).toBe(DEFAULT_LIMIT); + }); + + it("caps limits at MAX_LIMIT", () => { + expect(clampLimit(MAX_LIMIT + 1)).toBe(MAX_LIMIT); + }); + + it("rejects negative or fractional limits", () => { + expect(() => clampLimit(-1)).toThrow(/non-negative integer/); + expect(() => clampLimit(1.5)).toThrow(/non-negative integer/); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/engine-data-path.test.ts b/packages/appkit/src/database/runtime/tests/engine-data-path.test.ts new file mode 100644 index 00000000..45f6a67d --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/engine-data-path.test.ts @@ -0,0 +1,497 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { Pool } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { DEFAULT_LIMIT, MAX_LIMIT } from "../../contract"; +import { + boolean, + defineSchema, + fk, + id, + integer, + text, +} from "../../schema-builder"; +import { DataPathError, type IdValue, type Row } from "../data-path"; +import { type AnyDb, createEngineDataPath, createEngineDb } from "../index"; + +const schema = defineSchema((t) => { + const users = t.table("users", { + id: id(), + name: text(), + age: integer(), + active: boolean(), + secret: text().private(), + }); + const posts = t.table("posts", { + id: id(), + title: text(), + secret: text().private(), + authorId: fk(() => users.id).notNull(), + }); + return { users, posts }; +}); + +const users = schema.$tables.users; +const posts = schema.$tables.posts; + +const dialect = new PgDialect(); +function renderSql(frag: unknown): { sql: string; params: unknown[] } { + const { sql, params } = dialect.sqlToQuery(frag as SQL); + return { sql, params: params as unknown[] }; +} + +/** + * A hand-rolled stand-in for the Drizzle db. It records every call and returns + * canned results so we can assert the engine DataPath delegates correctly (real + * SQL round-trips are integration-tier). The chainable shape mirrors the exact + * builder surface `engine/data-path.ts` reaches for. + */ +interface FakeResults { + findMany?: Row[]; + findFirst?: Row; + count?: number; + insert?: Row[]; + upsert?: Row[]; + update?: Row[]; + delete?: { id: IdValue }[]; + execute?: unknown; +} + +interface FakeCalls { + findMany: { table: string; config: Record }[]; + findFirst: { table: string; config: Record }[]; + count: { table: unknown; filter: unknown }[]; + insert: { table: unknown; values: Row }[]; + upsert: { + table: unknown; + values: Row; + config: { target: unknown; set: Row }; + }[]; + update: { table: unknown; values: Row; where: unknown }[]; + delete: { table: unknown; where: unknown; returning: unknown }[]; + execute: { fragment: unknown }[]; + transactions: number; +} + +function makeFakeDb( + results: FakeResults = {}, + options: { tables?: string[] } = {}, +): { db: AnyDb; calls: FakeCalls } { + const calls: FakeCalls = { + findMany: [], + findFirst: [], + count: [], + insert: [], + upsert: [], + update: [], + delete: [], + execute: [], + transactions: 0, + }; + + const tableNames = options.tables ?? Object.keys(schema.$tables); + + const query: Record = {}; + for (const name of tableNames) { + query[name] = { + async findMany(config: Record) { + calls.findMany.push({ table: name, config }); + return results.findMany ?? []; + }, + async findFirst(config: Record) { + calls.findFirst.push({ table: name, config }); + return results.findFirst; + }, + }; + } + + const db = { + query, + async $count(table: unknown, filter: unknown) { + calls.count.push({ table, filter }); + return results.count ?? 0; + }, + insert(table: unknown) { + return { + values(values: Row) { + return { + async returning() { + calls.insert.push({ table, values }); + return results.insert ?? []; + }, + onConflictDoUpdate(config: { target: unknown; set: Row }) { + return { + async returning() { + calls.upsert.push({ table, values, config }); + return results.upsert ?? []; + }, + }; + }, + }; + }, + }; + }, + update(table: unknown) { + return { + set(values: Row) { + return { + where(where: unknown) { + return { + async returning() { + calls.update.push({ table, values, where }); + return results.update ?? []; + }, + }; + }, + }; + }, + }; + }, + delete(table: unknown) { + return { + where(where: unknown) { + return { + async returning(returning: unknown) { + calls.delete.push({ table, where, returning }); + return results.delete ?? []; + }, + }; + }, + }; + }, + async execute(fragment: unknown) { + calls.execute.push({ fragment }); + return results.execute ?? { rows: [] }; + }, + async transaction(fn: (tx: unknown) => unknown) { + calls.transactions += 1; + return fn(db); + }, + }; + + return { db: db as unknown as AnyDb, calls }; +} + +describe("createEngineDataPath — select", () => { + it("delegates to findMany with translated where/order/limit/offset", async () => { + const { db, calls } = makeFakeDb({ findMany: [{ id: 1 }] }); + const dp = createEngineDataPath(db, schema); + + const rows = await dp.select(users, { + where: { name: "bob" }, + order: { name: "asc" }, + limit: 5, + offset: 2, + }); + + expect(rows).toEqual([{ id: 1 }]); + expect(calls.findMany).toHaveLength(1); + const { table, config } = calls.findMany[0]; + expect(table).toBe("users"); + expect(config.limit).toBe(5); + expect(config.offset).toBe(2); + expect(renderSql(config.where).sql).toBe(`"users"."name" = $1`); + expect(config.orderBy).toHaveLength(1); + expect(config.with).toBeUndefined(); + }); + + it("uses the default projection (drops private columns) without a select", async () => { + const { db, calls } = makeFakeDb(); + const dp = createEngineDataPath(db, schema); + + await dp.select(users, {}); + + expect(calls.findMany[0].config.columns).toEqual({ + id: true, + name: true, + age: true, + active: true, + }); + expect(calls.findMany[0].config.limit).toBe(DEFAULT_LIMIT); + }); + + it("caps explicit limits at MAX_LIMIT", async () => { + const { db, calls } = makeFakeDb(); + const dp = createEngineDataPath(db, schema); + + await dp.select(users, { limit: MAX_LIMIT + 1 }); + + expect(calls.findMany[0].config.limit).toBe(MAX_LIMIT); + }); + + it("rejects invalid limits", async () => { + const { db } = makeFakeDb(); + const dp = createEngineDataPath(db, schema); + + await expect(dp.select(users, { limit: -1 })).rejects.toThrow( + /non-negative integer/, + ); + }); + + it("lets an explicit select fetch a private column", async () => { + const { db, calls } = makeFakeDb(); + const dp = createEngineDataPath(db, schema); + + await dp.select(users, { select: ["id", "secret"] }); + + expect(calls.findMany[0].config.columns).toEqual({ + id: true, + secret: true, + }); + }); + + it("forwards include as a `with` config", async () => { + const { db, calls } = makeFakeDb(); + const dp = createEngineDataPath(db, schema); + + await dp.select(users, { include: { posts: true } }); + + expect(calls.findMany[0].config.with).toEqual({ + posts: { + columns: { + id: true, + title: true, + authorId: true, + }, + }, + }); + }); + + it("looks up relational query builders by schema key", async () => { + const aliasedSchema = defineSchema((t) => ({ + users: t.table("app_users", { + id: id(), + name: text(), + }), + })); + const { db, calls } = makeFakeDb({}, { tables: ["users"] }); + const dp = createEngineDataPath(db, aliasedSchema); + + await dp.select(aliasedSchema.$tables.users, {}); + + expect(calls.findMany[0].table).toBe("users"); + }); +}); + +describe("createEngineDataPath — findOne", () => { + it("looks up by primary key and returns the row", async () => { + const { db, calls } = makeFakeDb({ findFirst: { id: 7, name: "x" } }); + const dp = createEngineDataPath(db, schema); + + const row = await dp.findOne(users, 7); + + expect(row).toEqual({ id: 7, name: "x" }); + const { sql, params } = renderSql(calls.findFirst[0].config.where); + expect(sql).toBe(`"users"."id" = $1`); + expect(params).toEqual([7]); + expect(calls.findFirst[0].config.columns).toEqual({ + id: true, + name: true, + age: true, + active: true, + }); + }); + + it("returns null when no row matches", async () => { + const { db } = makeFakeDb({ findFirst: undefined }); + const dp = createEngineDataPath(db, schema); + + expect(await dp.findOne(users, 999)).toBeNull(); + }); + + it("forwards include to a `with` config", async () => { + const { db, calls } = makeFakeDb({ findFirst: { id: 1 } }); + const dp = createEngineDataPath(db, schema); + + await dp.findOne(users, 1, { include: { posts: true } }); + + expect(calls.findFirst[0].config.with).toEqual({ + posts: { + columns: { + id: true, + title: true, + authorId: true, + }, + }, + }); + }); +}); + +describe("createEngineDataPath — count", () => { + it("returns the engine count and forwards a translated filter", async () => { + const { db, calls } = makeFakeDb({ count: 3 }); + const dp = createEngineDataPath(db, schema); + + expect(await dp.count(users, { active: true })).toBe(3); + expect(calls.count).toHaveLength(1); + expect(calls.count[0].table).toBe(users.$engine); + expect(renderSql(calls.count[0].filter).sql).toBe(`"users"."active" = $1`); + }); + + it("passes an undefined filter when no where is given", async () => { + const { db, calls } = makeFakeDb({ count: 0 }); + const dp = createEngineDataPath(db, schema); + + expect(await dp.count(users)).toBe(0); + expect(calls.count[0].filter).toBeUndefined(); + }); +}); + +describe("createEngineDataPath — insert / update / upsert / delete", () => { + it("insert returns the first returned row and targets the engine table", async () => { + const inserted = { id: 1, name: "new" }; + const { db, calls } = makeFakeDb({ insert: [inserted] }); + const dp = createEngineDataPath(db, schema); + + expect(await dp.insert(users, { name: "new" })).toEqual(inserted); + expect(calls.insert[0].table).toBe(users.$engine); + expect(calls.insert[0].values).toEqual({ name: "new" }); + }); + + it("update returns the updated row, or null when nothing matched", async () => { + const updated = { id: 1, name: "edit" }; + const ok = makeFakeDb({ update: [updated] }); + const dpOk = createEngineDataPath(ok.db, schema); + + expect(await dpOk.update(users, 1, { name: "edit" })).toEqual(updated); + const { sql, params } = renderSql(ok.calls.update[0].where); + expect(sql).toBe(`"users"."id" = $1`); + expect(params).toEqual([1]); + + const miss = makeFakeDb({ update: [] }); + const dpMiss = createEngineDataPath(miss.db, schema); + expect(await dpMiss.update(users, 2, { name: "x" })).toBeNull(); + }); + + it("upsert maps onConflict columns to a target and returns the row", async () => { + const row = { id: 1, name: "u" }; + const { db, calls } = makeFakeDb({ upsert: [row] }); + const dp = createEngineDataPath(db, schema); + + expect(await dp.upsert(users, { id: 1, name: "u" }, ["id"])).toEqual(row); + const cfg = calls.upsert[0].config; + expect(Array.isArray(cfg.target)).toBe(true); + expect((cfg.target as unknown[]).length).toBe(1); + expect(cfg.set).toEqual({ id: 1, name: "u" }); + }); + + it("delete returns true when a row was removed and false otherwise", async () => { + const hit = makeFakeDb({ delete: [{ id: 1 }] }); + const dpHit = createEngineDataPath(hit.db, schema); + expect(await dpHit.delete(users, 1)).toBe(true); + + const missed = makeFakeDb({ delete: [] }); + const dpMissed = createEngineDataPath(missed.db, schema); + expect(await dpMissed.delete(users, 2)).toBe(false); + }); +}); + +describe("createEngineDataPath — getColumn", () => { + it("reads a single (even private) column by id", async () => { + const { db, calls } = makeFakeDb({ findFirst: { secret: "s3cr3t" } }); + const dp = createEngineDataPath(db, schema); + + expect(await dp.getColumn(users, 1, "secret")).toBe("s3cr3t"); + expect(calls.findFirst[0].config.columns).toEqual({ secret: true }); + }); + + it("returns null when the row is missing", async () => { + const { db } = makeFakeDb({ findFirst: undefined }); + const dp = createEngineDataPath(db, schema); + + expect(await dp.getColumn(users, 1, "secret")).toBeNull(); + }); +}); + +describe("createEngineDataPath — raw", () => { + it("returns the rows array from the query result", async () => { + const { db, calls } = makeFakeDb({ execute: { rows: [{ n: 1 }] } }); + const dp = createEngineDataPath(db, schema); + + const filterId = 5; + const rows = await dp.raw`select * from users where id = ${filterId}`; + + expect(rows).toEqual([{ n: 1 }]); + expect(calls.execute).toHaveLength(1); + }); + + it("falls back to the raw result when there is no `rows` field", async () => { + const { db } = makeFakeDb({ execute: [{ n: 2 }] }); + const dp = createEngineDataPath(db, schema); + + expect(await dp.raw`select 1`).toEqual([{ n: 2 }]); + }); + + it("parameterizes interpolated values (no string injection)", async () => { + const { db, calls } = makeFakeDb({ execute: { rows: [] } }); + const dp = createEngineDataPath(db, schema); + + const evil = "1; drop table users"; + await dp.raw`select * from users where id = ${evil}`; + + const { sql, params } = renderSql(calls.execute[0].fragment); + expect(sql).toContain("$1"); + expect(sql).not.toContain("drop table"); + expect(params).toEqual([evil]); + }); +}); + +describe("createEngineDataPath — transaction", () => { + it("runs the callback with a DataPath bound to the transaction", async () => { + const { db, calls } = makeFakeDb({ insert: [{ id: 1, name: "tx" }] }); + const dp = createEngineDataPath(db, schema); + + const result = await dp.transaction((tx) => + tx.insert(users, { name: "tx" }), + ); + + expect(result).toEqual({ id: 1, name: "tx" }); + expect(calls.transactions).toBe(1); + expect(calls.insert).toHaveLength(1); + }); + + it("propagates errors thrown inside the transaction (rollback path)", async () => { + const { db } = makeFakeDb(); + const dp = createEngineDataPath(db, schema); + + await expect( + dp.transaction(async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + }); +}); + +describe("createEngineDataPath — unregistered table", () => { + it("throws a 500 DataPathError when the table is not in the engine schema", async () => { + const { db } = makeFakeDb({}, { tables: ["users"] }); + const dp = createEngineDataPath(db, schema); + + await expect(dp.select(posts, {})).rejects.toBeInstanceOf(DataPathError); + await expect(dp.select(posts, {})).rejects.toMatchObject({ + statusCode: 500, + }); + }); +}); + +describe("createEngineDb", () => { + let pool: Pool | undefined; + + afterAll(async () => { + await pool?.end(); + }); + + it("builds a db whose relational query API is reachable for every table", () => { + pool = new Pool(); + const db = createEngineDb(pool, schema); + + const q = db.query as unknown as Record< + string, + { findMany?: unknown; findFirst?: unknown } + >; + expect(typeof q.users.findMany).toBe("function"); + expect(typeof q.users.findFirst).toBe("function"); + expect(typeof q.posts.findMany).toBe("function"); + expect(typeof q.posts.findFirst).toBe("function"); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/projection.test.ts b/packages/appkit/src/database/runtime/tests/projection.test.ts new file mode 100644 index 00000000..d9467519 --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/projection.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { defineSchema, id, integer, text } from "../../schema-builder"; +import { defaultColumns, stripPrivate } from "../index"; + +const schema = defineSchema((t) => { + const users = t.table("users", { + id: id(), + name: text(), + age: integer(), + secret: text().private(), + token: text().private(), + }); + const notes = t.table("notes", { + id: id(), + body: text(), + }); + return { users, notes }; +}); + +const users = schema.$tables.users; +const notes = schema.$tables.notes; + +describe("defaultColumns", () => { + it("keeps every non-private column", () => { + expect(defaultColumns(users)).toEqual({ id: true, name: true, age: true }); + }); + + it("drops private columns", () => { + const cols = defaultColumns(users); + expect(cols).not.toHaveProperty("secret"); + expect(cols).not.toHaveProperty("token"); + }); + + it("returns every column when none are private", () => { + expect(defaultColumns(notes)).toEqual({ id: true, body: true }); + }); +}); + +describe("stripPrivate", () => { + it("removes private keys from a row", () => { + const row = { id: 1, name: "bob", age: 7, secret: "s", token: "t" }; + expect(stripPrivate(users, row)).toEqual({ id: 1, name: "bob", age: 7 }); + }); + + it("returns the same row reference when nothing is private", () => { + const row = { id: 1, body: "hi" }; + expect(stripPrivate(notes, row)).toBe(row); + }); + + it("does not mutate the input row", () => { + const row = { id: 1, name: "bob", secret: "s" }; + stripPrivate(users, row); + expect(row).toHaveProperty("secret", "s"); + }); + + it("ignores private columns that are absent from the row", () => { + expect(stripPrivate(users, { id: 1, secret: "s" })).toEqual({ id: 1 }); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/translate.test.ts b/packages/appkit/src/database/runtime/tests/translate.test.ts new file mode 100644 index 00000000..149caa92 --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/translate.test.ts @@ -0,0 +1,365 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { IN_CAP, MAX_INCLUDES, MAX_LIMIT } from "../../contract"; +import { + boolean, + defineSchema, + fk, + id, + integer, + text, +} from "../../schema-builder"; +import { DataPathError } from "../data-path"; +import { + selectToColumns, + translateInclude, + translateOrder, + translateWhere, +} from "../index"; + +const schema = defineSchema((t) => { + const users = t.table("users", { + id: id(), + name: text(), + age: integer(), + active: boolean(), + }); + const posts = t.table("posts", { + id: id(), + title: text(), + secret: text().private(), + authorId: fk(() => users.id).notNull(), + }); + return { users, posts }; +}); + +const tables = schema.$tables; +const users = tables.users; + +const dialect = new PgDialect(); + +function render(frag: SQL | undefined): { sql: string; params: unknown[] } { + if (!frag) throw new Error("expected an SQL fragment, got undefined"); + const { sql, params } = dialect.sqlToQuery(frag); + return { sql, params: params as unknown[] }; +} + +describe("translateWhere — operators", () => { + it("scalar shorthand becomes eq", () => { + const { sql, params } = render( + translateWhere(users, tables, { name: "bob" }), + ); + expect(sql).toBe(`"users"."name" = $1`); + expect(params).toEqual(["bob"]); + }); + + it("eq operator object", () => { + const { sql, params } = render( + translateWhere(users, tables, { name: { eq: "bob" } }), + ); + expect(sql).toBe(`"users"."name" = $1`); + expect(params).toEqual(["bob"]); + }); + + it("eq null becomes IS NULL", () => { + const { sql, params } = render( + translateWhere(users, tables, { name: { eq: null } }), + ); + expect(sql).toBe(`"users"."name" is null`); + expect(params).toEqual([]); + }); + + it("scalar null shorthand becomes IS NULL", () => { + const { sql } = render(translateWhere(users, tables, { name: null })); + expect(sql).toBe(`"users"."name" is null`); + }); + + it("neq becomes <>", () => { + const { sql } = render(translateWhere(users, tables, { age: { neq: 5 } })); + expect(sql).toBe(`"users"."age" <> $1`); + }); + + it("neq null becomes IS NOT NULL", () => { + const { sql } = render( + translateWhere(users, tables, { name: { neq: null } }), + ); + expect(sql).toBe(`"users"."name" is not null`); + }); + + it("gt / gte / lt / lte", () => { + expect(render(translateWhere(users, tables, { age: { gt: 1 } })).sql).toBe( + `"users"."age" > $1`, + ); + expect(render(translateWhere(users, tables, { age: { gte: 1 } })).sql).toBe( + `"users"."age" >= $1`, + ); + expect(render(translateWhere(users, tables, { age: { lt: 1 } })).sql).toBe( + `"users"."age" < $1`, + ); + expect(render(translateWhere(users, tables, { age: { lte: 1 } })).sql).toBe( + `"users"."age" <= $1`, + ); + }); + + it("like / ilike coerce to string", () => { + expect( + render(translateWhere(users, tables, { name: { like: "a%" } })).sql, + ).toBe(`"users"."name" like $1`); + expect( + render(translateWhere(users, tables, { name: { ilike: "a%" } })).sql, + ).toBe(`"users"."name" ilike $1`); + }); + + it("in produces a parameterized list", () => { + const { sql, params } = render( + translateWhere(users, tables, { id: { in: [1, 2, 3] } }), + ); + expect(sql).toBe(`"users"."id" in ($1, $2, $3)`); + expect(params).toEqual([1, 2, 3]); + }); + + it("array shorthand becomes an IN filter", () => { + const { sql, params } = render( + translateWhere(users, tables, { id: [1, 2, 3] }), + ); + expect(sql).toBe(`"users"."id" in ($1, $2, $3)`); + expect(params).toEqual([1, 2, 3]); + }); + + it("in over IN_CAP throws", () => { + const list = Array.from({ length: IN_CAP + 1 }, (_, i) => i); + expect(() => translateWhere(users, tables, { id: { in: list } })).toThrow( + DataPathError, + ); + expect(() => translateWhere(users, tables, { id: { in: list } })).toThrow( + /IN_CAP/, + ); + }); + + it("is null vs is not null", () => { + expect( + render(translateWhere(users, tables, { name: { is: null } })).sql, + ).toBe(`"users"."name" is null`); + expect( + render(translateWhere(users, tables, { name: { is: "null" } })).sql, + ).toBe(`"users"."name" is null`); + expect( + render(translateWhere(users, tables, { name: { is: "x" } })).sql, + ).toBe(`"users"."name" is not null`); + }); + + it("unknown operator throws", () => { + expect(() => + translateWhere(users, tables, { + age: { foo: 1 } as Record, + }), + ).toThrow(/Unknown filter operator/); + }); + + it("unknown column throws", () => { + expect(() => translateWhere(users, tables, { nope: 1 })).toThrow( + /Unknown column/, + ); + }); + + it("empty clause returns undefined", () => { + expect(translateWhere(users, tables, {})).toBeUndefined(); + }); +}); + +describe("translateWhere — and / or groups", () => { + it("AND group joins conditions with and", () => { + const { sql, params } = render( + translateWhere(users, tables, { + and: [{ age: { gt: 5 } }, { age: { lt: 10 } }], + }), + ); + expect(sql).toBe(`("users"."age" > $1 and "users"."age" < $2)`); + expect(params).toEqual([5, 10]); + }); + + it("OR group joins conditions with or", () => { + const { sql } = render( + translateWhere(users, tables, { + or: [{ name: "a" }, { name: "b" }], + }), + ); + expect(sql).toBe(`("users"."name" = $1 or "users"."name" = $2)`); + }); + + it("nested and within or", () => { + const { sql } = render( + translateWhere(users, tables, { + or: [{ and: [{ age: { gt: 1 } }, { age: { lt: 9 } }] }, { name: "x" }], + }), + ); + expect(sql).toContain(" or "); + expect(sql).toContain(" and "); + }); + + it("empty group contributes nothing", () => { + expect(translateWhere(users, tables, { and: [] })).toBeUndefined(); + }); + + it("a top-level group does not fall through to column lookup", () => { + expect(() => + translateWhere(users, tables, { and: [{ name: "a" }] }), + ).not.toThrow(); + }); +}); + +describe("translateWhere — relation predicates", () => { + it("{ some } becomes a correlated EXISTS", () => { + const { sql } = render( + translateWhere(users, tables, { posts: { some: { title: "x" } } }), + ); + expect(sql).toContain("exists ("); + expect(sql).toContain(`select 1 from "posts"`); + expect(sql).toContain(`"posts"."authorId" = "users"."id"`); + expect(sql).toContain(`"posts"."title" = $1`); + }); + + it("{ none } becomes a correlated NOT EXISTS", () => { + const { sql } = render( + translateWhere(users, tables, { posts: { none: {} } }), + ); + expect(sql).toContain("not exists ("); + expect(sql).toContain(`"posts"."authorId" = "users"."id"`); + }); + + it("unknown relation throws", () => { + expect(() => + translateWhere(users, tables, { comments: { some: {} } }), + ).toThrow(/Unknown relation/); + }); +}); + +describe("translateOrder", () => { + it("asc / desc per column", () => { + const [first, second] = translateOrder(users, { + age: "asc", + name: "desc", + }); + expect(render(first).sql).toBe(`"users"."age" asc`); + expect(render(second).sql).toBe(`"users"."name" desc`); + }); + + it("unknown column throws", () => { + expect(() => translateOrder(users, { nope: "asc" })).toThrow( + /Unknown column/, + ); + }); +}); + +describe("selectToColumns", () => { + it("maps a select list to a columns record", () => { + expect(selectToColumns(users, ["id", "name"])).toEqual({ + id: true, + name: true, + }); + }); + + it("rejects unknown columns", () => { + expect(() => selectToColumns(users, ["nope"])).toThrow(/Unknown column/); + }); +}); + +describe("translateInclude", () => { + it("true includes the relation with the default child projection", () => { + expect(translateInclude(users, tables, { posts: true })).toEqual({ + posts: { + columns: { + id: true, + title: true, + authorId: true, + }, + }, + }); + }); + + it("false skips the relation", () => { + expect(translateInclude(users, tables, { posts: false })).toEqual({}); + }); + + it("options map to a Drizzle with-config", () => { + const cfg = translateInclude(users, tables, { + posts: { + select: ["title"], + where: { title: "x" }, + order: { title: "asc" }, + limit: 5, + }, + }) as { posts: Record }; + + expect(cfg.posts.columns).toEqual({ title: true }); + expect(cfg.posts.limit).toBe(5); + expect(render(cfg.posts.where as SQL).sql).toBe(`"posts"."title" = $1`); + const orderBy = cfg.posts.orderBy as SQL[]; + expect(orderBy).toHaveLength(1); + expect(render(orderBy[0]).sql).toBe(`"posts"."title" asc`); + }); + + it("options without an explicit select use the default child projection", () => { + const cfg = translateInclude(users, tables, { + posts: { + limit: 5, + }, + }) as { posts: Record }; + + expect(cfg.posts.columns).toEqual({ + id: true, + title: true, + authorId: true, + }); + }); + + it("caps relation limits at MAX_LIMIT", () => { + const cfg = translateInclude(users, tables, { + posts: { + limit: MAX_LIMIT + 1, + }, + }) as { posts: Record }; + + expect(cfg.posts.limit).toBe(MAX_LIMIT); + }); + + it("resolves relation target tables by physical table name", () => { + const aliased = defineSchema((t) => { + const aliasedUsers = t.table("app_users", { id: id() }); + const aliasedPosts = t.table("app_posts", { + id: id(), + authorId: fk(() => aliasedUsers.id), + }); + return { users: aliasedUsers, posts: aliasedPosts }; + }); + + expect( + translateInclude(aliased.$tables.users, aliased.$tables, { + app_posts: true, + }), + ).toEqual({ + app_posts: { + columns: { + id: true, + authorId: true, + }, + }, + }); + }); + + it("unknown relation throws", () => { + expect(() => translateInclude(users, tables, { nope: true })).toThrow( + /Unknown relation/, + ); + }); + + it("over MAX_INCLUDES throws", () => { + const include = Object.fromEntries( + Array.from({ length: MAX_INCLUDES + 1 }, (_, i) => [`r${i}`, true]), + ); + expect(() => translateInclude(users, tables, include)).toThrow( + /MAX_INCLUDES/, + ); + }); +});