From 5dfd66ebcdf90a3a25372085113737a1043a0b51 Mon Sep 17 00:00:00 2001 From: Leonardo Santiago Date: Wed, 12 Nov 2025 15:58:50 -0300 Subject: [PATCH 1/6] feat: add python generator --- src/server/routes/generators/python.ts | 35 +++ src/server/routes/index.ts | 2 + src/server/templates/python.ts | 364 +++++++++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 src/server/routes/generators/python.ts create mode 100644 src/server/templates/python.ts diff --git a/src/server/routes/generators/python.ts b/src/server/routes/generators/python.ts new file mode 100644 index 00000000..51385373 --- /dev/null +++ b/src/server/routes/generators/python.ts @@ -0,0 +1,35 @@ +import type { FastifyInstance } from 'fastify' +import { PostgresMeta } from '../../../lib/index.js' +import { DEFAULT_POOL_CONFIG } from '../../constants.js' +import { extractRequestForLogging } from '../../utils.js' +import { apply as applyPyTemplate } from '../../templates/python.js' +import { getGeneratorMetadata } from '../../../lib/generators.js' + +export default async (fastify: FastifyInstance) => { + fastify.get<{ + Headers: { pg: string } + Querystring: { + excluded_schemas?: string + included_schemas?: string + } + }>('/', async (request, reply) => { + const connectionString = request.headers.pg + const excludedSchemas = + request.query.excluded_schemas?.split(',').map((schema) => schema.trim()) ?? [] + const includedSchemas = + request.query.included_schemas?.split(',').map((schema) => schema.trim()) ?? [] + + const pgMeta: PostgresMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString }) + const { data: generatorMeta, error: generatorMetaError } = await getGeneratorMetadata(pgMeta, { + includedSchemas, + excludedSchemas, + }) + if (generatorMetaError) { + request.log.error({ error: generatorMetaError, request: extractRequestForLogging(request) }) + reply.code(500) + return { error: generatorMetaError.message } + } + + return applyPyTemplate(generatorMeta) + }) +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 1532c4ea..46ffba0f 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -21,6 +21,7 @@ import ViewsRoute from './views.js' import TypeScriptTypeGenRoute from './generators/typescript.js' import GoTypeGenRoute from './generators/go.js' import SwiftTypeGenRoute from './generators/swift.js' +import PythonTypeGenRoute from './generators/python.js' import { PG_CONNECTION, CRYPTO_KEY } from '../constants.js' export default async (fastify: FastifyInstance) => { @@ -82,4 +83,5 @@ export default async (fastify: FastifyInstance) => { fastify.register(TypeScriptTypeGenRoute, { prefix: '/generators/typescript' }) fastify.register(GoTypeGenRoute, { prefix: '/generators/go' }) fastify.register(SwiftTypeGenRoute, { prefix: '/generators/swift' }) + fastify.register(PythonTypeGenRoute, { prefix: '/generators/python' }) } diff --git a/src/server/templates/python.ts b/src/server/templates/python.ts new file mode 100644 index 00000000..7a782bac --- /dev/null +++ b/src/server/templates/python.ts @@ -0,0 +1,364 @@ +import type { + PostgresColumn, + PostgresMaterializedView, + PostgresSchema, + PostgresTable, + PostgresType, + PostgresView, +} from '../../lib/index.js' +import type { GeneratorMetadata } from '../../lib/generators.js' +import { console } from 'inspector/promises'; + +type Operation = 'Select' | 'Insert' | 'Update' + +interface Serializable { + serialize(): string +} + +class PythonContext { + types: { [k: string]: PostgresType }; + user_enums: { [k: string]: PythonEnum }; + columns: Record; + schemas: { [k: string]: PostgresSchema }; + + constructor(types: PostgresType[], columns: PostgresColumn[], schemas: PostgresSchema[]) { + this.schemas = Object.fromEntries(schemas.map((schema) => [schema.name, schema])); + this.types = Object.fromEntries(types.map((type) => [type.name, type])); + this.columns = columns + .sort(({ name: a }, { name: b }) => a.localeCompare(b)) + .reduce( + (acc, curr) => { + acc[curr.table_id] ??= [] + acc[curr.table_id].push(curr) + return acc + }, + {} as Record + ); + this.user_enums = Object.fromEntries(types + .filter((type) => type.enums.length > 0) + .map((type) => [type.name, new PythonEnum(type)])); + } + + resolveTypeName(name: string) : string { + if (name in this.user_enums) { + return this.user_enums[name].name; + } + if (name in PY_TYPE_MAP) { + return PY_TYPE_MAP[name] + } + if (name in this.types) { + return formatForPyClassName(this.types[name].name); + } + throw new TypeError(`Unknown row type: ${name}`); + } + + parsePgType(pg_type: string) : PythonType { + if (pg_type.endsWith('[]')) { + const inner_str = pg_type.slice(0, -2); + const inner = this.parsePgType(inner_str); + return new PythonListType(inner); + } else { + const type_name = this.resolveTypeName(pg_type); + return new PythonSimpleType(type_name); + } + } + + tableToClass(table: PostgresTable) : PythonClass { + const attributes: PythonClassAttribute[] = (this.columns[table.id] ?? []) + .map((col) => { + const type = new PythonConcreteType(this, col.format, col.is_nullable); + return new PythonClassAttribute(col.name, type); + }); + return new PythonClass(table.name, this.schemas[table.schema], attributes) + } + + typeToClass(type: PostgresType) : PythonClass { + const types = Object.values(this.types); + const attributes = type.attributes.map((attribute) => { + const type = types.find((type) => type.id === attribute.type_id) + return { + ...attribute, + type, + } + }); + const attributeEntries: PythonClassAttribute[] = attributes + .map((attribute) => { + const type = new PythonConcreteType(this, attribute.type!.format, false); + return new PythonClassAttribute(attribute.name, type); + }); + const schema = this.schemas[type.schema]; + return new PythonClass(type.name, schema, attributeEntries); + } +} + + +class PythonEnum implements Serializable { + name: string; + variants: string[]; + constructor(type: PostgresType) { + this.name = formatForPyClassName(type.name); + this.variants = type.enums.map(formatForPyAttributeName); + } + serialize(): string { + const variants = this.variants.map((item) => `"${item}"`).join(', '); + return `${this.name}: TypeAlias = Literal[${variants}]`; + } +} + +type PythonType = PythonListType | PythonSimpleType; + +class PythonSimpleType implements Serializable { + name: string; + constructor(name: string) { + this.name = name; + } + serialize() : string { + return this.name; + } +} + +class PythonListType implements Serializable { + inner: PythonType; + constructor(inner: PythonType) { + this.inner = inner; + } + serialize() : string { + return `List[${this.inner.serialize()}]`; + } +} + +class PythonConcreteType implements Serializable { + py_type: PythonType; + pg_name: string; + nullable: boolean; + default_value: string | null; + constructor(ctx: PythonContext, pg_name: string, nullable: boolean) { + const py_type = ctx.parsePgType(pg_name); + + this.py_type = py_type; + this.pg_name = pg_name; + this.nullable = nullable; + this.default_value = null; + } + + serialize() : string { + return this.nullable + ? `Optional[${this.py_type.serialize()}]` + : this.py_type.serialize(); + } +} + +class PythonClassAttribute implements Serializable { + name: string; + pg_name: string; + py_type: PythonConcreteType; + constructor(name: string, py_type: PythonConcreteType) { + this.name = formatForPyAttributeName(name); + this.pg_name = name; + this.py_type = py_type; + } + serialize(): string { + return ` ${this.name}: Annotated[${this.py_type.serialize()}, Field(alias="${this.pg_name}")]` + } +} + +class PythonClass implements Serializable { + name: string; + schema: PostgresSchema; + class_attributes: PythonClassAttribute[]; + + + constructor(name: string, schema: PostgresSchema, class_attributes: PythonClassAttribute[]) { + this.schema = schema; + this.class_attributes = class_attributes; + this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}`; + } + serialize(): string { + const attributes = this.class_attributes.length > 0 + ? this.class_attributes.map((attr) => attr.serialize()).join('\n') + : " pass"; + return `class ${this.name}(BaseModel):\n${attributes}`.trim(); + } +} + +function concatLines(items: Serializable[]): string { + return items.map((item) => item.serialize()).join('\n\n'); +} + +const PY_TYPE_MAP: Record = { + // Bool + bool: 'bool', + + // Numbers + int2: 'int', + int4: 'int', + int8: 'int', + float4: 'float', + float8: 'float', + numeric: 'float', + + // Strings + bytea: 'bytes', + bpchar: 'str', + varchar: 'str', + string: 'str', + date: 'datetime.date', + text: 'str', + citext: 'str', + time: 'datetime.time', + timetz: 'datetime.time', + timestamp: 'datetime.datetime', + timestamptz: 'datetime.datetime', + uuid: 'uuid.UUID', + vector: 'list[Any]', + + // JSON + json: 'Json[Any]', + jsonb: 'Json[Any]', + + // Range types (can be adjusted to more complex types if needed) + int4range: 'str', + int4multirange: 'str', + int8range: 'str', + int8multirange: 'str', + numrange: 'str', + nummultirange: 'str', + tsrange: 'str', + tsmultirange: 'str', + tstzrange: 'str', + tstzmultirange: 'str', + daterange: 'str', + datemultirange: 'str', + + // Miscellaneous types + void: 'None', + record: 'dict[str, Any]', +} as const + +export const apply = ({ + schemas, + tables, + views, + materializedViews, + columns, + types, +}: GeneratorMetadata): string => { + const ctx = new PythonContext(types, columns, schemas); + const py_tables = tables + .filter((table) => schemas.some((schema) => schema.name === table.schema)) + .map((table) => ctx.tableToClass(table)); + console.log('composite_types'); + const composite_types = types.filter((type) => type.attributes.length > 0).map((type) => ctx.typeToClass(type)); + + let output = ` +from pydantic import BaseModel, Json, Field +from typing import Any, Annotated, Literal, Optional, TypeAlias +import datetime + +${concatLines(Object.values(ctx.user_enums))} + +${concatLines(py_tables)} + +${concatLines(composite_types)} + +`.trim() + +// ${views +// .filter((view) => schemas.some((schema) => schema.name === view.schema)) +// .flatMap((view) => +// generateTableStructsForOperations( +// schemas.find((schema) => schema.name === view.schema)!, +// view, +// columnsByTableId[view.id], +// types, +// ['Select'] +// ) +// ) +// .join('\n\n')} + +// ${materializedViews +// .filter((materializedView) => schemas.some((schema) => schema.name === materializedView.schema)) +// .flatMap((materializedView) => +// generateTableStructsForOperations( +// schemas.find((schema) => schema.name === materializedView.schema)!, +// materializedView, +// columnsByTableId[materializedView.id], +// types, +// ['Select'] +// ) +// ) +// .join('\n\n')} + +// ${compositeTypes +// .filter((compositeType) => schemas.some((schema) => schema.name === compositeType.schema)) +// .map((compositeType) => +// generateCompositeTypeStruct( +// schemas.find((schema) => schema.name === compositeType.schema)!, +// compositeType, +// types +// ) +// ) +// .join('\n\n')} +// `.trim() + + return output +} + +/** + * Converts a Postgres name to PascalCase. + * + * @example + * ```ts + * formatForPyTypeName('pokedex') // Pokedex + * formatForPyTypeName('pokemon_center') // PokemonCenter + * formatForPyTypeName('victory-road') // VictoryRoad + * formatForPyTypeName('pokemon league') // PokemonLeague + * ``` + */ +function formatForPyClassName(name: string): string { + console.log(name) + return name + .split(/[^a-zA-Z0-9]/) + .map((word) => `${word[0].toUpperCase()}${word.slice(1)}`) + .join('') +} + +/** + * Converts a Postgres name to snake_case. + * + * @example + * ```ts + * formatForPyTypeName('Pokedex') // pokedex + * formatForPyTypeName('PokemonCenter') // pokemon_enter + * formatForPyTypeName('victory-road') // victory_road + * formatForPyTypeName('pokemon league') // pokemon_league + * ``` + */ +function formatForPyAttributeName(name: string): string { + return name + .split(/[^a-zA-Z0-9]+/) // Split on non-alphanumeric characters (like spaces, dashes, etc.) + .map(word => word.toLowerCase()) // Convert each word to lowercase + .join('_'); // Join with underscores +} + +function pgTypeToPythonType(pgType: string, nullable: boolean, types: PostgresType[] = []): string { + let pythonType: string | undefined = undefined + + if (pgType in PY_TYPE_MAP) { + pythonType = PY_TYPE_MAP[pgType as keyof typeof PY_TYPE_MAP] + } + + // Enums + const enumType = types.find((type) => type.name === pgType && type.enums.length > 0) + if (enumType) { + pythonType = formatForPyClassName(String(pgType)) + } + + if (pythonType) { + // If the type is nullable, append "| None" to the type + return nullable ? `${pythonType} | None` : pythonType + } + + // Fallback + return nullable ? String(pgType)+' | None' : String(pgType) +} From 3bcc3e63b6f60c41449c36e0e36154549a06af9a Mon Sep 17 00:00:00 2001 From: Leonardo Santiago Date: Thu, 13 Nov 2025 10:06:58 -0300 Subject: [PATCH 2/6] fix: use schema in type name --- src/server/templates/python.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/server/templates/python.ts b/src/server/templates/python.ts index 7a782bac..d31b15b4 100644 --- a/src/server/templates/python.ts +++ b/src/server/templates/python.ts @@ -47,7 +47,9 @@ class PythonContext { return PY_TYPE_MAP[name] } if (name in this.types) { - return formatForPyClassName(this.types[name].name); + const type = this.types[name]; + const schema = type!.schema; + return `${formatForPyClassName(schema)}${formatForPyClassName(name)}`; } throw new TypeError(`Unknown row type: ${name}`); } @@ -251,9 +253,10 @@ export const apply = ({ const composite_types = types.filter((type) => type.attributes.length > 0).map((type) => ctx.typeToClass(type)); let output = ` -from pydantic import BaseModel, Json, Field -from typing import Any, Annotated, Literal, Optional, TypeAlias import datetime +from typing import Annotated, Any, List, Literal, Optional, TypeAlias + +from pydantic import BaseModel, Field, Json ${concatLines(Object.values(ctx.user_enums))} From 5f48036d2f58f00f386a92ce87a733b9b8a8f67e Mon Sep 17 00:00:00 2001 From: Leonardo Santiago Date: Thu, 13 Nov 2025 10:17:13 -0300 Subject: [PATCH 3/6] fix: add views and materialized views --- src/server/templates/python.ts | 67 ++++++++++++++-------------------- 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/src/server/templates/python.ts b/src/server/templates/python.ts index d31b15b4..f68ddb20 100644 --- a/src/server/templates/python.ts +++ b/src/server/templates/python.ts @@ -91,6 +91,24 @@ class PythonContext { const schema = this.schemas[type.schema]; return new PythonClass(type.name, schema, attributeEntries); } + + viewToClass(view: PostgresView) : PythonClass { + const attributes: PythonClassAttribute[] = (this.columns[view.id] ?? []) + .map((col) => { + const type = new PythonConcreteType(this, col.format, col.is_nullable); + return new PythonClassAttribute(col.name, type); + }); + return new PythonClass(view.name, this.schemas[view.schema], attributes) + } + + matViewToClass(matview: PostgresMaterializedView) : PythonClass { + const attributes: PythonClassAttribute[] = (this.columns[matview.id] ?? []) + .map((col) => { + const type = new PythonConcreteType(this, col.format, col.is_nullable); + return new PythonClassAttribute(col.name, type); + }); + return new PythonClass(matview.name, this.schemas[matview.schema], attributes) + } } @@ -249,8 +267,11 @@ export const apply = ({ const py_tables = tables .filter((table) => schemas.some((schema) => schema.name === table.schema)) .map((table) => ctx.tableToClass(table)); - console.log('composite_types'); + const composite_types = types.filter((type) => type.attributes.length > 0).map((type) => ctx.typeToClass(type)); + console.log(views); + const py_views = views.map((view) => ctx.viewToClass(view)); + const py_matviews = materializedViews.map((matview) => ctx.matViewToClass(matview)); let output = ` import datetime @@ -260,49 +281,15 @@ from pydantic import BaseModel, Field, Json ${concatLines(Object.values(ctx.user_enums))} +${concatLines(composite_types)} + ${concatLines(py_tables)} -${concatLines(composite_types)} +${concatLines(py_views)} -`.trim() +${concatLines(py_matviews)} -// ${views -// .filter((view) => schemas.some((schema) => schema.name === view.schema)) -// .flatMap((view) => -// generateTableStructsForOperations( -// schemas.find((schema) => schema.name === view.schema)!, -// view, -// columnsByTableId[view.id], -// types, -// ['Select'] -// ) -// ) -// .join('\n\n')} - -// ${materializedViews -// .filter((materializedView) => schemas.some((schema) => schema.name === materializedView.schema)) -// .flatMap((materializedView) => -// generateTableStructsForOperations( -// schemas.find((schema) => schema.name === materializedView.schema)!, -// materializedView, -// columnsByTableId[materializedView.id], -// types, -// ['Select'] -// ) -// ) -// .join('\n\n')} - -// ${compositeTypes -// .filter((compositeType) => schemas.some((schema) => schema.name === compositeType.schema)) -// .map((compositeType) => -// generateCompositeTypeStruct( -// schemas.find((schema) => schema.name === compositeType.schema)!, -// compositeType, -// types -// ) -// ) -// .join('\n\n')} -// `.trim() +`.trim() return output } From 5a514cc7b8da26014374e59d454426b1012ab833 Mon Sep 17 00:00:00 2001 From: Leonardo Santiago Date: Thu, 13 Nov 2025 16:34:37 -0300 Subject: [PATCH 4/6] fix: add views and materialized_views, add insert and update methods too --- src/server/templates/python.ts | 146 ++++++++++++++++++--------------- 1 file changed, 82 insertions(+), 64 deletions(-) diff --git a/src/server/templates/python.ts b/src/server/templates/python.ts index f68ddb20..bded9688 100644 --- a/src/server/templates/python.ts +++ b/src/server/templates/python.ts @@ -7,9 +7,6 @@ import type { PostgresView, } from '../../lib/index.js' import type { GeneratorMetadata } from '../../lib/generators.js' -import { console } from 'inspector/promises'; - -type Operation = 'Select' | 'Insert' | 'Update' interface Serializable { serialize(): string @@ -18,7 +15,7 @@ interface Serializable { class PythonContext { types: { [k: string]: PostgresType }; user_enums: { [k: string]: PythonEnum }; - columns: Record; + columns: Record; schemas: { [k: string]: PostgresSchema }; constructor(types: PostgresType[], columns: PostgresColumn[], schemas: PostgresSchema[]) { @@ -32,7 +29,7 @@ class PythonContext { acc[curr.table_id].push(curr) return acc }, - {} as Record + {} as Record ); this.user_enums = Object.fromEntries(types .filter((type) => type.enums.length > 0) @@ -49,14 +46,15 @@ class PythonContext { if (name in this.types) { const type = this.types[name]; const schema = type!.schema; - return `${formatForPyClassName(schema)}${formatForPyClassName(name)}`; + return `${formatForPyClassName(schema)}${formatForPyClassName(type.name)}`; } - throw new TypeError(`Unknown row type: ${name}`); + console.log(`Unknown recognized row type ${name}`); + return 'Any'; } parsePgType(pg_type: string) : PythonType { - if (pg_type.endsWith('[]')) { - const inner_str = pg_type.slice(0, -2); + if (pg_type.startsWith('_')) { + const inner_str = pg_type.slice(1); const inner = this.parsePgType(inner_str); return new PythonListType(inner); } else { @@ -65,15 +63,6 @@ class PythonContext { } } - tableToClass(table: PostgresTable) : PythonClass { - const attributes: PythonClassAttribute[] = (this.columns[table.id] ?? []) - .map((col) => { - const type = new PythonConcreteType(this, col.format, col.is_nullable); - return new PythonClassAttribute(col.name, type); - }); - return new PythonClass(table.name, this.schemas[table.schema], attributes) - } - typeToClass(type: PostgresType) : PythonClass { const types = Object.values(this.types); const attributes = type.attributes.map((attribute) => { @@ -85,28 +74,38 @@ class PythonContext { }); const attributeEntries: PythonClassAttribute[] = attributes .map((attribute) => { - const type = new PythonConcreteType(this, attribute.type!.format, false); - return new PythonClassAttribute(attribute.name, type); + const type = this.parsePgType(attribute.type!.name); + return new PythonClassAttribute(attribute.name, type, false, false, false, false); }); const schema = this.schemas[type.schema]; return new PythonClass(type.name, schema, attributeEntries); } + columnsToClassAttrs(table_id: number) : PythonClassAttribute[] { + const attrs = this.columns[table_id] ?? []; + return attrs.map((col) => { + const type = this.parsePgType(col.format); + return new PythonClassAttribute(col.name, type, + col.is_nullable, + col.is_updatable, + col.is_generated || !!col.default_value, + col.is_identity); + }); + } + + tableToClass(table: PostgresTable) : PythonClass { + const attributes = this.columnsToClassAttrs(table.id); + return new PythonClass(table.name, this.schemas[table.schema], attributes) + } + + viewToClass(view: PostgresView) : PythonClass { - const attributes: PythonClassAttribute[] = (this.columns[view.id] ?? []) - .map((col) => { - const type = new PythonConcreteType(this, col.format, col.is_nullable); - return new PythonClassAttribute(col.name, type); - }); + const attributes = this.columnsToClassAttrs(view.id); return new PythonClass(view.name, this.schemas[view.schema], attributes) } matViewToClass(matview: PostgresMaterializedView) : PythonClass { - const attributes: PythonClassAttribute[] = (this.columns[matview.id] ?? []) - .map((col) => { - const type = new PythonConcreteType(this, col.format, col.is_nullable); - return new PythonClassAttribute(col.name, type); - }); + const attributes = this.columnsToClassAttrs(matview.id); return new PythonClass(matview.name, this.schemas[matview.schema], attributes) } } @@ -116,7 +115,7 @@ class PythonEnum implements Serializable { name: string; variants: string[]; constructor(type: PostgresType) { - this.name = formatForPyClassName(type.name); + this.name = `${formatForPyClassName(type.schema)}${formatForPyClassName(type.name)}`; this.variants = type.enums.map(formatForPyAttributeName); } serialize(): string { @@ -147,58 +146,71 @@ class PythonListType implements Serializable { } } -class PythonConcreteType implements Serializable { - py_type: PythonType; +class PythonClassAttribute implements Serializable { + name: string; pg_name: string; + py_type: PythonType; nullable: boolean; - default_value: string | null; - constructor(ctx: PythonContext, pg_name: string, nullable: boolean) { - const py_type = ctx.parsePgType(pg_name); + mutable: boolean; + has_default: boolean; + is_identity: boolean; + + constructor(name: string, py_type: PythonType, nullable: boolean, mutable: boolean, has_default: boolean, is_identity: boolean) { + this.name = formatForPyAttributeName(name); + this.pg_name = name; this.py_type = py_type; - this.pg_name = pg_name; this.nullable = nullable; - this.default_value = null; + this.mutable = mutable; + this.has_default = has_default; + this.is_identity = is_identity; } - - serialize() : string { - return this.nullable + + serialize(): string { + const py_type = this.nullable ? `Optional[${this.py_type.serialize()}]` : this.py_type.serialize(); + return ` ${this.name}: Annotated[${py_type}, Field(alias="${this.pg_name}")]` } -} -class PythonClassAttribute implements Serializable { - name: string; - pg_name: string; - py_type: PythonConcreteType; - constructor(name: string, py_type: PythonConcreteType) { - this.name = formatForPyAttributeName(name); - this.pg_name = name; - this.py_type = py_type; - } - serialize(): string { - return ` ${this.name}: Annotated[${this.py_type.serialize()}, Field(alias="${this.pg_name}")]` - } } class PythonClass implements Serializable { name: string; + table_name: string; + parent_class: string; schema: PostgresSchema; class_attributes: PythonClassAttribute[]; - - constructor(name: string, schema: PostgresSchema, class_attributes: PythonClassAttribute[]) { + constructor(name: string, schema: PostgresSchema, class_attributes: PythonClassAttribute[], parent_class: string="BaseModel") { this.schema = schema; this.class_attributes = class_attributes; + this.table_name = name; this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}`; + this.parent_class = parent_class; } serialize(): string { const attributes = this.class_attributes.length > 0 ? this.class_attributes.map((attr) => attr.serialize()).join('\n') : " pass"; - return `class ${this.name}(BaseModel):\n${attributes}`.trim(); + return `class ${this.name}(${this.parent_class}):\n${attributes}`; } + + update() : PythonClass { + // Converts all attributes to nullable + const attrs = this.class_attributes + .filter((attr) => attr.mutable || attr.is_identity) + .map((attr) => new PythonClassAttribute(attr.name, attr.py_type, true, attr.mutable, attr.has_default, attr.is_identity)) + return new PythonClass(`${this.table_name}_update`, this.schema, attrs, "TypedDict") + } + + insert() : PythonClass { + // Converts all attributes that have a default to nullable. + const attrs = this.class_attributes + .map((attr) => new PythonClassAttribute(attr.name, attr.py_type, attr.has_default || attr.nullable, attr.mutable, attr.has_default, attr.is_identity)); + return new PythonClass(`${this.table_name}_insert`, this.schema, attrs, "TypedDict") + } + } function concatLines(items: Serializable[]): string { @@ -266,29 +278,36 @@ export const apply = ({ const ctx = new PythonContext(types, columns, schemas); const py_tables = tables .filter((table) => schemas.some((schema) => schema.name === table.schema)) - .map((table) => ctx.tableToClass(table)); + .flatMap((table) => { + const py_class = ctx.tableToClass(table); + return [py_class, py_class.insert(), py_class.update()]; + }); + + const composite_types = types + .filter((type) => type.attributes.length > 0) + .map((type) => ctx.typeToClass(type)); - const composite_types = types.filter((type) => type.attributes.length > 0).map((type) => ctx.typeToClass(type)); - console.log(views); const py_views = views.map((view) => ctx.viewToClass(view)); const py_matviews = materializedViews.map((matview) => ctx.matViewToClass(matview)); let output = ` +from __future__ import annotations + import datetime -from typing import Annotated, Any, List, Literal, Optional, TypeAlias +from typing import Annotated, Any, List, Literal, Optional, TypeAlias, TypedDict from pydantic import BaseModel, Field, Json ${concatLines(Object.values(ctx.user_enums))} -${concatLines(composite_types)} - ${concatLines(py_tables)} ${concatLines(py_views)} ${concatLines(py_matviews)} +${concatLines(composite_types)} + `.trim() return output @@ -306,7 +325,6 @@ ${concatLines(py_matviews)} * ``` */ function formatForPyClassName(name: string): string { - console.log(name) return name .split(/[^a-zA-Z0-9]/) .map((word) => `${word[0].toUpperCase()}${word.slice(1)}`) From 74507d6523a8eb1d63fb1a5d185f585f7d9e461f Mon Sep 17 00:00:00 2001 From: Leonardo Santiago Date: Fri, 14 Nov 2025 10:31:15 -0300 Subject: [PATCH 5/6] chore: add TypedDict for Insert and Update new class is needed because typeddict uses NonRequired for missing attributes --- src/server/templates/python.ts | 245 ++++++++++++++++--------------- test/server/typegen.ts | 253 +++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+), 114 deletions(-) diff --git a/src/server/templates/python.ts b/src/server/templates/python.ts index bded9688..8e8b2955 100644 --- a/src/server/templates/python.ts +++ b/src/server/templates/python.ts @@ -8,6 +8,60 @@ import type { } from '../../lib/index.js' import type { GeneratorMetadata } from '../../lib/generators.js' +export const apply = ({ + schemas, + tables, + views, + materializedViews, + columns, + types, +}: GeneratorMetadata): string => { + const ctx = new PythonContext(types, columns, schemas); + const py_tables = tables + .filter((table) => schemas.some((schema) => schema.name === table.schema)) + .flatMap((table) => { + const py_class_and_methods = ctx.tableToClass(table); + return py_class_and_methods; + }); + const composite_types = types + .filter((type) => type.attributes.length > 0) + .map((type) => ctx.typeToClass(type)); + const py_views = views.map((view) => ctx.viewToClass(view)); + const py_matviews = materializedViews.map((matview) => ctx.matViewToClass(matview)); + + let output = ` +from __future__ import annotations + +import datetime +from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, +) + +from pydantic import BaseModel, Field, Json + +${concatLines(Object.values(ctx.user_enums))} + +${concatLines(py_tables)} + +${concatLines(py_views)} + +${concatLines(py_matviews)} + +${concatLines(composite_types)} + +`.trim() + + return output +} + + interface Serializable { serialize(): string } @@ -63,7 +117,7 @@ class PythonContext { } } - typeToClass(type: PostgresType) : PythonClass { + typeToClass(type: PostgresType) : PythonBaseModel { const types = Object.values(this.types); const attributes = type.attributes.map((attribute) => { const type = types.find((type) => type.id === attribute.type_id) @@ -72,41 +126,49 @@ class PythonContext { type, } }); - const attributeEntries: PythonClassAttribute[] = attributes + const attributeEntries: PythonBaseModelAttr[] = attributes .map((attribute) => { const type = this.parsePgType(attribute.type!.name); - return new PythonClassAttribute(attribute.name, type, false, false, false, false); + return new PythonBaseModelAttr(attribute.name, type, false); }); + const schema = this.schemas[type.schema]; - return new PythonClass(type.name, schema, attributeEntries); + return new PythonBaseModel(type.name, schema, attributeEntries); + } + + columnsToClassAttrs(table_id: number) : PythonBaseModelAttr[] { + const attrs = this.columns[table_id] ?? []; + return attrs.map((col) => { + const type = this.parsePgType(col.format); + return new PythonBaseModelAttr(col.name, type, col.is_nullable); + }); } - columnsToClassAttrs(table_id: number) : PythonClassAttribute[] { + columnsToDictAttrs(table_id: number, not_required: boolean) : PythonTypedDictAttr[] { const attrs = this.columns[table_id] ?? []; return attrs.map((col) => { const type = this.parsePgType(col.format); - return new PythonClassAttribute(col.name, type, - col.is_nullable, - col.is_updatable, - col.is_generated || !!col.default_value, - col.is_identity); + return new PythonTypedDictAttr(col.name, type, col.is_nullable, not_required || col.is_nullable || col.is_identity || (col.default_value !== null)); }); } - tableToClass(table: PostgresTable) : PythonClass { - const attributes = this.columnsToClassAttrs(table.id); - return new PythonClass(table.name, this.schemas[table.schema], attributes) + tableToClass(table: PostgresTable) : [PythonBaseModel, PythonTypedDict, PythonTypedDict] { + const schema = this.schemas[table.schema]; + const select = new PythonBaseModel(table.name, schema, this.columnsToClassAttrs(table.id)); + const insert = new PythonTypedDict(table.name, "Insert", schema, this.columnsToDictAttrs(table.id, false)); + const update = new PythonTypedDict(table.name, "Update", schema, this.columnsToDictAttrs(table.id, true)); + return [select, insert, update]; } - viewToClass(view: PostgresView) : PythonClass { + viewToClass(view: PostgresView) : PythonBaseModel { const attributes = this.columnsToClassAttrs(view.id); - return new PythonClass(view.name, this.schemas[view.schema], attributes) + return new PythonBaseModel(view.name, this.schemas[view.schema], attributes) } - matViewToClass(matview: PostgresMaterializedView) : PythonClass { + matViewToClass(matview: PostgresMaterializedView) : PythonBaseModel { const attributes = this.columnsToClassAttrs(matview.id); - return new PythonClass(matview.name, this.schemas[matview.schema], attributes) + return new PythonBaseModel(matview.name, this.schemas[matview.schema], attributes) } } @@ -116,7 +178,7 @@ class PythonEnum implements Serializable { variants: string[]; constructor(type: PostgresType) { this.name = `${formatForPyClassName(type.schema)}${formatForPyClassName(type.name)}`; - this.variants = type.enums.map(formatForPyAttributeName); + this.variants = type.enums; } serialize(): string { const variants = this.variants.map((item) => `"${item}"`).join(', '); @@ -146,71 +208,93 @@ class PythonListType implements Serializable { } } -class PythonClassAttribute implements Serializable { +class PythonBaseModelAttr implements Serializable { name: string; pg_name: string; py_type: PythonType; nullable: boolean; - mutable: boolean; - has_default: boolean; - is_identity: boolean; - - constructor(name: string, py_type: PythonType, nullable: boolean, mutable: boolean, has_default: boolean, is_identity: boolean) { + constructor(name: string, py_type: PythonType, nullable: boolean) { this.name = formatForPyAttributeName(name); this.pg_name = name; this.py_type = py_type; this.nullable = nullable; - this.mutable = mutable; - this.has_default = has_default; - this.is_identity = is_identity; } - + serialize(): string { const py_type = this.nullable ? `Optional[${this.py_type.serialize()}]` : this.py_type.serialize(); - return ` ${this.name}: Annotated[${py_type}, Field(alias="${this.pg_name}")]` + return ` ${this.name}: ${py_type} = Field(alias="${this.pg_name}")` } - } -class PythonClass implements Serializable { +class PythonBaseModel implements Serializable { name: string; table_name: string; - parent_class: string; schema: PostgresSchema; - class_attributes: PythonClassAttribute[]; + class_attributes: PythonBaseModelAttr[]; - constructor(name: string, schema: PostgresSchema, class_attributes: PythonClassAttribute[], parent_class: string="BaseModel") { + constructor(name: string, schema: PostgresSchema, class_attributes: PythonBaseModelAttr[]) { this.schema = schema; this.class_attributes = class_attributes; this.table_name = name; this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}`; - this.parent_class = parent_class; } serialize(): string { const attributes = this.class_attributes.length > 0 ? this.class_attributes.map((attr) => attr.serialize()).join('\n') : " pass"; - return `class ${this.name}(${this.parent_class}):\n${attributes}`; + return `class ${this.name}(BaseModel):\n${attributes}`; } +} + +class PythonTypedDictAttr implements Serializable { + name: string; + pg_name: string; + py_type: PythonType; + nullable: boolean; + not_required: boolean; - update() : PythonClass { - // Converts all attributes to nullable - const attrs = this.class_attributes - .filter((attr) => attr.mutable || attr.is_identity) - .map((attr) => new PythonClassAttribute(attr.name, attr.py_type, true, attr.mutable, attr.has_default, attr.is_identity)) - return new PythonClass(`${this.table_name}_update`, this.schema, attrs, "TypedDict") + constructor(name: string, py_type: PythonType, nullable: boolean, required: boolean) { + this.name = formatForPyAttributeName(name); + this.pg_name = name; + this.py_type = py_type; + this.nullable = nullable; + this.not_required = required; } - insert() : PythonClass { - // Converts all attributes that have a default to nullable. - const attrs = this.class_attributes - .map((attr) => new PythonClassAttribute(attr.name, attr.py_type, attr.has_default || attr.nullable, attr.mutable, attr.has_default, attr.is_identity)); - return new PythonClass(`${this.table_name}_insert`, this.schema, attrs, "TypedDict") + serialize(): string { + const annotation = `Annotated[${this.py_type.serialize()}, Field(alias="${this.pg_name}")]`; + const rhs = this.not_required + ? `NotRequired[${annotation}]` + : annotation; + return ` ${this.name}: ${rhs}`; } +} + +class PythonTypedDict implements Serializable { + name: string; + table_name: string; + parent_class: string; + schema: PostgresSchema; + dict_attributes: PythonTypedDictAttr[]; + operation: "Insert" | "Update"; + constructor(name: string, operation: "Insert" | "Update", schema: PostgresSchema, dict_attributes: PythonTypedDictAttr[], parent_class: string="BaseModel") { + this.schema = schema; + this.dict_attributes = dict_attributes; + this.table_name = name; + this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}`; + this.parent_class = parent_class; + this.operation = operation; + } + serialize(): string { + const attributes = this.dict_attributes.length > 0 + ? this.dict_attributes.map((attr) => attr.serialize()).join('\n') + : " pass"; + return `class ${this.name}${this.operation}(TypedDict):\n${attributes}`; + } } function concatLines(items: Serializable[]): string { @@ -267,52 +351,6 @@ const PY_TYPE_MAP: Record = { record: 'dict[str, Any]', } as const -export const apply = ({ - schemas, - tables, - views, - materializedViews, - columns, - types, -}: GeneratorMetadata): string => { - const ctx = new PythonContext(types, columns, schemas); - const py_tables = tables - .filter((table) => schemas.some((schema) => schema.name === table.schema)) - .flatMap((table) => { - const py_class = ctx.tableToClass(table); - return [py_class, py_class.insert(), py_class.update()]; - }); - - const composite_types = types - .filter((type) => type.attributes.length > 0) - .map((type) => ctx.typeToClass(type)); - - const py_views = views.map((view) => ctx.viewToClass(view)); - const py_matviews = materializedViews.map((matview) => ctx.matViewToClass(matview)); - - let output = ` -from __future__ import annotations - -import datetime -from typing import Annotated, Any, List, Literal, Optional, TypeAlias, TypedDict - -from pydantic import BaseModel, Field, Json - -${concatLines(Object.values(ctx.user_enums))} - -${concatLines(py_tables)} - -${concatLines(py_views)} - -${concatLines(py_matviews)} - -${concatLines(composite_types)} - -`.trim() - - return output -} - /** * Converts a Postgres name to PascalCase. * @@ -349,24 +387,3 @@ function formatForPyAttributeName(name: string): string { .join('_'); // Join with underscores } -function pgTypeToPythonType(pgType: string, nullable: boolean, types: PostgresType[] = []): string { - let pythonType: string | undefined = undefined - - if (pgType in PY_TYPE_MAP) { - pythonType = PY_TYPE_MAP[pgType as keyof typeof PY_TYPE_MAP] - } - - // Enums - const enumType = types.find((type) => type.name === pgType && type.enums.length > 0) - if (enumType) { - pythonType = formatForPyClassName(String(pgType)) - } - - if (pythonType) { - // If the type is nullable, append "| None" to the type - return nullable ? `${pythonType} | None` : pythonType - } - - // Fallback - return nullable ? String(pgType)+' | None' : String(pgType) -} diff --git a/test/server/typegen.ts b/test/server/typegen.ts index 4bb83d94..240a62a3 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -6248,3 +6248,256 @@ test('typegen: swift w/ public access control', async () => { }" `) }) + + +test('typegen: python', async () => { + const { body } = await app.inject({ + method: 'GET', + path: '/generators/python', + query: { access_control: 'public' }, + }) + expect(body).toMatchInlineSnapshot(` +"from __future__ import annotations + +import datetime +from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, +) + +from pydantic import BaseModel, Field, Json + +PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] + +PublicMemeStatus: TypeAlias = Literal["new", "old", "retired"] + +class PublicUsers(BaseModel): + decimal: Optional[float] = Field(alias="decimal") + id: int = Field(alias="id") + name: Optional[str] = Field(alias="name") + status: Optional[PublicUserStatus] = Field(alias="status") + +class PublicUsersInsert(TypedDict): + decimal: NotRequired[Annotated[float, Field(alias="decimal")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + status: NotRequired[Annotated[PublicUserStatus, Field(alias="status")]] + +class PublicUsersUpdate(TypedDict): + decimal: NotRequired[Annotated[float, Field(alias="decimal")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + status: NotRequired[Annotated[PublicUserStatus, Field(alias="status")]] + +class PublicTodos(BaseModel): + details: Optional[str] = Field(alias="details") + id: int = Field(alias="id") + user_id: int = Field(alias="user-id") + +class PublicTodosInsert(TypedDict): + details: NotRequired[Annotated[str, Field(alias="details")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + user_id: Annotated[int, Field(alias="user-id")] + +class PublicTodosUpdate(TypedDict): + details: NotRequired[Annotated[str, Field(alias="details")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + user_id: NotRequired[Annotated[int, Field(alias="user-id")]] + +class PublicUsersAudit(BaseModel): + created_at: Optional[datetime.datetime] = Field(alias="created_at") + id: int = Field(alias="id") + previous_value: Optional[Json[Any]] = Field(alias="previous_value") + user_id: Optional[int] = Field(alias="user_id") + +class PublicUsersAuditInsert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + previous_value: NotRequired[Annotated[Json[Any], Field(alias="previous_value")]] + user_id: NotRequired[Annotated[int, Field(alias="user_id")]] + +class PublicUsersAuditUpdate(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + previous_value: NotRequired[Annotated[Json[Any], Field(alias="previous_value")]] + user_id: NotRequired[Annotated[int, Field(alias="user_id")]] + +class PublicUserDetails(BaseModel): + details: Optional[str] = Field(alias="details") + user_id: int = Field(alias="user_id") + +class PublicUserDetailsInsert(TypedDict): + details: NotRequired[Annotated[str, Field(alias="details")]] + user_id: Annotated[int, Field(alias="user_id")] + +class PublicUserDetailsUpdate(TypedDict): + details: NotRequired[Annotated[str, Field(alias="details")]] + user_id: NotRequired[Annotated[int, Field(alias="user_id")]] + +class PublicEmpty(BaseModel): + pass + +class PublicEmptyInsert(TypedDict): + pass + +class PublicEmptyUpdate(TypedDict): + pass + +class PublicTableWithOtherTablesRowType(BaseModel): + col1: Optional[PublicUserDetails] = Field(alias="col1") + col2: Optional[PublicAView] = Field(alias="col2") + +class PublicTableWithOtherTablesRowTypeInsert(TypedDict): + col1: NotRequired[Annotated[PublicUserDetails, Field(alias="col1")]] + col2: NotRequired[Annotated[PublicAView, Field(alias="col2")]] + +class PublicTableWithOtherTablesRowTypeUpdate(TypedDict): + col1: NotRequired[Annotated[PublicUserDetails, Field(alias="col1")]] + col2: NotRequired[Annotated[PublicAView, Field(alias="col2")]] + +class PublicTableWithPrimaryKeyOtherThanId(BaseModel): + name: Optional[str] = Field(alias="name") + other_id: int = Field(alias="other_id") + +class PublicTableWithPrimaryKeyOtherThanIdInsert(TypedDict): + name: NotRequired[Annotated[str, Field(alias="name")]] + other_id: NotRequired[Annotated[int, Field(alias="other_id")]] + +class PublicTableWithPrimaryKeyOtherThanIdUpdate(TypedDict): + name: NotRequired[Annotated[str, Field(alias="name")]] + other_id: NotRequired[Annotated[int, Field(alias="other_id")]] + +class PublicEvents(BaseModel): + created_at: datetime.datetime = Field(alias="created_at") + data: Optional[Json[Any]] = Field(alias="data") + event_type: Optional[str] = Field(alias="event_type") + id: int = Field(alias="id") + +class PublicEventsInsert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicEventsUpdate(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicEvents2024(BaseModel): + created_at: datetime.datetime = Field(alias="created_at") + data: Optional[Json[Any]] = Field(alias="data") + event_type: Optional[str] = Field(alias="event_type") + id: int = Field(alias="id") + +class PublicEvents2024Insert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: Annotated[int, Field(alias="id")] + +class PublicEvents2024Update(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicEvents2025(BaseModel): + created_at: datetime.datetime = Field(alias="created_at") + data: Optional[Json[Any]] = Field(alias="data") + event_type: Optional[str] = Field(alias="event_type") + id: int = Field(alias="id") + +class PublicEvents2025Insert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: Annotated[int, Field(alias="id")] + +class PublicEvents2025Update(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicCategory(BaseModel): + id: int = Field(alias="id") + name: str = Field(alias="name") + +class PublicCategoryInsert(TypedDict): + id: NotRequired[Annotated[int, Field(alias="id")]] + name: Annotated[str, Field(alias="name")] + +class PublicCategoryUpdate(TypedDict): + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + +class PublicMemes(BaseModel): + category: Optional[int] = Field(alias="category") + created_at: datetime.datetime = Field(alias="created_at") + id: int = Field(alias="id") + metadata: Optional[Json[Any]] = Field(alias="metadata") + name: str = Field(alias="name") + status: Optional[PublicMemeStatus] = Field(alias="status") + +class PublicMemesInsert(TypedDict): + category: NotRequired[Annotated[int, Field(alias="category")]] + created_at: Annotated[datetime.datetime, Field(alias="created_at")] + id: NotRequired[Annotated[int, Field(alias="id")]] + metadata: NotRequired[Annotated[Json[Any], Field(alias="metadata")]] + name: Annotated[str, Field(alias="name")] + status: NotRequired[Annotated[PublicMemeStatus, Field(alias="status")]] + +class PublicMemesUpdate(TypedDict): + category: NotRequired[Annotated[int, Field(alias="category")]] + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + metadata: NotRequired[Annotated[Json[Any], Field(alias="metadata")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + status: NotRequired[Annotated[PublicMemeStatus, Field(alias="status")]] + +class PublicAView(BaseModel): + id: Optional[int] = Field(alias="id") + +class PublicTodosView(BaseModel): + details: Optional[str] = Field(alias="details") + id: Optional[int] = Field(alias="id") + user_id: Optional[int] = Field(alias="user-id") + +class PublicUsersView(BaseModel): + decimal: Optional[float] = Field(alias="decimal") + id: Optional[int] = Field(alias="id") + name: Optional[str] = Field(alias="name") + status: Optional[PublicUserStatus] = Field(alias="status") + +class PublicUserTodosSummaryView(BaseModel): + todo_count: Optional[int] = Field(alias="todo_count") + todo_details: Optional[List[str]] = Field(alias="todo_details") + user_id: Optional[int] = Field(alias="user_id") + user_name: Optional[str] = Field(alias="user_name") + user_status: Optional[PublicUserStatus] = Field(alias="user_status") + +class PublicUsersViewWithMultipleRefsToUsers(BaseModel): + initial_id: Optional[int] = Field(alias="initial_id") + initial_name: Optional[str] = Field(alias="initial_name") + second_id: Optional[int] = Field(alias="second_id") + second_name: Optional[str] = Field(alias="second_name") + +class PublicTodosMatview(BaseModel): + details: Optional[str] = Field(alias="details") + id: Optional[int] = Field(alias="id") + user_id: Optional[int] = Field(alias="user-id") + +class PublicCompositeTypeWithArrayAttribute(BaseModel): + my_text_array: List[str] = Field(alias="my_text_array") + +class PublicCompositeTypeWithRecordAttribute(BaseModel): + todo: PublicTodos = Field(alias="todo")" +`)}) From a73dd6e7fb2a695a30096494c7fa2d38628f57ea Mon Sep 17 00:00:00 2001 From: Leonardo Santiago Date: Fri, 14 Nov 2025 10:58:14 -0300 Subject: [PATCH 6/6] format: run npx prettier --write --- src/server/templates/python.ts | 298 +++++++++++++++++---------------- test/server/typegen.ts | 4 +- 2 files changed, 159 insertions(+), 143 deletions(-) diff --git a/src/server/templates/python.ts b/src/server/templates/python.ts index 8e8b2955..2ac80996 100644 --- a/src/server/templates/python.ts +++ b/src/server/templates/python.ts @@ -16,18 +16,18 @@ export const apply = ({ columns, types, }: GeneratorMetadata): string => { - const ctx = new PythonContext(types, columns, schemas); + const ctx = new PythonContext(types, columns, schemas) const py_tables = tables .filter((table) => schemas.some((schema) => schema.name === table.schema)) .flatMap((table) => { - const py_class_and_methods = ctx.tableToClass(table); - return py_class_and_methods; - }); + const py_class_and_methods = ctx.tableToClass(table) + return py_class_and_methods + }) const composite_types = types .filter((type) => type.attributes.length > 0) - .map((type) => ctx.typeToClass(type)); - const py_views = views.map((view) => ctx.viewToClass(view)); - const py_matviews = materializedViews.map((matview) => ctx.matViewToClass(matview)); + .map((type) => ctx.typeToClass(type)) + const py_views = views.map((view) => ctx.viewToClass(view)) + const py_matviews = materializedViews.map((matview) => ctx.matViewToClass(matview)) let output = ` from __future__ import annotations @@ -61,20 +61,19 @@ ${concatLines(composite_types)} return output } - interface Serializable { serialize(): string } class PythonContext { - types: { [k: string]: PostgresType }; - user_enums: { [k: string]: PythonEnum }; - columns: Record; - schemas: { [k: string]: PostgresSchema }; + types: { [k: string]: PostgresType } + user_enums: { [k: string]: PythonEnum } + columns: Record + schemas: { [k: string]: PostgresSchema } constructor(types: PostgresType[], columns: PostgresColumn[], schemas: PostgresSchema[]) { - this.schemas = Object.fromEntries(schemas.map((schema) => [schema.name, schema])); - this.types = Object.fromEntries(types.map((type) => [type.name, type])); + this.schemas = Object.fromEntries(schemas.map((schema) => [schema.name, schema])) + this.types = Object.fromEntries(types.map((type) => [type.name, type])) this.columns = columns .sort(({ name: a }, { name: b }) => a.localeCompare(b)) .reduce( @@ -84,221 +83,239 @@ class PythonContext { return acc }, {} as Record - ); - this.user_enums = Object.fromEntries(types - .filter((type) => type.enums.length > 0) - .map((type) => [type.name, new PythonEnum(type)])); + ) + this.user_enums = Object.fromEntries( + types.filter((type) => type.enums.length > 0).map((type) => [type.name, new PythonEnum(type)]) + ) } - resolveTypeName(name: string) : string { + resolveTypeName(name: string): string { if (name in this.user_enums) { - return this.user_enums[name].name; + return this.user_enums[name].name } if (name in PY_TYPE_MAP) { return PY_TYPE_MAP[name] } if (name in this.types) { - const type = this.types[name]; - const schema = type!.schema; - return `${formatForPyClassName(schema)}${formatForPyClassName(type.name)}`; + const type = this.types[name] + const schema = type!.schema + return `${formatForPyClassName(schema)}${formatForPyClassName(type.name)}` } - console.log(`Unknown recognized row type ${name}`); - return 'Any'; + console.log(`Unknown recognized row type ${name}`) + return 'Any' } - parsePgType(pg_type: string) : PythonType { + parsePgType(pg_type: string): PythonType { if (pg_type.startsWith('_')) { - const inner_str = pg_type.slice(1); - const inner = this.parsePgType(inner_str); - return new PythonListType(inner); + const inner_str = pg_type.slice(1) + const inner = this.parsePgType(inner_str) + return new PythonListType(inner) } else { - const type_name = this.resolveTypeName(pg_type); - return new PythonSimpleType(type_name); + const type_name = this.resolveTypeName(pg_type) + return new PythonSimpleType(type_name) } } - typeToClass(type: PostgresType) : PythonBaseModel { - const types = Object.values(this.types); + typeToClass(type: PostgresType): PythonBaseModel { + const types = Object.values(this.types) const attributes = type.attributes.map((attribute) => { const type = types.find((type) => type.id === attribute.type_id) return { ...attribute, type, } - }); - const attributeEntries: PythonBaseModelAttr[] = attributes - .map((attribute) => { - const type = this.parsePgType(attribute.type!.name); - return new PythonBaseModelAttr(attribute.name, type, false); - }); - - const schema = this.schemas[type.schema]; - return new PythonBaseModel(type.name, schema, attributeEntries); + }) + const attributeEntries: PythonBaseModelAttr[] = attributes.map((attribute) => { + const type = this.parsePgType(attribute.type!.name) + return new PythonBaseModelAttr(attribute.name, type, false) + }) + + const schema = this.schemas[type.schema] + return new PythonBaseModel(type.name, schema, attributeEntries) } - columnsToClassAttrs(table_id: number) : PythonBaseModelAttr[] { - const attrs = this.columns[table_id] ?? []; + columnsToClassAttrs(table_id: number): PythonBaseModelAttr[] { + const attrs = this.columns[table_id] ?? [] return attrs.map((col) => { - const type = this.parsePgType(col.format); - return new PythonBaseModelAttr(col.name, type, col.is_nullable); - }); + const type = this.parsePgType(col.format) + return new PythonBaseModelAttr(col.name, type, col.is_nullable) + }) } - columnsToDictAttrs(table_id: number, not_required: boolean) : PythonTypedDictAttr[] { - const attrs = this.columns[table_id] ?? []; + columnsToDictAttrs(table_id: number, not_required: boolean): PythonTypedDictAttr[] { + const attrs = this.columns[table_id] ?? [] return attrs.map((col) => { - const type = this.parsePgType(col.format); - return new PythonTypedDictAttr(col.name, type, col.is_nullable, not_required || col.is_nullable || col.is_identity || (col.default_value !== null)); - }); + const type = this.parsePgType(col.format) + return new PythonTypedDictAttr( + col.name, + type, + col.is_nullable, + not_required || col.is_nullable || col.is_identity || col.default_value !== null + ) + }) } - tableToClass(table: PostgresTable) : [PythonBaseModel, PythonTypedDict, PythonTypedDict] { - const schema = this.schemas[table.schema]; - const select = new PythonBaseModel(table.name, schema, this.columnsToClassAttrs(table.id)); - const insert = new PythonTypedDict(table.name, "Insert", schema, this.columnsToDictAttrs(table.id, false)); - const update = new PythonTypedDict(table.name, "Update", schema, this.columnsToDictAttrs(table.id, true)); - return [select, insert, update]; + tableToClass(table: PostgresTable): [PythonBaseModel, PythonTypedDict, PythonTypedDict] { + const schema = this.schemas[table.schema] + const select = new PythonBaseModel(table.name, schema, this.columnsToClassAttrs(table.id)) + const insert = new PythonTypedDict( + table.name, + 'Insert', + schema, + this.columnsToDictAttrs(table.id, false) + ) + const update = new PythonTypedDict( + table.name, + 'Update', + schema, + this.columnsToDictAttrs(table.id, true) + ) + return [select, insert, update] } - - viewToClass(view: PostgresView) : PythonBaseModel { - const attributes = this.columnsToClassAttrs(view.id); + viewToClass(view: PostgresView): PythonBaseModel { + const attributes = this.columnsToClassAttrs(view.id) return new PythonBaseModel(view.name, this.schemas[view.schema], attributes) } - matViewToClass(matview: PostgresMaterializedView) : PythonBaseModel { - const attributes = this.columnsToClassAttrs(matview.id); + matViewToClass(matview: PostgresMaterializedView): PythonBaseModel { + const attributes = this.columnsToClassAttrs(matview.id) return new PythonBaseModel(matview.name, this.schemas[matview.schema], attributes) } } - class PythonEnum implements Serializable { - name: string; - variants: string[]; + name: string + variants: string[] constructor(type: PostgresType) { - this.name = `${formatForPyClassName(type.schema)}${formatForPyClassName(type.name)}`; - this.variants = type.enums; + this.name = `${formatForPyClassName(type.schema)}${formatForPyClassName(type.name)}` + this.variants = type.enums } serialize(): string { - const variants = this.variants.map((item) => `"${item}"`).join(', '); - return `${this.name}: TypeAlias = Literal[${variants}]`; + const variants = this.variants.map((item) => `"${item}"`).join(', ') + return `${this.name}: TypeAlias = Literal[${variants}]` } } -type PythonType = PythonListType | PythonSimpleType; +type PythonType = PythonListType | PythonSimpleType class PythonSimpleType implements Serializable { - name: string; + name: string constructor(name: string) { - this.name = name; + this.name = name } - serialize() : string { - return this.name; + serialize(): string { + return this.name } } class PythonListType implements Serializable { - inner: PythonType; + inner: PythonType constructor(inner: PythonType) { - this.inner = inner; + this.inner = inner + } + serialize(): string { + return `List[${this.inner.serialize()}]` } - serialize() : string { - return `List[${this.inner.serialize()}]`; - } } class PythonBaseModelAttr implements Serializable { - name: string; - pg_name: string; - py_type: PythonType; - nullable: boolean; - + name: string + pg_name: string + py_type: PythonType + nullable: boolean + constructor(name: string, py_type: PythonType, nullable: boolean) { - this.name = formatForPyAttributeName(name); - this.pg_name = name; - this.py_type = py_type; - this.nullable = nullable; + this.name = formatForPyAttributeName(name) + this.pg_name = name + this.py_type = py_type + this.nullable = nullable } serialize(): string { const py_type = this.nullable ? `Optional[${this.py_type.serialize()}]` - : this.py_type.serialize(); + : this.py_type.serialize() return ` ${this.name}: ${py_type} = Field(alias="${this.pg_name}")` } } class PythonBaseModel implements Serializable { - name: string; - table_name: string; - schema: PostgresSchema; - class_attributes: PythonBaseModelAttr[]; - + name: string + table_name: string + schema: PostgresSchema + class_attributes: PythonBaseModelAttr[] + constructor(name: string, schema: PostgresSchema, class_attributes: PythonBaseModelAttr[]) { - this.schema = schema; - this.class_attributes = class_attributes; - this.table_name = name; - this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}`; + this.schema = schema + this.class_attributes = class_attributes + this.table_name = name + this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}` } serialize(): string { - const attributes = this.class_attributes.length > 0 - ? this.class_attributes.map((attr) => attr.serialize()).join('\n') - : " pass"; - return `class ${this.name}(BaseModel):\n${attributes}`; + const attributes = + this.class_attributes.length > 0 + ? this.class_attributes.map((attr) => attr.serialize()).join('\n') + : ' pass' + return `class ${this.name}(BaseModel):\n${attributes}` } } class PythonTypedDictAttr implements Serializable { - name: string; - pg_name: string; - py_type: PythonType; - nullable: boolean; - not_required: boolean; + name: string + pg_name: string + py_type: PythonType + nullable: boolean + not_required: boolean constructor(name: string, py_type: PythonType, nullable: boolean, required: boolean) { - this.name = formatForPyAttributeName(name); - this.pg_name = name; - this.py_type = py_type; - this.nullable = nullable; - this.not_required = required; + this.name = formatForPyAttributeName(name) + this.pg_name = name + this.py_type = py_type + this.nullable = nullable + this.not_required = required } serialize(): string { - const annotation = `Annotated[${this.py_type.serialize()}, Field(alias="${this.pg_name}")]`; - const rhs = this.not_required - ? `NotRequired[${annotation}]` - : annotation; - return ` ${this.name}: ${rhs}`; + const annotation = `Annotated[${this.py_type.serialize()}, Field(alias="${this.pg_name}")]` + const rhs = this.not_required ? `NotRequired[${annotation}]` : annotation + return ` ${this.name}: ${rhs}` } } class PythonTypedDict implements Serializable { - name: string; - table_name: string; - parent_class: string; - schema: PostgresSchema; - dict_attributes: PythonTypedDictAttr[]; - operation: "Insert" | "Update"; - - constructor(name: string, operation: "Insert" | "Update", schema: PostgresSchema, dict_attributes: PythonTypedDictAttr[], parent_class: string="BaseModel") { - this.schema = schema; - this.dict_attributes = dict_attributes; - this.table_name = name; - this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}`; - this.parent_class = parent_class; - this.operation = operation; + name: string + table_name: string + parent_class: string + schema: PostgresSchema + dict_attributes: PythonTypedDictAttr[] + operation: 'Insert' | 'Update' + + constructor( + name: string, + operation: 'Insert' | 'Update', + schema: PostgresSchema, + dict_attributes: PythonTypedDictAttr[], + parent_class: string = 'BaseModel' + ) { + this.schema = schema + this.dict_attributes = dict_attributes + this.table_name = name + this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}` + this.parent_class = parent_class + this.operation = operation } serialize(): string { - const attributes = this.dict_attributes.length > 0 - ? this.dict_attributes.map((attr) => attr.serialize()).join('\n') - : " pass"; - return `class ${this.name}${this.operation}(TypedDict):\n${attributes}`; + const attributes = + this.dict_attributes.length > 0 + ? this.dict_attributes.map((attr) => attr.serialize()).join('\n') + : ' pass' + return `class ${this.name}${this.operation}(TypedDict):\n${attributes}` } } function concatLines(items: Serializable[]): string { - return items.map((item) => item.serialize()).join('\n\n'); + return items.map((item) => item.serialize()).join('\n\n') } const PY_TYPE_MAP: Record = { @@ -381,9 +398,8 @@ function formatForPyClassName(name: string): string { * ``` */ function formatForPyAttributeName(name: string): string { - return name + return name .split(/[^a-zA-Z0-9]+/) // Split on non-alphanumeric characters (like spaces, dashes, etc.) - .map(word => word.toLowerCase()) // Convert each word to lowercase - .join('_'); // Join with underscores + .map((word) => word.toLowerCase()) // Convert each word to lowercase + .join('_') // Join with underscores } - diff --git a/test/server/typegen.ts b/test/server/typegen.ts index 240a62a3..46079de0 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -6249,7 +6249,6 @@ test('typegen: swift w/ public access control', async () => { `) }) - test('typegen: python', async () => { const { body } = await app.inject({ method: 'GET', @@ -6500,4 +6499,5 @@ class PublicCompositeTypeWithArrayAttribute(BaseModel): class PublicCompositeTypeWithRecordAttribute(BaseModel): todo: PublicTodos = Field(alias="todo")" -`)}) +`) +})