Skip to content

Commit ade7903

Browse files
committed
fix: deserialize should only expose @Exposed fields
1 parent 19fbd40 commit ade7903

14 files changed

Lines changed: 383 additions & 78 deletions

File tree

AGENTS.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Agent notes
2+
3+
Conventions that aren't lint-enforced. If you're writing code in this repo, follow these.
4+
5+
## Type identifiers
6+
7+
- **Don't use TS's `Record<K, V>` utility type.** Use `{ [k: K]: V }` directly.
8+
9+
Reason: typegres exports its own `Record` class (the pg composite/row type), and `Record<K, V>` 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.
10+
11+
```ts
12+
// Don't:
13+
const headers: Record<string, string> = {};
14+
15+
// Do:
16+
const headers: { [k: string]: string } = {};
17+
```
18+
19+
Using the typegres `Record` class as a type (e.g. `Record<O, 1>` as a return of `scalar()`) is fine — that's the intended use of the exported class.

eslint.config.js

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,6 @@ export default [
5454
selector: "MemberExpression[object.name='expose'][property.name='unchecked']",
5555
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 -- <reason>`.",
5656
}],
57-
"@typescript-eslint/no-restricted-types": ["error", {
58-
types: {
59-
"Record": {
60-
message: "Use { [key: string]: T } instead. 'Record' conflicts with the pg Record type.",
61-
},
62-
},
63-
}],
6457
},
6558
},
6659
{

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "typegres",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"type": "module",
55
"main": "./dist/index.mjs",
66
"types": "./dist/index.d.mts",

src/builder/query.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { test, expect, expectTypeOf } from "vitest";
22
import { Int4, Int8, Text, Bool, Jsonb } from "../types";
33
import { sql, compile } from "./sql";
44
import { setupDb, db } from "../test-helpers";
5+
import { expose } from "typegres";
56
setupDb();
67

78
// --- values() ---
@@ -742,6 +743,9 @@ test("groupBy: multiple calls stack", async () => {
742743
)
743744
.groupBy((n) => [n.values.a])
744745
.groupBy((n) => [n.values.b])
746+
// @ts-expect-error --- TODO: typing here is broken as the tuple
747+
// type intersected with the namespace (with the previous tuple) isn't
748+
// quite correct
745749
.select(({ 0: a, 1: b, values }) => ({ a, b, total: values.c.sum() }))
746750
.orderBy((n) => [n[0] as any, "asc"])
747751
);
@@ -852,7 +856,9 @@ test("type test: db.execute(Table.from()) row methods are never-typed (uncallabl
852856
await tx.execute(sql`INSERT INTO widgets (name) VALUES ('w1')`);
853857

854858
class Widgets extends db.Table("widgets") {
859+
@expose()
855860
id = (Int8<1>).column({ nonNull: true, generated: true });
861+
@expose()
856862
name = (Text<1>).column({ nonNull: true });
857863

858864
// Plain method — should not be a callable function on the row type.

src/builder/query.ts

Lines changed: 16 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,13 @@ import { isTableClass, TableBase } from "../table";
99
import z from "zod";
1010
import { Values } from "./values";
1111

12-
// Extract only Any<> instances from a row type
13-
export const selectList = <T extends RowType>(output: T): T => {
14-
return Object.fromEntries(Object.entries(output).filter(([, v]) => v instanceof Any)) as T;
15-
};
16-
1712
// Compile a row type into a SQL select list: col AS "name", ...
18-
export const compileSelectList = (output: RowType): Sql => {
13+
export const compileSelectList = (output: RowType, omitAliases = false): Sql => {
1914
return sql.join(
2015
Object.entries(output).flatMap(([k, v]) =>
21-
v instanceof Any ? [sql`${v.toSql()} as ${sql.ident(k)}`] : [],
16+
v instanceof Any ? [
17+
omitAliases ? v.toSql() :
18+
sql`${v.toSql()} as ${sql.ident(k)}`] : [],
2219
),
2320
);
2421
};
@@ -44,30 +41,6 @@ export const reAlias = <R extends RowType>(row: R, alias: Alias): R => {
4441
return out;
4542
};
4643

47-
// Deserialize raw string rows using typed output descriptors
48-
export const deserializeRows = <R>(
49-
rows: { [key: string]: string }[],
50-
output: { [key: string]: unknown },
51-
): R[] => {
52-
return rows.map((row) =>
53-
Object.fromEntries(
54-
Object.entries(row).map(([k, v]) => {
55-
const type = output[k];
56-
if (!(type instanceof Any)) {
57-
throw new Error(
58-
`deserializeRows: output column '${k}' is not a typed pg expression (got ${typeof v}). ` +
59-
`The select callback must return an object whose values are Any instances.`,
60-
);
61-
}
62-
if (v === null || v === undefined) {
63-
return [k, null];
64-
}
65-
return [k, type.deserialize(String(v))];
66-
}),
67-
),
68-
) as R[];
69-
};
70-
7144
// Hydrate raw rows into typed instances that share the shape's prototype.
7245
// Each column field is an Any wrapping a CAST(param) of the deserialized
7346
// value — so methods on the class that reference `this.col` can compose
@@ -102,13 +75,19 @@ export const hydrateRows = <R>(
10275
};
10376

10477
// Mapping of row name to type (class instance)
105-
export type RowType = object;
78+
export type RowType = TableBase | { [k: string]: Any<any> };
10679
export const isRowType = (obj: unknown): obj is RowType => {
10780
if (obj === null || typeof obj !== "object") {
10881
return false;
10982
}
83+
if (obj instanceof TableBase) {
84+
return true;
85+
}
11086
const proto = Object.getPrototypeOf(obj);
111-
return obj instanceof TableBase || proto === Object.prototype || proto === null;
87+
if (proto !== Object.prototype && proto !== null) {
88+
return false;
89+
}
90+
return Object.entries(obj).every(([_, v]) => v instanceof Any);
11291
};
11392

11493
// All of the row types in the current namespace
@@ -285,7 +264,7 @@ export class QueryBuilder<
285264
// overload, R widens to `TableBase` and column access on the namespace fails.
286265
// By destructuring the constructor to `InstanceType<T>` directly, we capture
287266
// the concrete subclass type (`Owners`, `Pets`, …).
288-
join<T extends { readonly tsAlias: string; new (): object }>(
267+
join<T extends typeof TableBase>(
289268
from: T,
290269
on: (ns: N & { [K in T["tsAlias"]]: InstanceType<T> }) => Bool<any>,
291270
): QueryBuilder<N & { [K in T["tsAlias"]]: InstanceType<T> }, O, GB>;
@@ -302,7 +281,7 @@ export class QueryBuilder<
302281
});
303282
}
304283

305-
leftJoin<T extends { readonly tsAlias: string; new (): object }>(
284+
leftJoin<T extends typeof TableBase>(
306285
from: T,
307286
on: (ns: N & { [K in T["tsAlias"]]: RowTypeToNullable<InstanceType<T>> }) => Bool<any>,
308287
): QueryBuilder<N & { [K in T["tsAlias"]]: RowTypeToNullable<InstanceType<T>> }, O, GB>;
@@ -426,25 +405,20 @@ export class QueryBuilder<
426405
// TODO: ROW(), array_agg(), COALESCE should be regular typed ops once we support them
427406
// Conditional return type avoids overload resolution quirks: TS's `this:`
428407
// overloads can pick the wrong branch when the Card type is already narrowed.
429-
/* eslint-disable @typescript-eslint/no-restricted-types */
430408
scalar(): [Card] extends ["one"]
431409
? Record<O, 1>
432410
: [Card] extends ["maybe"]
433411
? Record<O, 0 | 1>
434412
: Anyarray<Record<O, 1>, 1>;
435-
/* eslint-enable @typescript-eslint/no-restricted-types */
436413
@expose()
437414
scalar(): any {
438-
const staticCols = selectList(this.rowType());
439-
const RecordClass = Record.of(staticCols as any);
415+
const RecordClass = Record.of(this.rowType());
440416

441417
// Wrap as a subquery: (SELECT ROW(...) FROM ... WHERE ...)
442418
// inner QB — when embedded in sql``, its emit() wraps as subquery with AS
443419
const inner = this.select((ns) => {
444-
const cols = selectList(this.#doSelect(ns));
445-
const rowExprs = Object.values(cols).map((type: any) => type.toSql());
446420
// ROW() takes raw expressions without aliases
447-
const rowSql = sql`ROW(${sql.join(rowExprs)})`;
421+
const rowSql = sql`ROW(${compileSelectList(this.#doSelect(ns), true)})`;
448422
return { __row: RecordClass.from(rowSql) };
449423
});
450424
if (this.card === "many") {

src/database.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ExecuteFn, Driver, QueryResult } from "./driver";
22
import type { Fromable, RowType, RowTypeToTsType } from "./builder/query";
3-
import { QueryBuilder, deserializeRows, hydrateRows } from "./builder/query";
3+
import { QueryBuilder, hydrateRows } from "./builder/query";
4+
import { deserializeRows } from "./util";
45
import type { Sql } from "./builder/sql";
56
import { sql } from "./builder/sql";
67
import { Table, type TableBase, type TableOptions } from "./table";
@@ -78,14 +79,14 @@ export class Database<C = undefined> {
7879
async execute(query: Sql): Promise<any> {
7980
const result = await this.#exec(query);
8081
if (query instanceof QueryBuilder) {
81-
return deserializeRows(result.rows as { [key: string]: string }[], query.rowType() as { [key: string]: unknown });
82+
return deserializeRows(result.rows as { [key: string]: string }[], query.rowType());
8283
}
8384
if (query instanceof InsertBuilder || query instanceof UpdateBuilder || query instanceof DeleteBuilder) {
8485
const returning = query.rowType();
8586
if (!returning) {
8687
return [];
8788
}
88-
return deserializeRows(result.rows as { [key: string]: string }[], returning as { [key: string]: unknown });
89+
return deserializeRows(result.rows as { [key: string]: string }[], returning);
8990
}
9091
return result;
9192
}

src/exoeval/tool.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import z from 'zod'
44
export const toolSymbol = Symbol.for('exoeval_tool')
55
export const toolFieldsSymbol = Symbol.for('exoeval_toolFields')
66

7+
// Reads the `@expose` marker if present. Returns the Set when the
8+
// output is a marker-bearing instance (a typegres Table row); returns
9+
// `undefined` for POJOs (no filter applies). Callers extract once and
10+
// pass the result to consumers like `deserializeRows` / `Record.of`.
11+
export const exposedFieldsOf = (output: object): Set<string> | undefined =>
12+
(output as { [k: symbol]: unknown })[toolFieldsSymbol] as Set<string> | undefined
13+
714
export type ToolKind = 'raw' | 'expr' | 'constructor'
815

916
export type ToolFunction<T extends (...args: unknown[]) => unknown = (...args: unknown[]) => unknown> = T & ((...args: unknown[]) => unknown) & {

src/hydrate.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect, expectTypeOf, beforeAll } from "vitest";
2-
import { typegres, sql, Table, Int8, Text, Bool } from "typegres";
2+
import { typegres, sql, Table, Int8, Text, Bool, expose } from "typegres";
33
import type { Database, QueryBuilder } from "typegres";
44

55
// End-to-end tests for db.hydrate(): materialize query rows as class
@@ -17,9 +17,16 @@ class User extends Table("users") {
1717
}
1818

1919
class Todo extends Table("todos") {
20+
@expose()
2021
id = (Int8<1>).column({ nonNull: true, generated: true });
22+
23+
@expose()
2124
user_id = (Int8<1>).column({ nonNull: true });
25+
26+
@expose()
2227
title = (Text<1>).column({ nonNull: true });
28+
29+
@expose()
2330
completed = (Bool<1>).column({ nonNull: true });
2431

2532
update(fields: { completed?: boolean; title?: string }) {

0 commit comments

Comments
 (0)