diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d669ebe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Agent notes + +Conventions that aren't lint-enforced. If you're writing code in this repo, follow these. + +## Type identifiers + +- **Don't use TS's `Record` utility type.** Use `{ [k: K]: V }` directly. + + Reason: typegres exports its own `Record` class (the pg composite/row type), and `Record` as a type position resolves to TS's global utility — they're not the same shape, and the visual collision causes confusion. ESLint can't distinguish the two reliably (it's identifier-name-based, not type-aware), so this is a convention rather than a lint rule. + + ```ts + // Don't: + const headers: Record = {}; + + // Do: + const headers: { [k: string]: string } = {}; + ``` + + Using the typegres `Record` class as a type (e.g. `Record` as a return of `scalar()`) is fine — that's the intended use of the exported class. diff --git a/eslint.config.js b/eslint.config.js index e83019e..3cdbc1b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -54,13 +54,6 @@ export default [ selector: "MemberExpression[object.name='expose'][property.name='unchecked']", message: "Don't use @expose.unchecked — it skips RPC arg validation. Use @expose(zSchema) instead. If the method's signature is genuinely inexpressible in zod (or this is a test fixture), add `// eslint-disable-next-line no-restricted-syntax -- `.", }], - "@typescript-eslint/no-restricted-types": ["error", { - types: { - "Record": { - message: "Use { [key: string]: T } instead. 'Record' conflicts with the pg Record type.", - }, - }, - }], }, }, { diff --git a/package.json b/package.json index 72557ca..474e781 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typegres", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/src/builder/query.test.ts b/src/builder/query.test.ts index 62d8305..6659b5d 100644 --- a/src/builder/query.test.ts +++ b/src/builder/query.test.ts @@ -2,6 +2,7 @@ import { test, expect, expectTypeOf } from "vitest"; import { Int4, Int8, Text, Bool, Jsonb } from "../types"; import { sql, compile } from "./sql"; import { setupDb, db } from "../test-helpers"; +import { expose } from "typegres"; setupDb(); // --- values() --- @@ -742,6 +743,9 @@ test("groupBy: multiple calls stack", async () => { ) .groupBy((n) => [n.values.a]) .groupBy((n) => [n.values.b]) + // @ts-expect-error --- TODO: typing here is broken as the tuple + // type intersected with the namespace (with the previous tuple) isn't + // quite correct .select(({ 0: a, 1: b, values }) => ({ a, b, total: values.c.sum() })) .orderBy((n) => [n[0] as any, "asc"]) ); @@ -852,7 +856,9 @@ test("type test: db.execute(Table.from()) row methods are never-typed (uncallabl await tx.execute(sql`INSERT INTO widgets (name) VALUES ('w1')`); class Widgets extends db.Table("widgets") { + @expose() id = (Int8<1>).column({ nonNull: true, generated: true }); + @expose() name = (Text<1>).column({ nonNull: true }); // Plain method — should not be a callable function on the row type. diff --git a/src/builder/query.ts b/src/builder/query.ts index cc54f27..0a790d0 100644 --- a/src/builder/query.ts +++ b/src/builder/query.ts @@ -9,16 +9,13 @@ import { isTableClass, TableBase } from "../table"; import z from "zod"; import { Values } from "./values"; -// Extract only Any<> instances from a row type -export const selectList = (output: T): T => { - return Object.fromEntries(Object.entries(output).filter(([, v]) => v instanceof Any)) as T; -}; - // Compile a row type into a SQL select list: col AS "name", ... -export const compileSelectList = (output: RowType): Sql => { +export const compileSelectList = (output: RowType, omitAliases = false): Sql => { return sql.join( Object.entries(output).flatMap(([k, v]) => - v instanceof Any ? [sql`${v.toSql()} as ${sql.ident(k)}`] : [], + v instanceof Any ? [ + omitAliases ? v.toSql() : + sql`${v.toSql()} as ${sql.ident(k)}`] : [], ), ); }; @@ -44,30 +41,6 @@ export const reAlias = (row: R, alias: Alias): R => { return out; }; -// Deserialize raw string rows using typed output descriptors -export const deserializeRows = ( - rows: { [key: string]: string }[], - output: { [key: string]: unknown }, -): R[] => { - return rows.map((row) => - Object.fromEntries( - Object.entries(row).map(([k, v]) => { - const type = output[k]; - if (!(type instanceof Any)) { - throw new Error( - `deserializeRows: output column '${k}' is not a typed pg expression (got ${typeof v}). ` + - `The select callback must return an object whose values are Any instances.`, - ); - } - if (v === null || v === undefined) { - return [k, null]; - } - return [k, type.deserialize(String(v))]; - }), - ), - ) as R[]; -}; - // Hydrate raw rows into typed instances that share the shape's prototype. // Each column field is an Any wrapping a CAST(param) of the deserialized // value — so methods on the class that reference `this.col` can compose @@ -102,13 +75,19 @@ export const hydrateRows = ( }; // Mapping of row name to type (class instance) -export type RowType = object; +export type RowType = TableBase | { [k: string]: Any }; export const isRowType = (obj: unknown): obj is RowType => { if (obj === null || typeof obj !== "object") { return false; } + if (obj instanceof TableBase) { + return true; + } const proto = Object.getPrototypeOf(obj); - return obj instanceof TableBase || proto === Object.prototype || proto === null; + if (proto !== Object.prototype && proto !== null) { + return false; + } + return Object.entries(obj).every(([_, v]) => v instanceof Any); }; // All of the row types in the current namespace @@ -285,7 +264,7 @@ export class QueryBuilder< // overload, R widens to `TableBase` and column access on the namespace fails. // By destructuring the constructor to `InstanceType` directly, we capture // the concrete subclass type (`Owners`, `Pets`, …). - join( + join( from: T, on: (ns: N & { [K in T["tsAlias"]]: InstanceType }) => Bool, ): QueryBuilder }, O, GB>; @@ -302,7 +281,7 @@ export class QueryBuilder< }); } - leftJoin( + leftJoin( from: T, on: (ns: N & { [K in T["tsAlias"]]: RowTypeToNullable> }) => Bool, ): QueryBuilder> }, O, GB>; @@ -426,25 +405,20 @@ export class QueryBuilder< // TODO: ROW(), array_agg(), COALESCE should be regular typed ops once we support them // Conditional return type avoids overload resolution quirks: TS's `this:` // overloads can pick the wrong branch when the Card type is already narrowed. - /* eslint-disable @typescript-eslint/no-restricted-types */ scalar(): [Card] extends ["one"] ? Record : [Card] extends ["maybe"] ? Record : Anyarray, 1>; - /* eslint-enable @typescript-eslint/no-restricted-types */ @expose() scalar(): any { - const staticCols = selectList(this.rowType()); - const RecordClass = Record.of(staticCols as any); + const RecordClass = Record.of(this.rowType()); // Wrap as a subquery: (SELECT ROW(...) FROM ... WHERE ...) // inner QB — when embedded in sql``, its emit() wraps as subquery with AS const inner = this.select((ns) => { - const cols = selectList(this.#doSelect(ns)); - const rowExprs = Object.values(cols).map((type: any) => type.toSql()); // ROW() takes raw expressions without aliases - const rowSql = sql`ROW(${sql.join(rowExprs)})`; + const rowSql = sql`ROW(${compileSelectList(this.#doSelect(ns), true)})`; return { __row: RecordClass.from(rowSql) }; }); if (this.card === "many") { diff --git a/src/database.ts b/src/database.ts index 2a2d487..4362954 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,6 +1,7 @@ import type { ExecuteFn, Driver, QueryResult } from "./driver"; import type { Fromable, RowType, RowTypeToTsType } from "./builder/query"; -import { QueryBuilder, deserializeRows, hydrateRows } from "./builder/query"; +import { QueryBuilder, hydrateRows } from "./builder/query"; +import { deserializeRows } from "./util"; import type { Sql } from "./builder/sql"; import { sql } from "./builder/sql"; import { Table, type TableBase, type TableOptions } from "./table"; @@ -78,14 +79,14 @@ export class Database { async execute(query: Sql): Promise { const result = await this.#exec(query); if (query instanceof QueryBuilder) { - return deserializeRows(result.rows as { [key: string]: string }[], query.rowType() as { [key: string]: unknown }); + return deserializeRows(result.rows as { [key: string]: string }[], query.rowType()); } if (query instanceof InsertBuilder || query instanceof UpdateBuilder || query instanceof DeleteBuilder) { const returning = query.rowType(); if (!returning) { return []; } - return deserializeRows(result.rows as { [key: string]: string }[], returning as { [key: string]: unknown }); + return deserializeRows(result.rows as { [key: string]: string }[], returning); } return result; } diff --git a/src/exoeval/tool.ts b/src/exoeval/tool.ts index 3cd8cd7..dfeb055 100644 --- a/src/exoeval/tool.ts +++ b/src/exoeval/tool.ts @@ -4,6 +4,13 @@ import z from 'zod' export const toolSymbol = Symbol.for('exoeval_tool') export const toolFieldsSymbol = Symbol.for('exoeval_toolFields') +// Reads the `@expose` marker if present. Returns the Set when the +// output is a marker-bearing instance (a typegres Table row); returns +// `undefined` for POJOs (no filter applies). Callers extract once and +// pass the result to consumers like `deserializeRows` / `Record.of`. +export const exposedFieldsOf = (output: object): Set | undefined => + (output as { [k: symbol]: unknown })[toolFieldsSymbol] as Set | undefined + export type ToolKind = 'raw' | 'expr' | 'constructor' export type ToolFunction unknown = (...args: unknown[]) => unknown> = T & ((...args: unknown[]) => unknown) & { diff --git a/src/hydrate.test.ts b/src/hydrate.test.ts index 3a67b25..12d84d7 100644 --- a/src/hydrate.test.ts +++ b/src/hydrate.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, expectTypeOf, beforeAll } from "vitest"; -import { typegres, sql, Table, Int8, Text, Bool } from "typegres"; +import { typegres, sql, Table, Int8, Text, Bool, expose } from "typegres"; import type { Database, QueryBuilder } from "typegres"; // End-to-end tests for db.hydrate(): materialize query rows as class @@ -17,9 +17,16 @@ class User extends Table("users") { } class Todo extends Table("todos") { + @expose() id = (Int8<1>).column({ nonNull: true, generated: true }); + + @expose() user_id = (Int8<1>).column({ nonNull: true }); + + @expose() title = (Text<1>).column({ nonNull: true }); + + @expose() completed = (Bool<1>).column({ nonNull: true }); update(fields: { completed?: boolean; title?: string }) { diff --git a/src/rpc.test.ts b/src/rpc.test.ts index 1affd6d..2246852 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -1,5 +1,5 @@ -import { describe, test, expect } from "vitest"; -import { sql, Table, Int8, Text } from "typegres"; +import { describe, test, expect, expectTypeOf } from "vitest"; +import { sql, Table, Int8, Text, Record, Anyarray } from "typegres"; import type { Database } from "typegres"; import { expose } from "./exoeval/tool"; import { RpcClient, inMemoryChannel } from "./exoeval/rpc"; @@ -173,6 +173,226 @@ describe("typegres over exoeval rpc — in-memory", () => { }); }); + // Three variants of the same exposure question, each pinning down + // a separate code path. Together they prove the @expose gate is + // layered: + // 1. bare `.from().execute()` — the rowType is the marker-bearing + // table instance; `deserializeRows` filters by membership. + // 2. spread `.select(({s}) => ({...s}))` — exoeval's spread + // evaluation walks the marker; non-@expose fields aren't + // copied in. + // 3. explicit `.select(({s}) => ({pass: s.password}))` — exoeval + // property access on non-@expose returns undefined; JSON + // serialization drops it. + describe("@expose gating across select shapes", () => { + const setupSecrets = async (tx: Database) => { + await tx.execute(sql` + CREATE TABLE secrets ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + public_name text NOT NULL, + password text NOT NULL + ) + `); + await tx.execute(sql`INSERT INTO secrets (public_name, password) VALUES ('alice', 'hunter2')`); + }; + + class Secrets extends Table("secrets") { + @expose() id = (Int8<1>).column({ nonNull: true, generated: true }); + @expose() public_name = (Text<1>).column({ nonNull: true }); + // Intentionally NOT @expose'd: + password = (Text<1>).column({ nonNull: true }); + } + class SecretsApi { + @expose() db: Database; + constructor(db: Database) { this.db = db; } + @expose() all() { return Secrets.from(); } + // eslint-disable-next-line no-restricted-syntax -- test fixture + @expose.unchecked() + insertSecrets(row: { public_name: string; password: string }) { return Secrets.insert(row); } + @expose() updateSecrets() { return Secrets.update(); } + @expose() deleteSecrets() { return Secrets.delete(); } + // Deliberately returns class instances; used to verify the wire rejects them. + @expose() async hydrateAll() { return this.db.hydrate(Secrets.from()); } + } + + // Closure helper: every test in this block does the same tx + rpc + setup + // dance — collapse it so each test reads as just the closure under test. + // `withinTransaction` discards its inner return, so capture via closure. + const runRpc = async (build: (api: SecretsApi) => Promise): Promise => { + let result: T; + await withinTransaction(async (tx) => { + await setupSecrets(tx); + const rpc = new RpcClient(inMemoryChannel(new SecretsApi(tx))); + result = (await rpc.run(build)) as T; + }); + return result!; + }; + + test("bare .from().execute() — gate fires", async () => { + await withinTransaction(async (tx) => { + await setupSecrets(tx); + const rpc = new RpcClient(inMemoryChannel(new SecretsApi(tx))); + const rows = await rpc.run((api) => api.all().execute(api.db)); + expect(rows).toEqual([{ id: "1", public_name: "alice" }]); + expect(rows[0]).not.toHaveProperty("password"); + }); + }); + + test("spread `({...s})` — exoeval copies only @expose'd fields", async () => { + await withinTransaction(async (tx) => { + await setupSecrets(tx); + const rpc = new RpcClient(inMemoryChannel(new SecretsApi(tx))); + const rows = await rpc.run((api) => + api.all().select(({ secrets }) => ({ ...secrets })).execute(api.db), + ); + expect(rows).toEqual([{ id: "1", public_name: "alice" }]); + expect(rows[0]).not.toHaveProperty("password"); + }); + }); + + test("explicit `({pass: s.password})` — non-@expose'd access yields undefined and fails RowType validation", async () => { + await withinTransaction(async (tx) => { + await setupSecrets(tx); + const rpc = new RpcClient(inMemoryChannel(new SecretsApi(tx))); + // `secrets.password` evaluates to undefined inside exoeval (the + // field isn't @expose'd), so the select callback returns + // `{pass: undefined}` — which fails `fn.returns(isRowType)` + // validation (RowType requires every value to be an Any). + await expect( + rpc.run((api) => + api.all() + .select(({ secrets }) => ({ pass: secrets.password })) + .execute(api.db), + ), + ).rejects.toThrow(); + }); + }); + + test("scalar subquery — single row Record", async () => { + await withinTransaction(async (tx) => { + await setupSecrets(tx); + const rpc = new RpcClient(inMemoryChannel(new SecretsApi(tx))); + const rows = await rpc.run((api) => + api.all() + .select(({ secrets: _ }) => ({ + single: api.all().limit(1).cardinality("one").scalar(), + })) + .limit(1) + .execute(api.db), + ); + expect(rows).toEqual([{ single: { id: "1", public_name: "alice" } }]); + expect(rows[0]!.single).not.toHaveProperty("password"); + }); + }); + + test("scalar subquery — array of Records (with pg subscript)", async () => { + await withinTransaction(async (tx) => { + await setupSecrets(tx); + const rpc = new RpcClient(inMemoryChannel(new SecretsApi(tx))); + const rows = await rpc.run((api) => + api.all() + .select(({ secrets: _ }) => { + const arr = api.all().cardinality("many").scalar(); + return { + all: arr, + first: arr["[]"](1), + }; + }) + .limit(1) + .execute(api.db), + ); + expect(rows[0]!.all).toEqual([{ id: "1", public_name: "alice" }]); + expect(rows[0]!.all[0]).toEqual({ id: "1", public_name: "alice" }); + expect(rows[0]!.all[0]).not.toHaveProperty("password"); + expect(rows[0]!.first).toEqual({ id: "1", public_name: "alice" }); + expect(rows[0]!.first).not.toHaveProperty("password"); + }); + }); + + // hydrate isn't @expose'd, so it can't be reached from the RPC wire — + // these run directly on tx. They exist to confirm the @expose gate + // also fires through the hydrate path (which goes through + // Record.deserialize → deserializeRows just like execute). + test("scalar subquery — single row Record (hydrate)", async () => { + await withinTransaction(async (tx) => { + await setupSecrets(tx); + const rows = await tx.hydrate( + Secrets.from() + .select(() => ({ + single: Secrets.from().limit(1).cardinality("one").scalar(), + })) + .limit(1), + ); + // hydrate returns Any-wrappers, not deserialized JS objects: + // `row.single` is a Record-typed Any instance. + expect(rows[0]!.single).toBeInstanceOf(Record); + expectTypeOf(rows[0]!.single).toMatchTypeOf>(); + }); + }); + + test("scalar subquery — array of Records (hydrate, with pg subscript)", async () => { + await withinTransaction(async (tx) => { + await setupSecrets(tx); + const rows = await tx.hydrate( + Secrets.from() + .select(() => { + const arr = Secrets.from().cardinality("many").scalar(); + return { + all: arr, + first: arr["[]"](1), + }; + }) + .limit(1), + ); + // hydrate keeps Any-wrappers (composable into follow-up queries), + // NOT plain JS values. Both fields are Any-instance subclasses. + expect(rows[0]!.all).toBeInstanceOf(Anyarray); + expect(rows[0]!.first).toBeInstanceOf(Record); + expectTypeOf(rows[0]!.all).toMatchTypeOf, 1>>(); + expectTypeOf(rows[0]!.first).toMatchTypeOf>(); + }); + }); + + test("insert .returning bare — gate fires", async () => { + const rows = await runRpc((api) => + api.insertSecrets({ public_name: "dave", password: "hunter3" }) + .returning(({ secrets }) => secrets) + .execute(api.db), + ); + expect(rows).toEqual([{ id: "2", public_name: "dave" }]); + expect(rows[0]).not.toHaveProperty("password"); + }); + + test("update .returning bare — gate fires", async () => { + const rows = await runRpc((api) => + api.updateSecrets() + .set(() => ({ public_name: "ALICE!" })) + .where(({ secrets }) => secrets.id["="]("1")) + .returning(({ secrets }) => secrets) + .execute(api.db), + ); + expect(rows).toEqual([{ id: "1", public_name: "ALICE!" }]); + expect(rows[0]).not.toHaveProperty("password"); + }); + + test("delete .returning bare — gate fires", async () => { + const rows = await runRpc((api) => + api.deleteSecrets() + .where(({ secrets }) => secrets.id["="]("1")) + .returning(({ secrets }) => secrets) + .execute(api.db), + ); + expect(rows).toEqual([{ id: "1", public_name: "alice" }]); + expect(rows[0]).not.toHaveProperty("password"); + }); + + test("hydrated rows can't pass over rpc — safeStringify rejects class instances", async () => { + await expect( + runRpc((api) => api.hydrateAll()), + ).rejects.toThrow(/cannot serialize.*instance over RPC/); + }); + }); + test("delete + where + returning over the wire", async () => { await withinTransaction(async (tx) => { await setupUsers(tx); diff --git a/src/types/overrides/any.ts b/src/types/overrides/any.ts index 4fcc4af..8a9a2f1 100644 --- a/src/types/overrides/any.ts +++ b/src/types/overrides/any.ts @@ -19,6 +19,7 @@ export class Any extends Generated { __class: typeof Any; __raw: Sql; __nullability: N; + __aggregate: Any; }; // __typname: the pg type name as a SQL fragment (for use in templates // like `CAST(x AS int4)`). __typnameText: the same name as a plain diff --git a/src/types/overrides/anyarray.ts b/src/types/overrides/anyarray.ts index be2bf09..ce6c218 100644 --- a/src/types/overrides/anyarray.ts +++ b/src/types/overrides/anyarray.ts @@ -1,6 +1,33 @@ import { Anyarray as Generated } from "../generated/anyarray"; import type { Any } from "../index"; +import { Int2, Int4, Int8 } from "../index"; +import { meta } from "../runtime"; +import type { Sql } from "../../builder/sql"; +import { sql } from "../../builder/sql"; +import { expose } from "../../exoeval/tool"; export class Anyarray, N extends number> extends Generated { static __element: Any; + + // pg array subscript: 1-indexed; out-of-bounds returns NULL (not an error). + // Bracket-string-keyed to mirror pg's SQL syntax (`arr[i]`), matching the + // existing `id["="](v)` / `arr["&&"](rhs)` style. Result is the element + // type's __nullable variant since out-of-bounds yields SQL NULL. + // eslint-disable-next-line no-restricted-syntax -- mirrors codegen for typegres ops + @expose.unchecked() + ["[]"] | Int4 | Int8 | number | bigint>( + i: I, + ): T extends { [meta]: { __nullable: infer U } } ? U : T { + let idxSql: Sql + if (typeof i === "number" || typeof i === "bigint") { + idxSql = sql`${i}`; + } else if (i instanceof Int2 || i instanceof Int4 || i instanceof Int8) { + idxSql = i.toSql(); + } else { + throw new Error(`Invalid array index type: ${typeof i}`); + } + const elClass = (this.constructor as unknown as { __element: Any }) + .__element[meta].__class; + return elClass.from(sql`((${this.toSql()})[${idxSql}])`) as any; + } } diff --git a/src/types/overrides/record.ts b/src/types/overrides/record.ts index 6912610..5487f26 100644 --- a/src/types/overrides/record.ts +++ b/src/types/overrides/record.ts @@ -1,7 +1,7 @@ import { Record as Generated } from "../generated/record"; -import type { Any } from "../index"; -import type { RowTypeToTsType } from "../../builder/query"; +import type { RowType, RowTypeToTsType } from "../../builder/query"; import { sql } from "../../builder/sql"; +import { deserializeRows } from "../../util"; // Pg composite format parser: `(val1,val2,...)` → per-field string, or null // for a pg NULL field. Pg encodes NULL as absence between commas @@ -48,32 +48,24 @@ const parseComposite = (raw: string): (string | null)[] => { }; export class Record extends Generated { - static __columns: { [key: string]: Any } = {}; - // Conditional so the declaration tolerates T being filled with a non-row // type (e.g. codegen's `types.Record<0 | 1>` in variant [meta] fields, // where the slot is positionally wrong but inert). - declare deserialize: (raw: string) => T extends object ? RowTypeToTsType : unknown; + declare deserialize: (raw: string) => T extends RowType ? RowTypeToTsType : unknown; - static of }>(columns: T) { - const entries = Object.entries(columns); + static of(rowType: T) { const cls = class extends (this as any) { - static __columns = columns; static __typname = sql`record`; static __typnameText = "record"; }; - // Closure over entries — works even when called without `this` (e.g., from Anyarray) cls.prototype["deserialize"] = (raw: string) => { + // pg emits composite fields positionally; reassemble into a named + // row so deserializeRows can apply the @expose filter by name. const fields = parseComposite(raw); - return Object.fromEntries( - entries.map(([name, type], i) => { - const val = fields[i]; - // undefined = column not emitted (pg output always emits every - // field, so this is just a safety net); null = pg NULL; "" = real - // empty string and flows through to type.deserialize. - return [name, val === undefined || val === null ? null : type.deserialize(val)]; - }), + const namedRow = Object.fromEntries( + Object.keys(rowType).map((name, i) => [name, fields[i] ?? null]), ); + return deserializeRows([namedRow], rowType)[0]; }; return cls as unknown as typeof Record; } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index cd915c8..6188c45 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -6,13 +6,12 @@ import * as types from "./index"; import type { Any } from "./index"; import { getTypeDef } from "./deserialize"; import { isPlainData } from "../util"; +import type { RowType } from "../builder/query"; // Global metadata symbol — hides internals from autocomplete. // All type metadata (__class, __nullable, __nonNullable, etc.) lives under this key. export const meta = Symbol("typegres"); -export type Meta = T extends { [meta]: infer M } ? M : never; - // Nullability: 0 = null, 1 = non-null, 0|1 = nullable, number = aggregate/unknown // StrictNull: null propagates — if any arg is null, result is null (proisstrict = true) export type StrictNull = number extends T ? number : T; @@ -50,11 +49,11 @@ export type TsTypeOf = export type Nullable = T extends { [meta]: { __nullable: infer U } } ? U : T; // Extract the aggregate variant (N=number) of a pg type via the [meta] bag -export type Aggregate = T extends { [meta]: { __aggregate: infer U } } ? U : T; +export type Aggregate> = T[typeof meta]['__aggregate']; // Transform a row type to aggregate context — all columns become N=number -export type AggregateRow = { - [K in keyof R]: Aggregate; +export type AggregateRow = { + [K in keyof R]: R[K] extends Any ? Aggregate : never; }; // Keys of R that are column descriptors (have the __required brand from column()) diff --git a/src/util.ts b/src/util.ts index c2ea2bb..c644559 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,3 +1,7 @@ +import type { RowType } from "./builder/query"; +import {Any} from './types/index'; +import { exposedFieldsOf } from "./exoeval/tool"; + // Plain-data check, recursive. Is this a value that's JSON-safe all // the way down — no class instances anywhere in the tree? Accepts: // - primitives (string, number, bigint, boolean, symbol, undefined) @@ -24,3 +28,58 @@ export const isPlainData = (v: unknown): boolean => { } return true; }; + +const isPlainObject = (v: unknown): v is object => { + if (v === null || typeof v !== "object") {return false;} + const proto = Object.getPrototypeOf(v); + return proto === Object.prototype || proto === null; +} + +// --- @expose-aware row deserialization --- +// +// Final-presentation gate for the @expose contract. If `output` is a +// table-instance row type, it carries a Set of @expose-marked field +// names under `toolFieldsSymbol` (read via `exposedFieldsOf`); this +// filter drops unmarked columns at the wire boundary so a bare +// `Users.from().execute(db)` (or a scalar subquery wrapping the same +// instance) can't leak unmarked fields like `password_hash`. POJOs +// (from `.select(cb)`) carry no marker — `exposedFieldsOf` returns +// undefined and every named field passes through. +// +// Lives here (not in builder/query.ts) so `Record.deserialize` can +// reuse the same logic without a value-import cycle through the type +// re-export tree. + +export const deserializeRows = ( + rows: { [key: string]: string | null | undefined }[], + shape: RowType, +): R[] => { + let exposed = undefined; + if (!isPlainObject(shape)) { + exposed = exposedFieldsOf(shape); + if (exposed == null) { + throw new Error( + `deserializeRows: expected a plain object or an @expose-marked table instance as shape, got ${JSON.stringify(shape)}`, + ); + } + } + return rows.map((row) => + Object.fromEntries( + Object.entries(row) + .filter(([k]) => !exposed || exposed.has(k)) + .map(([k, v]) => { + const type = (shape as { [k: string]: unknown })[k]; + if (!(type instanceof Any)) { + throw new Error( + `deserializeRows: output column '${k}' is not a typed pg expression (got ${JSON.stringify(type)}). ` + + `The select callback must return an object whose values are Any instances.`, + ); + } + if (v == null) { + return [k, null]; + } + return [k, type.deserialize(String(v))]; + }), + ), + ) as R[]; +};