Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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<K, V>` utility type.** Use `{ [k: K]: V }` directly.

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.

```ts
// Don't:
const headers: Record<string, string> = {};

// Do:
const headers: { [k: string]: string } = {};
```

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.
7 changes: 0 additions & 7 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <reason>`.",
}],
"@typescript-eslint/no-restricted-types": ["error", {
types: {
"Record": {
message: "Use { [key: string]: T } instead. 'Record' conflicts with the pg Record type.",
},
},
}],
},
},
{
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/builder/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() ---
Expand Down Expand Up @@ -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"])
Comment thread
ryanrasti marked this conversation as resolved.
);
Expand Down Expand Up @@ -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.
Expand Down
58 changes: 16 additions & 42 deletions src/builder/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends RowType>(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)}`] : [],
),
);
};
Expand All @@ -44,30 +41,6 @@ export const reAlias = <R extends RowType>(row: R, alias: Alias): R => {
return out;
};

// Deserialize raw string rows using typed output descriptors
export const deserializeRows = <R>(
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
Expand Down Expand Up @@ -102,13 +75,19 @@ export const hydrateRows = <R>(
};

// Mapping of row name to type (class instance)
export type RowType = object;
export type RowType = TableBase | { [k: string]: Any<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
Expand Down Expand Up @@ -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<T>` directly, we capture
// the concrete subclass type (`Owners`, `Pets`, …).
join<T extends { readonly tsAlias: string; new (): object }>(
join<T extends typeof TableBase>(
from: T,
on: (ns: N & { [K in T["tsAlias"]]: InstanceType<T> }) => Bool<any>,
): QueryBuilder<N & { [K in T["tsAlias"]]: InstanceType<T> }, O, GB>;
Expand All @@ -302,7 +281,7 @@ export class QueryBuilder<
});
}

leftJoin<T extends { readonly tsAlias: string; new (): object }>(
leftJoin<T extends typeof TableBase>(
from: T,
on: (ns: N & { [K in T["tsAlias"]]: RowTypeToNullable<InstanceType<T>> }) => Bool<any>,
): QueryBuilder<N & { [K in T["tsAlias"]]: RowTypeToNullable<InstanceType<T>> }, O, GB>;
Expand Down Expand Up @@ -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<O, 1>
: [Card] extends ["maybe"]
? Record<O, 0 | 1>
: Anyarray<Record<O, 1>, 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") {
Expand Down
7 changes: 4 additions & 3 deletions src/database.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -78,14 +79,14 @@ export class Database<C = undefined> {
async execute(query: Sql): Promise<any> {
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;
}
Expand Down
7 changes: 7 additions & 0 deletions src/exoeval/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | undefined =>
(output as { [k: symbol]: unknown })[toolFieldsSymbol] as Set<string> | undefined

export type ToolKind = 'raw' | 'expr' | 'constructor'

export type ToolFunction<T extends (...args: unknown[]) => unknown = (...args: unknown[]) => unknown> = T & ((...args: unknown[]) => unknown) & {
Expand Down
9 changes: 8 additions & 1 deletion src/hydrate.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }) {
Expand Down
Loading
Loading