diff --git a/README.md b/README.md index 5798b73..11d600e 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,103 @@ sql: driver: better-sqlite3 # npm package name ``` +## Strongly typed columns + +### Enums + +Enum types declared in your schema are generated as string literal union +types, along with a runtime array of their values. No configuration is +required. + +```sql +CREATE TYPE status AS ENUM ('active', 'inactive', 'banned'); + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + st status NOT NULL +); +``` + +```ts +export type Status = "active" | "inactive" | "banned"; + +export const StatusValues: readonly Status[] = ["active", "inactive", "banned"]; + +export interface GetUserRow { + id: string; + st: Status; +} +``` + +### Type overrides + +Use the `overrides` option to map SQL types (including domains and other +custom types) or individual columns to your own TypeScript types. Each entry +matches either a SQL type name (`db_type`) or a single column +(`column: "table.column"`), and emits `ts_type` verbatim. An optional +`import` adds a type-only import to files that use the override. + +```yaml +codegen: +- out: db + plugin: ts + options: + runtime: node + driver: pg + overrides: + # Map a domain (or any SQL type) to an imported type + - db_type: "email" + ts_type: "Email" + import: + path: "./brands" + name: "Email" + # Inline branded/nominal types work too + - db_type: "user_id" + ts_type: 'string & { readonly __brand: "user_id" }' + # Give a JSON column a concrete payload type + - column: "users.meta" + ts_type: "UserMeta" + import: + path: "./brands" + name: "UserMeta" +``` + +```ts +import type { Email, UserMeta } from "./brands"; + +export interface GetUserRow { + id: string & { readonly __brand: "user_id" }; + email: Email; + meta: UserMeta | null; +} +``` + +Column overrides take precedence over `db_type` overrides, which take +precedence over generated enum types and the built-in type mappings. +Nullability and array handling still apply: a nullable overridden column is +emitted as `T | null`, and an array column as `T[]`. + +Note that overrides only change the generated *types* — converting values at +runtime (for example, parsing a `numeric` string into a `Big`) is still up to +your driver configuration. + +### Types-only generation + +Set `types_only: true` to generate only the `Args`/`Row` interfaces, enum +union types, and override imports — no driver imports, query constants, or +query functions. This is useful for sharing database types with a frontend +that never runs the queries itself. + +```yaml +codegen: +- out: src/types/db + plugin: ts + options: + runtime: node + driver: pg + types_only: true +``` + ## Development If you want to build and test sqlc-gen-typescript locally, follow these steps: diff --git a/examples/bun-mysql2/src/db/query_sql.ts b/examples/bun-mysql2/src/db/query_sql.ts index 1673007..9b3efeb 100644 --- a/examples/bun-mysql2/src/db/query_sql.ts +++ b/examples/bun-mysql2/src/db/query_sql.ts @@ -1,6 +1,8 @@ // Code generated by sqlc. DO NOT EDIT. -import mysql, { RowDataPacket, ResultSetHeader } from "mysql2/promise"; +import type mysql from "mysql2/promise"; + +import type { RowDataPacket, ResultSetHeader } from "mysql2/promise"; type Client = mysql.Connection | mysql.Pool; diff --git a/examples/bun-pg/src/db/query_sql.ts b/examples/bun-pg/src/db/query_sql.ts index 2e8db52..525d4a4 100644 --- a/examples/bun-pg/src/db/query_sql.ts +++ b/examples/bun-pg/src/db/query_sql.ts @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. -import { QueryArrayConfig, QueryArrayResult } from "pg"; +import type { QueryArrayConfig, QueryArrayResult } from "pg"; interface Client { query: (config: QueryArrayConfig) => Promise; diff --git a/examples/bun-postgres/src/db/query_sql.ts b/examples/bun-postgres/src/db/query_sql.ts index 8e15f3b..1121031 100644 --- a/examples/bun-postgres/src/db/query_sql.ts +++ b/examples/bun-postgres/src/db/query_sql.ts @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. -import { Sql } from "postgres"; +import type { Sql } from "postgres"; export const getAuthorQuery = `-- name: GetAuthor :one SELECT id, name, bio FROM authors diff --git a/examples/node-better-sqlite3/src/db/query_sql.ts b/examples/node-better-sqlite3/src/db/query_sql.ts index 8996855..6628e69 100644 --- a/examples/node-better-sqlite3/src/db/query_sql.ts +++ b/examples/node-better-sqlite3/src/db/query_sql.ts @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. -import { Database } from "better-sqlite3"; +import type { Database } from "better-sqlite3"; export const getAuthorQuery = `-- name: GetAuthor :one SELECT id, name, bio FROM authors diff --git a/examples/node-mysql2/src/db/query_sql.ts b/examples/node-mysql2/src/db/query_sql.ts index c79988f..9d10881 100644 --- a/examples/node-mysql2/src/db/query_sql.ts +++ b/examples/node-mysql2/src/db/query_sql.ts @@ -1,6 +1,8 @@ // Code generated by sqlc. DO NOT EDIT. -import mysql, { RowDataPacket, ResultSetHeader } from "mysql2/promise"; +import type mysql from "mysql2/promise"; + +import type { RowDataPacket, ResultSetHeader } from "mysql2/promise"; type Client = mysql.Connection | mysql.Pool; @@ -91,12 +93,12 @@ export interface CreateAuthorReturnIdArgs { bio: string | null; } -export async function createAuthorReturnId(client: Client, args: CreateAuthorReturnIdArgs): Promise { +export async function createAuthorReturnId(client: Client, args: CreateAuthorReturnIdArgs): Promise { const [result] = await client.query({ sql: createAuthorReturnIdQuery, values: [args.name, args.bio] }); - return result?.insertId ?? 0; + return String(result?.insertId ?? 0); } export const deleteAuthorQuery = `-- name: DeleteAuthor :exec diff --git a/examples/node-pg/src/db/query_sql.ts b/examples/node-pg/src/db/query_sql.ts index 2e8db52..525d4a4 100644 --- a/examples/node-pg/src/db/query_sql.ts +++ b/examples/node-pg/src/db/query_sql.ts @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. -import { QueryArrayConfig, QueryArrayResult } from "pg"; +import type { QueryArrayConfig, QueryArrayResult } from "pg"; interface Client { query: (config: QueryArrayConfig) => Promise; diff --git a/examples/node-postgres/src/db/query_sql.ts b/examples/node-postgres/src/db/query_sql.ts index 8e15f3b..1121031 100644 --- a/examples/node-postgres/src/db/query_sql.ts +++ b/examples/node-postgres/src/db/query_sql.ts @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. -import { Sql } from "postgres"; +import type { Sql } from "postgres"; export const getAuthorQuery = `-- name: GetAuthor :one SELECT id, name, bio FROM authors diff --git a/src/app.ts b/src/app.ts index e3a077c..0ba4abe 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import { createPrinter, createSourceFile, factory, + isImportDeclaration, } from "typescript"; import { @@ -27,7 +28,12 @@ import { Query, } from "./gen/plugin/codegen_pb"; -import { argName, colName } from "./drivers/utlis"; +import { argName, colName, propertyKey } from "./drivers/utlis"; +import { + CustomTypeFn, + CustomTypeRegistry, + TypeOverride, +} from "./customTypes"; import { Driver as Sqlite3Driver } from "./drivers/better-sqlite3"; import { Driver as PgDriver } from "./drivers/pg"; import { Driver as PostgresDriver } from "./drivers/postgres"; @@ -44,6 +50,8 @@ interface Options { runtime?: string; driver?: string; mysql2?: Mysql2Options + overrides?: TypeOverride[]; + types_only?: boolean; } interface Driver { @@ -79,19 +87,22 @@ interface Driver { ) => Node; } -function createNodeGenerator(options: Options): Driver { +function createNodeGenerator( + options: Options, + customType: CustomTypeFn +): Driver { switch (options.driver) { case "mysql2": { - return new MysqlDriver(options.mysql2); + return new MysqlDriver(options.mysql2, customType); } case "pg": { - return new PgDriver(); + return new PgDriver(customType); } case "postgres": { - return new PostgresDriver(); + return new PostgresDriver(customType); } case "better-sqlite3": { - return new Sqlite3Driver(); + return new Sqlite3Driver(customType); } } throw new Error(`unknown driver: ${options.driver}`); @@ -106,7 +117,8 @@ function codegen(input: GenerateRequest): GenerateResponse { options = JSON.parse(text) as Options; } - const driver = createNodeGenerator(options); + const registry = new CustomTypeRegistry(input, options.overrides ?? []); + const driver = createNodeGenerator(options, registry.resolver()); // TODO: Verify options, parse them from protobuf honestly @@ -121,7 +133,16 @@ function codegen(input: GenerateRequest): GenerateResponse { } for (const [filename, queries] of querymap.entries()) { - const nodes = driver.preamble(queries); + // With types_only, emit only interfaces, enums, and override imports — + // no driver imports, query constants, or query functions + const nodes = options.types_only ? [] : driver.preamble(queries); + // Insert custom type imports and enum declarations after the driver's + // own imports, before any other declarations + let insertAt = 0; + while (insertAt < nodes.length && isImportDeclaration(nodes[insertAt])) { + insertAt++; + } + nodes.splice(insertAt, 0, ...registry.fileDecls(queries)); for (const query of queries) { const colmap = new Map(); @@ -139,13 +160,15 @@ function codegen(input: GenerateRequest): GenerateResponse { const lowerName = query.name[0].toLowerCase() + query.name.slice(1); const textName = `${lowerName}Query`; - nodes.push( - queryDecl( - textName, - `-- name: ${query.name} ${query.cmd} + if (!options.types_only) { + nodes.push( + queryDecl( + textName, + `-- name: ${query.name} ${query.cmd} ${query.text}` - ) - ); + ) + ); + } let argIface = undefined; let returnIface = undefined; @@ -158,7 +181,7 @@ ${query.text}` nodes.push(rowDecl(returnIface, driver, query.columns)); } - switch (query.cmd) { + switch (options.types_only ? "" : query.cmd) { case ":exec": { nodes.push( driver.execDecl(lowerName, textName, argIface, query.params) @@ -221,6 +244,13 @@ function readInput(): GenerateRequest { } function queryDecl(name: string, sql: string) { + // Escape characters that have special meaning inside a template literal so + // queries containing backticks (MySQL quoting), "${" or backslashes still + // produce the original SQL text at runtime + const raw = sql + .replace(/\\/g, "\\\\") + .replace(/`/g, "\\`") + .replace(/\$\{/g, "\\${"); return factory.createVariableStatement( [factory.createToken(SyntaxKind.ExportKeyword)], factory.createVariableDeclarationList( @@ -229,7 +259,7 @@ function queryDecl(name: string, sql: string) { factory.createIdentifier(name), undefined, undefined, - factory.createNoSubstitutionTemplateLiteral(sql, sql) + factory.createNoSubstitutionTemplateLiteral(sql, raw) ), ], NodeFlags.Const //| NodeFlags.Constant | NodeFlags.Constant @@ -250,7 +280,7 @@ function argsDecl( params.map((param, i) => factory.createPropertySignature( undefined, - factory.createIdentifier(argName(i, param.column)), + propertyKey(argName(i, param.column)), undefined, driver.columnType(param.column) ) @@ -271,7 +301,7 @@ function rowDecl( columns.map((column, i) => factory.createPropertySignature( undefined, - factory.createIdentifier(colName(i, column)), + propertyKey(colName(i, column)), undefined, driver.columnType(column) ) diff --git a/src/customTypes.ts b/src/customTypes.ts new file mode 100644 index 0000000..f3cd857 --- /dev/null +++ b/src/customTypes.ts @@ -0,0 +1,248 @@ +import { NodeFlags, SyntaxKind, TypeNode, Node, factory } from "typescript"; + +import { Column, GenerateRequest, Query } from "./gen/plugin/codegen_pb"; + +// A function that maps a column to a custom TypeScript type, or undefined to +// fall back to the driver's built-in type mapping. +export type CustomTypeFn = (column: Column) => TypeNode | undefined; + +export interface TypeImport { + // Module specifier to import from, e.g. "./brands" or "zod" + path: string; + // Named export to import. Imports are always emitted as type-only. + name: string; +} + +export interface TypeOverride { + // Match a SQL type by name, e.g. "uuid", "email" (a domain), or + // "pg_catalog.numeric". Either db_type or column must be set. + db_type?: string; + // Match a single column as "table.column", e.g. "authors.bio" + column?: string; + // TypeScript type expression to emit, e.g. "UserId" or + // `string & { readonly __brand: "email" }` + ts_type: string; + // Optional type-only import to add to files that use this override + import?: TypeImport; +} + +interface EnumEntry { + typeName: string; + valuesName: string; + vals: string[]; +} + +function pascalCase(name: string): string { + return name + .split(/[^a-zA-Z0-9]+/) + .filter((part) => part.length > 0) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(""); +} + +function stripCatalog(name: string): string { + const pgCatalog = "pg_catalog."; + return name.startsWith(pgCatalog) ? name.slice(pgCatalog.length) : name; +} + +// Prints an arbitrary type expression verbatim, e.g. `Branded` +function verbatimType(text: string): TypeNode { + return factory.createTypeReferenceNode( + factory.createIdentifier(text), + undefined + ); +} + +// Resolves custom column types from two sources, in priority order: +// +// 1. User-configured overrides (by column, then by SQL type name), which +// cover domains, branded/nominal types, and JSON payload types +// 2. Enum types declared in the schema, emitted as string literal unions +export class CustomTypeRegistry { + private enums: Map = new Map(); + private byColumn: Map = new Map(); + private byDbType: Map = new Map(); + + constructor(input: GenerateRequest, overrides: TypeOverride[]) { + const defaultSchema = input.catalog?.defaultSchema ?? ""; + for (const schema of input.catalog?.schemas ?? []) { + for (const enumType of schema.enums) { + const entry: EnumEntry = { + typeName: pascalCase(enumType.name), + valuesName: `${pascalCase(enumType.name)}Values`, + vals: enumType.vals, + }; + if (schema.name === defaultSchema || schema.name === "") { + this.enums.set(enumType.name, entry); + } + if (schema.name !== "") { + this.enums.set(`${schema.name}.${enumType.name}`, entry); + } + } + } + for (const override of overrides) { + if (!override.ts_type) { + throw new Error(`override is missing ts_type`); + } + if (override.column) { + this.byColumn.set(override.column, override); + } else if (override.db_type) { + this.byDbType.set(override.db_type, override); + } else { + throw new Error( + `override for ts_type "${override.ts_type}" must set db_type or column` + ); + } + } + } + + resolver(): CustomTypeFn { + return (column) => { + const override = this.overrideFor(column); + if (override) { + return verbatimType(override.ts_type); + } + const enumEntry = this.enumFor(column); + if (enumEntry) { + return factory.createTypeReferenceNode( + factory.createIdentifier(enumEntry.typeName), + undefined + ); + } + return undefined; + }; + } + + private overrideFor(column: Column): TypeOverride | undefined { + if (column.table?.name) { + const byColumn = this.byColumn.get( + `${column.table.name}.${column.name}` + ); + if (byColumn) { + return byColumn; + } + } + if (column.type?.name) { + return ( + this.byDbType.get(column.type.name) ?? + this.byDbType.get(stripCatalog(column.type.name)) + ); + } + return undefined; + } + + private enumFor(column: Column): EnumEntry | undefined { + if (!column.type?.name) { + return undefined; + } + return this.enums.get(stripCatalog(column.type.name)); + } + + // Declarations needed at the top of a generated file: type-only imports for + // overrides and literal union declarations for enums, limited to the types + // actually used by the file's queries. + fileDecls(queries: Query[]): Node[] { + const usedEnums: Map = new Map(); + const usedImports: Map> = new Map(); + + const visit = (column?: Column) => { + if (!column) { + return; + } + const override = this.overrideFor(column); + if (override?.import) { + if (!usedImports.has(override.import.path)) { + usedImports.set(override.import.path, new Set()); + } + usedImports.get(override.import.path)?.add(override.import.name); + return; + } + if (override) { + return; + } + const enumEntry = this.enumFor(column); + if (enumEntry) { + usedEnums.set(enumEntry.typeName, enumEntry); + } + }; + + for (const query of queries) { + query.columns.forEach(visit); + query.params.forEach((param) => visit(param.column)); + } + + const nodes: Node[] = []; + for (const path of [...usedImports.keys()].sort()) { + const names = [...(usedImports.get(path) ?? [])].sort(); + nodes.push( + factory.createImportDeclaration( + undefined, + factory.createImportClause( + true, + undefined, + factory.createNamedImports( + names.map((name) => + factory.createImportSpecifier( + false, + undefined, + factory.createIdentifier(name) + ) + ) + ) + ), + factory.createStringLiteral(path), + undefined + ) + ); + } + for (const typeName of [...usedEnums.keys()].sort()) { + const entry = usedEnums.get(typeName); + if (entry) { + nodes.push(...enumDecls(entry)); + } + } + return nodes; + } +} + +// export type Status = "active" | "inactive"; +// export const StatusValues: readonly Status[] = ["active", "inactive"]; +function enumDecls(entry: EnumEntry): Node[] { + return [ + factory.createTypeAliasDeclaration( + [factory.createToken(SyntaxKind.ExportKeyword)], + factory.createIdentifier(entry.typeName), + undefined, + factory.createUnionTypeNode( + entry.vals.map((val) => + factory.createLiteralTypeNode(factory.createStringLiteral(val)) + ) + ) + ), + factory.createVariableStatement( + [factory.createToken(SyntaxKind.ExportKeyword)], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier(entry.valuesName), + undefined, + factory.createTypeOperatorNode( + SyntaxKind.ReadonlyKeyword, + factory.createArrayTypeNode( + factory.createTypeReferenceNode( + factory.createIdentifier(entry.typeName), + undefined + ) + ) + ), + factory.createArrayLiteralExpression( + entry.vals.map((val) => factory.createStringLiteral(val)), + false + ) + ), + ], + NodeFlags.Const + ) + ), + ]; +} diff --git a/src/drivers/better-sqlite3.ts b/src/drivers/better-sqlite3.ts index c6210e8..820fedd 100644 --- a/src/drivers/better-sqlite3.ts +++ b/src/drivers/better-sqlite3.ts @@ -8,7 +8,8 @@ import { } from "typescript"; import { Parameter, Column, Query } from "../gen/plugin/codegen_pb"; -import { argName } from "./utlis"; +import { CustomTypeFn } from "../customTypes"; +import { argName, propertyAccess } from "./utlis"; function funcParamsDecl(iface: string | undefined, params: Parameter[]) { let funcParams = [ @@ -45,6 +46,12 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { } export class Driver { + private readonly customType: CustomTypeFn; + + constructor(customType?: CustomTypeFn) { + this.customType = customType ?? (() => undefined); + } + /** * {@link https://github.com/WiseLibs/better-sqlite3/blob/v9.4.1/docs/api.md#binding-parameters} * {@link https://github.com/sqlc-dev/sqlc/blob/v1.25.0/internal/codegen/golang/sqlite_type.go} @@ -55,7 +62,10 @@ export class Driver { } let typ: TypeNode = factory.createKeywordTypeNode(SyntaxKind.AnyKeyword); - switch (column.type.name) { + const custom = this.customType(column); + if (custom !== undefined) { + typ = custom; + } else switch (column.type.name) { case "int": case "integer": case "tinyint": @@ -115,7 +125,7 @@ export class Driver { factory.createImportDeclaration( undefined, factory.createImportClause( - false, + true, undefined, factory.createNamedImports([ factory.createImportSpecifier( @@ -190,10 +200,7 @@ export class Driver { ), undefined, params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier(argName(i, param.column)) - ) + propertyAccess("args", argName(i, param.column)) ) ) ) @@ -276,10 +283,7 @@ export class Driver { ), undefined, params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier(argName(i, param.column)) - ) + propertyAccess("args", argName(i, param.column)) ) ) ) @@ -391,10 +395,7 @@ export class Driver { ), undefined, params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier(argName(i, param.column)) - ) + propertyAccess("args", argName(i, param.column)) ) ) ) diff --git a/src/drivers/mysql2.ts b/src/drivers/mysql2.ts index 7859ade..cac0f84 100644 --- a/src/drivers/mysql2.ts +++ b/src/drivers/mysql2.ts @@ -1,9 +1,10 @@ -import { SyntaxKind, NodeFlags, TypeNode, factory } from "typescript"; +import { Expression, SyntaxKind, NodeFlags, TypeNode, factory } from "typescript"; // import { writeFileSync, STDIO } from "javy/fs"; import { Parameter, Column, Query } from "../gen/plugin/codegen_pb"; -import { argName, colName } from "./utlis"; +import { CustomTypeFn } from "../customTypes"; +import { argName, colName, propertyAccess, propertyKey } from "./utlis"; export interface Mysql2Options { support_big_numbers?: boolean; @@ -44,11 +45,27 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { return funcParams; } +// The mysql2 types declare insertId as a number, but with +// big_number_strings enabled the driver returns big values as strings, so +// convert explicitly to match the declared return type +function insertIdExpr(value: Expression, options: Mysql2Options): Expression { + if (options.support_big_numbers && options.big_number_strings) { + return factory.createCallExpression( + factory.createIdentifier("String"), + undefined, + [value] + ); + } + return value; +} + export class Driver { private readonly options: Mysql2Options + private readonly customType: CustomTypeFn; - constructor(options?: Mysql2Options) { + constructor(options?: Mysql2Options, customType?: CustomTypeFn) { this.options = options ?? {} + this.customType = customType ?? (() => undefined); } columnType(column?: Column): TypeNode { @@ -57,7 +74,10 @@ export class Driver { } let typ: TypeNode = factory.createKeywordTypeNode(SyntaxKind.StringKeyword); - switch (column.type.name) { + const custom = this.customType(column); + if (custom !== undefined) { + typ = custom; + } else switch (column.type.name) { case "bigint": { typ = factory.createKeywordTypeNode(SyntaxKind.NumberKeyword); @@ -231,8 +251,18 @@ export class Driver { factory.createImportDeclaration( undefined, factory.createImportClause( - false, + true, factory.createIdentifier("mysql"), + undefined + ), + factory.createStringLiteral("mysql2/promise"), + undefined + ), + factory.createImportDeclaration( + undefined, + factory.createImportClause( + true, + undefined, factory.createNamedImports([ factory.createImportSpecifier( false, @@ -318,10 +348,7 @@ export class Driver { factory.createIdentifier("values"), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier(argName(i, param.column)) - ) + propertyAccess("args", argName(i, param.column)) ), false ) @@ -408,12 +435,7 @@ export class Driver { factory.createIdentifier("values"), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier( - argName(i, param.column) - ) - ) + propertyAccess("args", argName(i, param.column)) ), false ) @@ -467,7 +489,7 @@ export class Driver { factory.createObjectLiteralExpression( columns.map((col, i) => factory.createPropertyAssignment( - factory.createIdentifier(colName(i, col)), + propertyKey(colName(i, col)), factory.createElementAccessExpression( factory.createIdentifier("row"), factory.createNumericLiteral(`${i}`) @@ -560,12 +582,7 @@ export class Driver { factory.createIdentifier("values"), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier( - argName(i, param.column) - ) - ) + propertyAccess("args", argName(i, param.column)) ), false ) @@ -631,7 +648,7 @@ export class Driver { factory.createObjectLiteralExpression( columns.map((col, i) => factory.createPropertyAssignment( - factory.createIdentifier(colName(i, col)), + propertyKey(colName(i, col)), factory.createElementAccessExpression( factory.createIdentifier("row"), factory.createNumericLiteral(`${i}`) @@ -655,6 +672,21 @@ export class Driver { ) { const funcParams = funcParamsDecl(argIface, params); + // Mirror the bigint column mapping: with support_big_numbers the driver + // may return the last insert id as a string, and with big_number_strings + // it always does + let insertIdType: TypeNode = factory.createKeywordTypeNode( + SyntaxKind.NumberKeyword + ); + if (this.options.support_big_numbers) { + insertIdType = this.options.big_number_strings + ? factory.createKeywordTypeNode(SyntaxKind.StringKeyword) + : factory.createUnionTypeNode([ + factory.createKeywordTypeNode(SyntaxKind.NumberKeyword), + factory.createKeywordTypeNode(SyntaxKind.StringKeyword), + ]); + } + return factory.createFunctionDeclaration( [ factory.createToken(SyntaxKind.ExportKeyword), @@ -665,7 +697,7 @@ export class Driver { undefined, funcParams, factory.createTypeReferenceNode(factory.createIdentifier("Promise"), [ - factory.createTypeReferenceNode("number", undefined), + insertIdType, ]), factory.createBlock( [ @@ -707,12 +739,7 @@ export class Driver { factory.createIdentifier("values"), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier( - argName(i, param.column) - ) - ) + propertyAccess("args", argName(i, param.column)) ), false ) @@ -734,14 +761,17 @@ export class Driver { ) ), factory.createReturnStatement( - factory.createBinaryExpression( - factory.createPropertyAccessChain( - factory.createIdentifier("result"), - factory.createToken(SyntaxKind.QuestionDotToken), - factory.createIdentifier("insertId") + insertIdExpr( + factory.createBinaryExpression( + factory.createPropertyAccessChain( + factory.createIdentifier("result"), + factory.createToken(SyntaxKind.QuestionDotToken), + factory.createIdentifier("insertId") + ), + factory.createToken(SyntaxKind.QuestionQuestionToken), + factory.createNumericLiteral(0) ), - factory.createToken(SyntaxKind.QuestionQuestionToken), - factory.createNumericLiteral(0) + this.options ) ), ], diff --git a/src/drivers/pg.ts b/src/drivers/pg.ts index 95828cf..0996972 100644 --- a/src/drivers/pg.ts +++ b/src/drivers/pg.ts @@ -8,7 +8,8 @@ import { } from "typescript"; import { Parameter, Column, Query } from "../gen/plugin/codegen_pb"; -import { argName, colName } from "./utlis"; +import { CustomTypeFn } from "../customTypes"; +import { argName, colName, propertyAccess, propertyKey } from "./utlis"; function funcParamsDecl(iface: string | undefined, params: Parameter[]) { let funcParams = [ @@ -45,6 +46,12 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { } export class Driver { + private readonly customType: CustomTypeFn; + + constructor(customType?: CustomTypeFn) { + this.customType = customType ?? (() => undefined); + } + columnType(column?: Column): TypeNode { if (column === undefined || column.type === undefined) { return factory.createKeywordTypeNode(SyntaxKind.AnyKeyword); @@ -56,7 +63,10 @@ export class Driver { typeName = typeName.slice(pgCatalog.length); } let typ: TypeNode = factory.createKeywordTypeNode(SyntaxKind.StringKeyword); - switch (typeName) { + const custom = this.customType(column); + if (custom !== undefined) { + typ = custom; + } else switch (typeName) { case "aclitem": { // string break; @@ -340,7 +350,7 @@ export class Driver { factory.createImportDeclaration( undefined, factory.createImportClause( - false, + true, undefined, factory.createNamedImports([ factory.createImportSpecifier( @@ -371,7 +381,7 @@ export class Driver { factory.createImportDeclaration( undefined, factory.createImportClause( - false, + true, undefined, factory.createNamedImports([ factory.createImportSpecifier( @@ -472,10 +482,7 @@ export class Driver { factory.createIdentifier("values"), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier(argName(i, param.column)) - ) + propertyAccess("args", argName(i, param.column)) ), false ) @@ -553,12 +560,7 @@ export class Driver { factory.createIdentifier("values"), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier( - argName(i, param.column) - ) - ) + propertyAccess("args", argName(i, param.column)) ), false ) @@ -630,7 +632,7 @@ export class Driver { factory.createObjectLiteralExpression( columns.map((col, i) => factory.createPropertyAssignment( - factory.createIdentifier(colName(i, col)), + propertyKey(colName(i, col)), factory.createElementAccessExpression( factory.createIdentifier("row"), factory.createNumericLiteral(`${i}`) @@ -701,12 +703,7 @@ export class Driver { factory.createIdentifier("values"), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier( - argName(i, param.column) - ) - ) + propertyAccess("args", argName(i, param.column)) ), false ) @@ -763,7 +760,7 @@ export class Driver { factory.createObjectLiteralExpression( columns.map((col, i) => factory.createPropertyAssignment( - factory.createIdentifier(colName(i, col)), + propertyKey(colName(i, col)), factory.createElementAccessExpression( factory.createIdentifier("row"), factory.createNumericLiteral(`${i}`) diff --git a/src/drivers/postgres.ts b/src/drivers/postgres.ts index 68d23f2..0a7fa0e 100644 --- a/src/drivers/postgres.ts +++ b/src/drivers/postgres.ts @@ -7,7 +7,8 @@ import { } from "typescript"; import { Parameter, Column } from "../gen/plugin/codegen_pb"; -import { argName, colName } from "./utlis"; +import { CustomTypeFn } from "../customTypes"; +import { argName, colName, propertyAccess, propertyKey } from "./utlis"; import { log } from "../logger"; function funcParamsDecl(iface: string | undefined, params: Parameter[]) { @@ -45,6 +46,12 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { } export class Driver { + private readonly customType: CustomTypeFn; + + constructor(customType?: CustomTypeFn) { + this.customType = customType ?? (() => undefined); + } + columnType(column?: Column): TypeNode { if (column === undefined || column.type === undefined) { return factory.createKeywordTypeNode(SyntaxKind.AnyKeyword); @@ -56,7 +63,10 @@ export class Driver { typeName = typeName.slice(pgCatalog.length); } let typ: TypeNode = factory.createKeywordTypeNode(SyntaxKind.StringKeyword); - switch (typeName) { + const custom = this.customType(column); + if (custom !== undefined) { + typ = custom; + } else switch (typeName) { case "aclitem": { // string break; @@ -303,7 +313,7 @@ export class Driver { factory.createImportDeclaration( undefined, factory.createImportClause( - false, + true, undefined, factory.createNamedImports([ factory.createImportSpecifier( @@ -353,10 +363,7 @@ export class Driver { factory.createIdentifier(queryName), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier(argName(i, param.column)) - ) + propertyAccess("args", argName(i, param.column)) ), false ), @@ -415,12 +422,7 @@ export class Driver { factory.createIdentifier(queryName), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier( - argName(i, param.column) - ) - ) + propertyAccess("args", argName(i, param.column)) ), false ), @@ -451,7 +453,7 @@ export class Driver { factory.createObjectLiteralExpression( columns.map((col, i) => factory.createPropertyAssignment( - factory.createIdentifier(colName(i, col)), + propertyKey(colName(i, col)), factory.createElementAccessExpression( factory.createIdentifier("row"), factory.createNumericLiteral(`${i}`) @@ -521,12 +523,7 @@ export class Driver { factory.createIdentifier(queryName), factory.createArrayLiteralExpression( params.map((param, i) => - factory.createPropertyAccessExpression( - factory.createIdentifier("args"), - factory.createIdentifier( - argName(i, param.column) - ) - ) + propertyAccess("args", argName(i, param.column)) ), false ), @@ -595,7 +592,7 @@ export class Driver { factory.createObjectLiteralExpression( columns.map((col, i) => factory.createPropertyAssignment( - factory.createIdentifier(colName(i, col)), + propertyKey(colName(i, col)), factory.createElementAccessExpression( factory.createIdentifier("row"), factory.createNumericLiteral(`${i}`) diff --git a/src/drivers/utlis.ts b/src/drivers/utlis.ts index 870b421..41ccb6a 100644 --- a/src/drivers/utlis.ts +++ b/src/drivers/utlis.ts @@ -1,3 +1,5 @@ +import { Expression, PropertyName, factory } from "typescript"; + import { Column } from "../gen/plugin/codegen_pb"; // https://stackoverflow.com/questions/40710628/how-to-convert-snake-case-to-camelcase @@ -6,8 +8,8 @@ export function fieldName( index: number, column?: Column ): string { - let name = `${prefix}_${index}`; - if (column) { + let name = `${prefix}_${index + 1}`; + if (column && column.name) { name = column.name; } return name @@ -22,3 +24,28 @@ export function argName(index: number, column?: Column): string { export function colName(index: number, column?: Column): string { return fieldName("col", index, column); } + +const validIdentifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +// Property name for an interface or object literal: a plain identifier when +// possible, otherwise a quoted string ("invalid-name": ...) +export function propertyKey(name: string): PropertyName { + if (validIdentifier.test(name)) { + return factory.createIdentifier(name); + } + return factory.createStringLiteral(name); +} + +// target.name when name is a valid identifier, otherwise target["name"] +export function propertyAccess(target: string, name: string): Expression { + if (validIdentifier.test(name)) { + return factory.createPropertyAccessExpression( + factory.createIdentifier(target), + factory.createIdentifier(name) + ); + } + return factory.createElementAccessExpression( + factory.createIdentifier(target), + factory.createStringLiteral(name) + ); +}