diff --git a/README.md b/README.md index 23c0049..47e6921 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,21 @@ Generated TypeScript types are compile-time types. The generated code does not post-process rows or validate runtime values. Runtime parsing belongs to postgres.js configuration. +PostgreSQL `json` and `jsonb` columns are emitted as `JsonValue` instead of +`any`: + +```ts +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = + | JsonPrimitive + | readonly JsonValue[] + | { readonly [key: string]: JsonValue | undefined }; +``` + +JSON parameters are serialized with `sql.json(...)` in generated queries. For +nullable JSON parameters, JavaScript `null` is sent as SQL `NULL`; use a +non-nullable JSON parameter if you need to write the JSON literal `null`. + ## Vercel Notes The generated code depends on the `postgres` npm package. postgres.js is pure diff --git a/examples/authors/postgresql/query.sql b/examples/authors/postgresql/query.sql index a4b0202..65b6cdd 100644 --- a/examples/authors/postgresql/query.sql +++ b/examples/authors/postgresql/query.sql @@ -8,9 +8,9 @@ ORDER BY name; -- name: CreateAuthor :one INSERT INTO authors ( - name, bio, status + name, bio, status, profile, notes ) VALUES ( - $1, $2, $3 + $1, $2, $3, $4, $5 ) RETURNING *; diff --git a/examples/authors/postgresql/schema.sql b/examples/authors/postgresql/schema.sql index 9aa7b11..9538c63 100644 --- a/examples/authors/postgresql/schema.sql +++ b/examples/authors/postgresql/schema.sql @@ -4,5 +4,7 @@ CREATE TABLE authors ( id BIGSERIAL PRIMARY KEY, name text NOT NULL, bio text, - status author_status NOT NULL DEFAULT 'active' + status author_status NOT NULL DEFAULT 'active', + profile jsonb NOT NULL DEFAULT '{}'::jsonb, + notes jsonb ); diff --git a/examples/bun-postgres/src/db/query_sql.ts b/examples/bun-postgres/src/db/query_sql.ts index 4bb31cd..e437a6b 100644 --- a/examples/bun-postgres/src/db/query_sql.ts +++ b/examples/bun-postgres/src/db/query_sql.ts @@ -2,6 +2,12 @@ import type { Sql } from "postgres"; +export type JsonPrimitive = string | number | boolean | null; + +export type JsonValue = JsonPrimitive | readonly JsonValue[] | { + readonly [key: string]: JsonValue | undefined; +}; + export type AuthorStatus = "active" | "inactive" | "pending"; export interface GetAuthorArgs { @@ -13,10 +19,12 @@ export interface GetAuthorRow { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export async function getAuthor(sql: Sql, args: GetAuthorArgs): Promise { - const rows = await sql `SELECT id, name, bio, status FROM authors + const rows = await sql `SELECT id, name, bio, status, profile, notes FROM authors WHERE id = ${args.id} LIMIT 1`; return rows[0] ?? null; } @@ -26,10 +34,12 @@ export interface ListAuthorsRow { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export async function listAuthors(sql: Sql): Promise { - return await sql `SELECT id, name, bio, status FROM authors + return await sql `SELECT id, name, bio, status, profile, notes FROM authors ORDER BY name`; } @@ -37,6 +47,8 @@ export interface CreateAuthorArgs { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export interface CreateAuthorRow { @@ -44,15 +56,17 @@ export interface CreateAuthorRow { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export async function createAuthor(sql: Sql, args: CreateAuthorArgs): Promise { const rows = await sql `INSERT INTO authors ( - name, bio, status + name, bio, status, profile, notes ) VALUES ( - ${args.name}, ${args.bio}, ${args.status} + ${args.name}, ${args.bio}, ${args.status}, ${sql.json(args.profile)}, ${args.notes === null ? null : sql.json(args.notes)} ) -RETURNING id, name, bio, status`; +RETURNING id, name, bio, status, profile, notes`; return rows[0] ?? null; } @@ -65,10 +79,12 @@ export interface ListAuthorsByStatusRow { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export async function listAuthorsByStatus(sql: Sql, args: ListAuthorsByStatusArgs): Promise { - return await sql `SELECT id, name, bio, status FROM authors + return await sql `SELECT id, name, bio, status, profile, notes FROM authors WHERE status = ${args.status} ORDER BY name`; } diff --git a/examples/bun-postgres/src/main.ts b/examples/bun-postgres/src/main.ts index ed4092f..7e2a5ba 100644 --- a/examples/bun-postgres/src/main.ts +++ b/examples/bun-postgres/src/main.ts @@ -15,6 +15,8 @@ async function main() { name: "Seal", bio: "Kissed from a rose", status: "active", + profile: { website: "https://example.com", verified: true }, + notes: null, }); if (author === null) { throw new Error("author not created"); diff --git a/examples/node-postgres/src/db/query_sql.ts b/examples/node-postgres/src/db/query_sql.ts index 4bb31cd..e437a6b 100644 --- a/examples/node-postgres/src/db/query_sql.ts +++ b/examples/node-postgres/src/db/query_sql.ts @@ -2,6 +2,12 @@ import type { Sql } from "postgres"; +export type JsonPrimitive = string | number | boolean | null; + +export type JsonValue = JsonPrimitive | readonly JsonValue[] | { + readonly [key: string]: JsonValue | undefined; +}; + export type AuthorStatus = "active" | "inactive" | "pending"; export interface GetAuthorArgs { @@ -13,10 +19,12 @@ export interface GetAuthorRow { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export async function getAuthor(sql: Sql, args: GetAuthorArgs): Promise { - const rows = await sql `SELECT id, name, bio, status FROM authors + const rows = await sql `SELECT id, name, bio, status, profile, notes FROM authors WHERE id = ${args.id} LIMIT 1`; return rows[0] ?? null; } @@ -26,10 +34,12 @@ export interface ListAuthorsRow { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export async function listAuthors(sql: Sql): Promise { - return await sql `SELECT id, name, bio, status FROM authors + return await sql `SELECT id, name, bio, status, profile, notes FROM authors ORDER BY name`; } @@ -37,6 +47,8 @@ export interface CreateAuthorArgs { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export interface CreateAuthorRow { @@ -44,15 +56,17 @@ export interface CreateAuthorRow { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export async function createAuthor(sql: Sql, args: CreateAuthorArgs): Promise { const rows = await sql `INSERT INTO authors ( - name, bio, status + name, bio, status, profile, notes ) VALUES ( - ${args.name}, ${args.bio}, ${args.status} + ${args.name}, ${args.bio}, ${args.status}, ${sql.json(args.profile)}, ${args.notes === null ? null : sql.json(args.notes)} ) -RETURNING id, name, bio, status`; +RETURNING id, name, bio, status, profile, notes`; return rows[0] ?? null; } @@ -65,10 +79,12 @@ export interface ListAuthorsByStatusRow { name: string; bio: string | null; status: AuthorStatus; + profile: JsonValue; + notes: JsonValue | null; } export async function listAuthorsByStatus(sql: Sql, args: ListAuthorsByStatusArgs): Promise { - return await sql `SELECT id, name, bio, status FROM authors + return await sql `SELECT id, name, bio, status, profile, notes FROM authors WHERE status = ${args.status} ORDER BY name`; } diff --git a/examples/node-postgres/src/main.ts b/examples/node-postgres/src/main.ts index a41a28e..f4e3840 100644 --- a/examples/node-postgres/src/main.ts +++ b/examples/node-postgres/src/main.ts @@ -25,6 +25,8 @@ async function main() { name: "Seal", bio: "Kissed from a rose", status: "active", + profile: { website: "https://example.com", verified: true }, + notes: null, }); if (author === null) { throw new Error("author not created"); diff --git a/llms.txt b/llms.txt index 121f7e0..d6f83e0 100644 --- a/llms.txt +++ b/llms.txt @@ -46,5 +46,9 @@ postgres.js. Current bigint policy emits PostgreSQL `int8`/`bigint`/`bigserial` as TypeScript `number`, so configure OID 20 parsing as shown when selecting bigint values. This is intended for values below `Number.MAX_SAFE_INTEGER`. +PostgreSQL `json` and `jsonb` are emitted as generated `JsonValue` aliases +instead of `any`. Scalar JSON parameters are wrapped with `sql.json(...)`; for +nullable JSON parameters, JavaScript `null` is sent as SQL `NULL`. + Current supported surface: PostgreSQL, postgres.js, Node.js/Vercel. Do not rely on old Bun SQL, MySQL, SQLite, or upstream-sync README instructions. diff --git a/src/app.ts b/src/app.ts index 5e15070..1387f84 100644 --- a/src/app.ts +++ b/src/app.ts @@ -66,6 +66,55 @@ function enumTypeDecl(name: string, enumDef: Enum): Node { ); } +function jsonTypeDecls(): Node[] { + return [ + factory.createTypeAliasDeclaration( + [factory.createToken(SyntaxKind.ExportKeyword)], + factory.createIdentifier("JsonPrimitive"), + undefined, + factory.createUnionTypeNode([ + factory.createKeywordTypeNode(SyntaxKind.StringKeyword), + factory.createKeywordTypeNode(SyntaxKind.NumberKeyword), + factory.createKeywordTypeNode(SyntaxKind.BooleanKeyword), + factory.createLiteralTypeNode(factory.createNull()), + ]), + ), + factory.createTypeAliasDeclaration( + [factory.createToken(SyntaxKind.ExportKeyword)], + factory.createIdentifier("JsonValue"), + undefined, + factory.createUnionTypeNode([ + factory.createTypeReferenceNode(factory.createIdentifier("JsonPrimitive"), undefined), + factory.createTypeOperatorNode( + SyntaxKind.ReadonlyKeyword, + factory.createArrayTypeNode( + factory.createTypeReferenceNode(factory.createIdentifier("JsonValue"), undefined), + ), + ), + factory.createTypeLiteralNode([ + factory.createIndexSignature( + [factory.createToken(SyntaxKind.ReadonlyKeyword)], + [ + factory.createParameterDeclaration( + undefined, + undefined, + factory.createIdentifier("key"), + undefined, + factory.createKeywordTypeNode(SyntaxKind.StringKeyword), + undefined, + ), + ], + factory.createUnionTypeNode([ + factory.createTypeReferenceNode(factory.createIdentifier("JsonValue"), undefined), + factory.createKeywordTypeNode(SyntaxKind.UndefinedKeyword), + ]), + ), + ]), + ]), + ), + ]; +} + /** * Convert snake_case to PascalCase */ @@ -101,6 +150,7 @@ function codegen(input: GenerateRequest): GenerateResponse { // Track enums used in this file const fileEnums = new Set(); + let fileUsesJson = false; for (const query of queries) { const lowerName = query.name[0].toLowerCase() + query.name.slice(1); @@ -125,6 +175,9 @@ function codegen(input: GenerateRequest): GenerateResponse { fileEnums.add(enumName); usedEnums.add(enumName); } + if (postgres.isJsonColumn(param.column)) { + fileUsesJson = true; + } } try { @@ -168,6 +221,9 @@ function codegen(input: GenerateRequest): GenerateResponse { fileEnums.add(enumName); usedEnums.add(enumName); } + if (postgres.isJsonColumn(col)) { + fileUsesJson = true; + } } try { @@ -232,7 +288,12 @@ function codegen(input: GenerateRequest): GenerateResponse { } } - // Add enum type declarations at the beginning of the file (after imports) + // Add shared JSON and enum type declarations at the beginning of the file (after imports) + const sharedTypeNodes: Node[] = []; + if (fileUsesJson) { + sharedTypeNodes.push(...jsonTypeDecls()); + } + const enumNodes: Node[] = []; for (const enumName of fileEnums) { const enumDef = enumMap.get(enumName); @@ -243,7 +304,7 @@ function codegen(input: GenerateRequest): GenerateResponse { // Insert enum declarations after the preamble (imports) const preambleLength = postgres.preamble().length; - nodes.splice(preambleLength, 0, ...enumNodes); + nodes.splice(preambleLength, 0, ...sharedTypeNodes, ...enumNodes); files.push( new File({ diff --git a/src/drivers/postgres.test.ts b/src/drivers/postgres.test.ts new file mode 100644 index 0000000..a4ff58f --- /dev/null +++ b/src/drivers/postgres.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; +import { + EmitHint, + NewLineKind, + Node, + ScriptKind, + ScriptTarget, + createPrinter, + createSourceFile, +} from "typescript"; + +import { Column, Identifier, Parameter } from "../gen/plugin/codegen_pb"; +import * as postgres from "./postgres"; + +function render(node: Node): string { + const sourceFile = createSourceFile("file.ts", "", ScriptTarget.Latest, false, ScriptKind.TS); + return createPrinter({ newLine: NewLineKind.LineFeed }).printNode( + EmitHint.Unspecified, + node, + sourceFile, + ); +} + +function column(name: string, typeName: string, notNull: boolean): Column { + return new Column({ + name, + notNull, + type: new Identifier({ name: typeName }), + }); +} + +describe("postgres driver JSON types", () => { + it("maps jsonb to JsonValue", () => { + expect(render(postgres.columnType(column("profile", "jsonb", true)))).toBe("JsonValue"); + }); + + it("preserves SQL nullability for nullable jsonb", () => { + expect(render(postgres.columnType(column("notes", "jsonb", false)))).toBe("JsonValue | null"); + }); + + it("throws instead of emitting any when type metadata is missing", () => { + expect(() => postgres.columnType(new Column({ name: "payload" }))).toThrow( + /Missing PostgreSQL type metadata/, + ); + }); + + it("wraps scalar JSON parameters with sql.json", () => { + const node = postgres.execDecl( + "updateAuthor", + "UPDATE authors SET profile = $1, notes = $2", + "UpdateAuthorArgs", + [ + new Parameter({ column: column("profile", "jsonb", true) }), + new Parameter({ column: column("notes", "jsonb", false) }), + ], + ); + + const output = render(node); + expect(output).toContain("${sql.json(args.profile)}"); + expect(output).toContain("${args.notes === null ? null : sql.json(args.notes)}"); + }); +}); diff --git a/src/drivers/postgres.ts b/src/drivers/postgres.ts index c480f4f..c3b8ff3 100644 --- a/src/drivers/postgres.ts +++ b/src/drivers/postgres.ts @@ -7,7 +7,14 @@ * - types.bigint.parse for bigint -> number conversion */ -import { SyntaxKind, NodeFlags, TypeNode, factory, FunctionDeclaration } from "typescript"; +import { + SyntaxKind, + NodeFlags, + TypeNode, + Expression, + factory, + FunctionDeclaration, +} from "typescript"; import { Parameter, Column, Enum } from "../gen/plugin/codegen_pb"; import { argName } from "./utils"; @@ -29,13 +36,37 @@ export function getEnumName(column?: Column): string | null { if (column === undefined || column.type === undefined) { return null; } - const typeName = column.type.name.toLowerCase(); + const typeName = normalizedTypeName(column); + if (typeName === null) { + return null; + } if (enumMap.has(typeName)) { return typeName; } return null; } +function normalizedTypeName(column?: Column): string | null { + if (column === undefined || column.type === undefined) { + return null; + } + let typeName = column.type.name; + const pgCatalog = "pg_catalog."; + if (typeName.startsWith(pgCatalog)) { + typeName = typeName.slice(pgCatalog.length); + } + return typeName.toLowerCase(); +} + +export function isJsonColumn(column?: Column): boolean { + const typeName = normalizedTypeName(column); + return typeName === "json" || typeName === "jsonb"; +} + +function isScalarJsonColumn(column?: Column): boolean { + return isJsonColumn(column) && !column?.isArray && (column?.arrayDims ?? 0) === 0; +} + /** * Convert snake_case to PascalCase for enum type names */ @@ -48,17 +79,20 @@ function pascalCase(str: string): string { export function columnType(column?: Column): TypeNode { if (column === undefined || column.type === undefined) { - return factory.createKeywordTypeNode(SyntaxKind.AnyKeyword); + throw new Error( + `Missing PostgreSQL type metadata for column "${column?.name || "unknown"}". ` + + `Try adding an explicit cast or named parameter in your query.`, + ); } const originalTypeName = column.type.name; - let typeName = originalTypeName; - const pgCatalog = "pg_catalog."; - if (typeName.startsWith(pgCatalog)) { - typeName = typeName.slice(pgCatalog.length); + const lowerTypeName = normalizedTypeName(column); + if (lowerTypeName === null) { + throw new Error( + `Missing PostgreSQL type metadata for column "${column.name || "unknown"}". ` + + `Try adding an explicit cast or named parameter in your query.`, + ); } - const lowerTypeName = typeName.toLowerCase(); - // Check if it's an enum type if (enumMap.has(lowerTypeName)) { const typ = factory.createTypeReferenceNode( @@ -126,10 +160,10 @@ export function columnType(column?: Column): TypeNode { case "oid": typ = factory.createKeywordTypeNode(SyntaxKind.NumberKeyword); break; - // JSON types - any to allow flexible object access + // JSON types case "json": case "jsonb": - typ = factory.createKeywordTypeNode(SyntaxKind.AnyKeyword); + typ = factory.createTypeReferenceNode(factory.createIdentifier("JsonValue"), undefined); break; // Void type (from functions like pg_advisory_xact_lock) case "void": @@ -254,7 +288,7 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { function buildTaggedTemplate(queryText: string, params: Parameter[]) { // Parse the SQL to find $1, $2, etc. and split into parts const parts: string[] = []; - const expressions: ReturnType[] = []; + const expressions: Expression[] = []; // Regex to match $1, $2, etc. const paramRegex = /\$(\d+)/g; @@ -269,12 +303,39 @@ function buildTaggedTemplate(queryText: string, params: Parameter[]) { const param = params[paramIndex]; if (param) { - expressions.push( - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier(argName(paramIndex, param.column)), - ), + const arg = factory.createPropertyAccessExpression( + factory.createIdentifier("args"), + factory.createIdentifier(argName(paramIndex, param.column)), ); + if (isScalarJsonColumn(param.column)) { + const jsonArg = factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier("sql"), + factory.createIdentifier("json"), + ), + undefined, + [arg], + ); + if (param.column?.notNull) { + expressions.push(jsonArg); + } else { + expressions.push( + factory.createConditionalExpression( + factory.createBinaryExpression( + arg, + factory.createToken(SyntaxKind.EqualsEqualsEqualsToken), + factory.createNull(), + ), + factory.createToken(SyntaxKind.QuestionToken), + factory.createNull(), + factory.createToken(SyntaxKind.ColonToken), + jsonArg, + ), + ); + } + } else { + expressions.push(arg); + } } else { // Fallback if param not found (shouldn't happen) expressions.push(