diff --git a/CLAUDE.md b/CLAUDE.md index 85e460f8..7e62f508 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ npm run gen:types:typescript # Generate TypeScript types from DB schema npm run gen:types:python # Generate Python types (Pydantic models) npm run gen:types:go # Generate Go types npm run gen:types:swift # Generate Swift types (beta) +npm run gen:types:kotlin # Generate Kotlin types (kotlinx.serialization) # With custom DB connection: PG_META_DB_URL=postgresql://... npm run gen:types:typescript @@ -121,7 +122,7 @@ Type generation (`npm run gen:types:*`) works by: 4. Templates output type definitions to stdout Environment variables: -- `PG_META_GENERATE_TYPES`: Language (typescript, python, go, swift) +- `PG_META_GENERATE_TYPES`: Language (typescript, python, go, swift, kotlin) - `PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS`: Comma-separated schemas to include - `PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS`: Enable 1:1 relationship detection - `PG_META_POSTGREST_VERSION`: PostgREST version for TypeScript template compatibility diff --git a/package.json b/package.json index ed9b8ae2..8e828a7f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "gen:types:go": "PG_META_GENERATE_TYPES=go node --loader ts-node/esm src/server/server.ts", "gen:types:swift": "PG_META_GENERATE_TYPES=swift node --loader ts-node/esm src/server/server.ts", "gen:types:python": "PG_META_GENERATE_TYPES=python node --loader ts-node/esm src/server/server.ts", + "gen:types:kotlin": "PG_META_GENERATE_TYPES=kotlin node --loader ts-node/esm src/server/server.ts", "start": "node dist/server/server.js", "dev": "trap 'npm run db:clean' INT && run-s db:clean db:run && run-s dev:code", "dev:code": "nodemon --exec node --loader ts-node/esm src/server/server.ts | pino-pretty --colorize", diff --git a/src/server/constants.ts b/src/server/constants.ts index c64b45e6..44feccbd 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -2,6 +2,7 @@ import crypto from 'crypto' import { PoolConfig } from '../lib/types.js' import { getSecret } from '../lib/secrets.js' import { AccessControl } from './templates/swift.js' +import { Visibility } from './templates/kotlin.js' import pkg from '#package.json' with { type: 'json' } export const PG_META_HOST = process.env.PG_META_HOST || '0.0.0.0' @@ -50,6 +51,9 @@ export const GENERATE_TYPES_SWIFT_ACCESS_CONTROL = process.env .PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL ? (process.env.PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL as AccessControl) : 'internal' +export const GENERATE_TYPES_KOTLIN_VISIBILITY = process.env.PG_META_GENERATE_TYPES_KOTLIN_VISIBILITY + ? (process.env.PG_META_GENERATE_TYPES_KOTLIN_VISIBILITY as Visibility) + : 'public' // json/jsonb/text types export const VALID_UNNAMED_FUNCTION_ARG_TYPES = new Set([114, 3802, 25]) diff --git a/src/server/routes/generators/kotlin.ts b/src/server/routes/generators/kotlin.ts new file mode 100644 index 00000000..1fbc309e --- /dev/null +++ b/src/server/routes/generators/kotlin.ts @@ -0,0 +1,39 @@ +import type { FastifyInstance } from 'fastify' +import { PostgresMeta } from '../../../lib/index.js' +import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' +import { apply as applyKotlinTemplate, Visibility } from '../../templates/kotlin.js' +import { getGeneratorMetadata } from '../../../lib/generators.js' + +export default async (fastify: FastifyInstance) => { + fastify.get<{ + Headers: { pg: string; 'x-pg-application-name'?: string } + Querystring: { + excluded_schemas?: string + included_schemas?: string + visibility?: Visibility + } + }>('/', async (request, reply) => { + const config = createConnectionConfig(request) + const excludedSchemas = + request.query.excluded_schemas?.split(',').map((schema) => schema.trim()) ?? [] + const includedSchemas = + request.query.included_schemas?.split(',').map((schema) => schema.trim()) ?? [] + const visibility = request.query.visibility ?? 'public' + + const pgMeta: PostgresMeta = new PostgresMeta(config) + 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 applyKotlinTemplate({ + ...generatorMeta, + visibility, + }) + }) +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 46ffba0f..2a2dda17 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -22,6 +22,7 @@ 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 KotlinTypeGenRoute from './generators/kotlin.js' import { PG_CONNECTION, CRYPTO_KEY } from '../constants.js' export default async (fastify: FastifyInstance) => { @@ -84,4 +85,5 @@ export default async (fastify: FastifyInstance) => { fastify.register(GoTypeGenRoute, { prefix: '/generators/go' }) fastify.register(SwiftTypeGenRoute, { prefix: '/generators/swift' }) fastify.register(PythonTypeGenRoute, { prefix: '/generators/python' }) + fastify.register(KotlinTypeGenRoute, { prefix: '/generators/kotlin' }) } diff --git a/src/server/server.ts b/src/server/server.ts index 68fbb54c..fea2a5a9 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -9,6 +9,7 @@ import { GENERATE_TYPES, GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS, GENERATE_TYPES_INCLUDED_SCHEMAS, + GENERATE_TYPES_KOTLIN_VISIBILITY, GENERATE_TYPES_SWIFT_ACCESS_CONTROL, PG_CONNECTION, PG_META_HOST, @@ -19,6 +20,7 @@ import { apply as applyTypescriptTemplate } from './templates/typescript.js' import { apply as applyGoTemplate } from './templates/go.js' import { apply as applySwiftTemplate } from './templates/swift.js' import { apply as applyPythonTemplate } from './templates/python.js' +import { apply as applyKotlinTemplate } from './templates/kotlin.js' const logger = pino({ formatters: { @@ -146,6 +148,11 @@ async function getTypeOutput(): Promise { return applyGoTemplate(config) case 'python': return applyPythonTemplate(config) + case 'kotlin': + return applyKotlinTemplate({ + ...config, + visibility: GENERATE_TYPES_KOTLIN_VISIBILITY, + }) default: throw new Error(`Unsupported language for GENERATE_TYPES: ${GENERATE_TYPES}`) } diff --git a/src/server/templates/kotlin.ts b/src/server/templates/kotlin.ts new file mode 100644 index 00000000..0b4040b3 --- /dev/null +++ b/src/server/templates/kotlin.ts @@ -0,0 +1,483 @@ +import type { + PostgresColumn, + PostgresMaterializedView, + PostgresSchema, + PostgresTable, + PostgresType, + PostgresView, +} from '../../lib/index.js' +import type { GeneratorMetadata } from '../../lib/generators.js' +import { PostgresForeignTable } from '../../lib/types.js' + +type Operation = 'Select' | 'Insert' | 'Update' + +/** + * Kotlin visibility modifier applied to every generated declaration. + * + * `public` is Kotlin's default, so it is emitted implicitly (no modifier) to + * keep the output idiomatic and lint-clean; `internal` is emitted explicitly. + */ +export type Visibility = 'public' | 'internal' + +type KotlinGeneratorOptions = { + visibility: Visibility +} + +type KotlinEnumEntry = { + formattedName: string + serialName: string +} + +type KotlinEnum = { + formattedName: string + entries: KotlinEnumEntry[] +} + +type KotlinProperty = { + formattedName: string + formattedType: string + serialName: string + nullable: boolean +} + +type KotlinDataClass = { + formattedName: string + properties: KotlinProperty[] +} + +// Tracks which serialization symbols are referenced so the import block only +// includes what the generated file actually uses. +type ImportUsage = { + serialName: boolean + jsonElement: boolean + jsonObject: boolean +} + +function formatForKotlinSchemaName(schema: string): string { + return `${formatForKotlinTypeName(schema)}Schema` +} + +function pgEnumToKotlinEnum(pgEnum: PostgresType): KotlinEnum { + return { + formattedName: formatForKotlinTypeName(pgEnum.name), + entries: pgEnum.enums.map((value) => ({ + formattedName: formatForKotlinEnumEntry(value), + serialName: value, + })), + } +} + +function pgTableToKotlinDataClass( + table: PostgresTable | PostgresForeignTable | PostgresView | PostgresMaterializedView, + columns: PostgresColumn[] | undefined, + operation: Operation, + context: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] }, + usage: ImportUsage +): KotlinDataClass { + const properties: KotlinProperty[] = + columns?.map((column) => { + let nullable: boolean + if (operation === 'Insert') { + nullable = + column.is_nullable || column.is_identity || column.is_generated || !!column.default_value + } else if (operation === 'Update') { + nullable = true + } else { + nullable = column.is_nullable + } + + return { + formattedName: formatForKotlinPropertyName(column.name), + formattedType: pgTypeToKotlinType(column.format, context, usage), + serialName: column.name, + nullable, + } + }) ?? [] + + return { + formattedName: `${formatForKotlinTypeName(table.name)}${operation}`, + properties, + } +} + +function pgCompositeTypeToKotlinDataClass( + type: PostgresType, + context: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] }, + usage: ImportUsage +): KotlinDataClass { + const properties: KotlinProperty[] = type.attributes.map((attribute) => { + const attributeType = context.types.find((t) => t.id === attribute.type_id) + // Resolve via the catalog name (e.g. `int4`), not `format` (e.g. `integer`): + // the type map and column metadata are both keyed on the catalog name. + return { + formattedName: formatForKotlinPropertyName(attribute.name), + formattedType: attributeType + ? pgTypeToKotlinType(attributeType.name, context, usage) + : markJsonElementUsed(usage), + serialName: attribute.name, + nullable: false, + } + }) + + if (!properties.length) { + usage.jsonElement = true + } + + return { + formattedName: formatForKotlinTypeName(type.name), + properties, + } +} + +function generateEnum( + enum_: KotlinEnum, + { visibility, level }: KotlinGeneratorOptions & { level: number }, + usage: ImportUsage +): string[] { + const modifier = visibilityModifier(visibility) + return [ + `${ident(level)}@Serializable`, + `${ident(level)}${modifier}enum class ${enum_.formattedName} {`, + ...enum_.entries.map((entry) => { + const annotation = + entry.serialName !== entry.formattedName + ? `@SerialName("${escapeForKotlinString(entry.serialName)}") ` + : '' + if (annotation) usage.serialName = true + return `${ident(level + 1)}${annotation}${entry.formattedName},` + }), + `${ident(level)}}`, + ] +} + +function generateDataClass( + dataClass: KotlinDataClass, + { visibility, level }: KotlinGeneratorOptions & { level: number }, + usage: ImportUsage +): string[] { + const modifier = visibilityModifier(visibility) + + // Kotlin forbids a `data class` with an empty primary constructor, so a + // column-less table/view is emitted as a plain serializable class. + if (dataClass.properties.length === 0) { + return [ + `${ident(level)}@Serializable`, + `${ident(level)}${modifier}class ${dataClass.formattedName}`, + ] + } + + return [ + `${ident(level)}@Serializable`, + `${ident(level)}${modifier}data class ${dataClass.formattedName}(`, + ...dataClass.properties.map((property) => { + const annotation = + property.serialName !== property.formattedName + ? `@SerialName("${escapeForKotlinString(property.serialName)}") ` + : '' + if (annotation) usage.serialName = true + // Nullable properties default to null so Insert/Update payloads can omit them. + const type = `${property.formattedType}${property.nullable ? '?' : ''}` + const defaultValue = property.nullable ? ' = null' : '' + return `${ident(level + 1)}${annotation}${modifier}val ${property.formattedName}: ${type}${defaultValue},` + }), + `${ident(level)})`, + ] +} + +export const apply = ({ + schemas, + tables, + foreignTables, + views, + materializedViews, + columns, + types, + visibility, +}: GeneratorMetadata & KotlinGeneratorOptions): string => { + const usage: ImportUsage = { serialName: false, jsonElement: false, jsonObject: false } + const context = { types, views, tables } + + const columnsByTableId = Object.fromEntries( + [...tables, ...foreignTables, ...views, ...materializedViews].map((t) => [t.id, []]) + ) + columns + .filter((c) => c.table_id in columnsByTableId) + .sort(({ name: a }, { name: b }) => a.localeCompare(b)) + .forEach((c) => columnsByTableId[c.table_id].push(c)) + + const body = schemas + .sort(({ name: a }, { name: b }) => a.localeCompare(b)) + .flatMap((schema) => { + const schemaTables = [...tables, ...foreignTables] + .filter((table) => table.schema === schema.name) + .sort(({ name: a }, { name: b }) => a.localeCompare(b)) + + const schemaViews = [...views, ...materializedViews] + .filter((view) => view.schema === schema.name) + .sort(({ name: a }, { name: b }) => a.localeCompare(b)) + + const schemaEnums = types + .filter((type) => type.schema === schema.name && type.enums.length > 0) + .sort(({ name: a }, { name: b }) => a.localeCompare(b)) + + const schemaCompositeTypes = types + .filter((type) => type.schema === schema.name && type.attributes.length > 0) + .sort(({ name: a }, { name: b }) => a.localeCompare(b)) + + return [ + `${visibilityModifier(visibility)}object ${formatForKotlinSchemaName(schema.name)} {`, + ...schemaEnums.flatMap((enum_) => + generateEnum(pgEnumToKotlinEnum(enum_), { visibility, level: 1 }, usage) + ), + ...schemaTables.flatMap((table) => + (['Select', 'Insert', 'Update'] as Operation[]).flatMap((operation) => + generateDataClass( + pgTableToKotlinDataClass( + table, + columnsByTableId[table.id], + operation, + context, + usage + ), + { visibility, level: 1 }, + usage + ) + ) + ), + ...schemaViews.flatMap((view) => + generateDataClass( + pgTableToKotlinDataClass(view, columnsByTableId[view.id], 'Select', context, usage), + { visibility, level: 1 }, + usage + ) + ), + ...schemaCompositeTypes.flatMap((type) => + generateDataClass( + pgCompositeTypeToKotlinDataClass(type, context, usage), + { + visibility, + level: 1, + }, + usage + ) + ), + '}', + ] + }) + + const imports = ['import kotlinx.serialization.Serializable'] + if (usage.serialName) imports.push('import kotlinx.serialization.SerialName') + if (usage.jsonElement) imports.push('import kotlinx.serialization.json.JsonElement') + if (usage.jsonObject) imports.push('import kotlinx.serialization.json.JsonObject') + imports.sort() + + return [...imports, '', ...body].join('\n') +} + +const KOTLIN_TYPE_MAP: Record = { + // Bool + bool: 'Boolean', + + // Numbers + int2: 'Short', + int4: 'Int', + int8: 'Long', + float4: 'Float', + float8: 'Double', + // Kotlin has no dependency-free arbitrary-precision decimal usable across all + // KMP targets, so numeric/decimal map to Double (matches the Go template). + numeric: 'Double', + decimal: 'Double', + + // Strings + bytea: 'String', + bpchar: 'String', + varchar: 'String', + date: 'String', + text: 'String', + citext: 'String', + time: 'String', + timetz: 'String', + timestamp: 'String', + timestamptz: 'String', + interval: 'String', + uuid: 'String', + vector: 'String', + + // Ranges + int4range: 'String', + int4multirange: 'String', + int8range: 'String', + int8multirange: 'String', + numrange: 'String', + nummultirange: 'String', + tsrange: 'String', + tsmultirange: 'String', + tstzrange: 'String', + tstzmultirange: 'String', + daterange: 'String', + datemultirange: 'String', + + // Misc + void: 'Unit', +} + +function pgTypeToKotlinType( + pgType: string, + context: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] }, + usage: ImportUsage +): string { + if (pgType in KOTLIN_TYPE_MAP) { + return KOTLIN_TYPE_MAP[pgType] + } + + // JSON + if (pgType === 'json' || pgType === 'jsonb') { + usage.jsonElement = true + return 'JsonElement' + } + if (pgType === 'record') { + usage.jsonObject = true + return 'JsonObject' + } + + // Arrays are prefixed with an underscore in the Postgres catalog. + if (pgType.startsWith('_')) { + return `List<${pgTypeToKotlinType(pgType.slice(1), context, usage)}>` + } + + // Enums + const enumType = context.types.find((type) => type.name === pgType && type.enums.length > 0) + if (enumType) { + return formatForKotlinTypeName(enumType.name) + } + + // Composite types (incl. table/view row types) + const compositeType = [...context.types, ...context.views, ...context.tables].find( + (type) => type.name === pgType + ) + if (compositeType) { + return formatForKotlinTypeName(compositeType.name) + } + + // Fallback + usage.jsonElement = true + return 'JsonElement' +} + +function markJsonElementUsed(usage: ImportUsage): string { + usage.jsonElement = true + return 'JsonElement' +} + +function visibilityModifier(visibility: Visibility): string { + // `public` is the Kotlin default; emitting it is redundant and flagged by linters. + return visibility === 'public' ? '' : `${visibility} ` +} + +function ident(level: number, options: { width: number } = { width: 2 }): string { + return ' '.repeat(level * options.width) +} + +function escapeForKotlinString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$') +} + +/** + * Converts a Postgres name to PascalCase. + * + * @example + * ```ts + * formatForKotlinTypeName('pokedex') // Pokedex + * formatForKotlinTypeName('pokemon_center') // PokemonCenter + * formatForKotlinTypeName('victory-road') // VictoryRoad + * formatForKotlinTypeName('pokemon league') // PokemonLeague + * formatForKotlinTypeName('_key_id_context') // KeyIdContext + * ``` + */ +function formatForKotlinTypeName(name: string): string { + const formatted = name + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((word) => `${word[0].toUpperCase()}${word.slice(1)}`) + .join('') + + return /^[A-Za-z]/.test(formatted) ? formatted : `N${formatted}` +} + +// Kotlin's hard keywords cannot be used as identifiers and must be backtick-escaped. +const KOTLIN_HARD_KEYWORDS = new Set([ + 'as', + 'break', + 'class', + 'continue', + 'do', + 'else', + 'false', + 'for', + 'fun', + 'if', + 'in', + 'interface', + 'is', + 'null', + 'object', + 'package', + 'return', + 'super', + 'this', + 'throw', + 'true', + 'try', + 'typealias', + 'typeof', + 'val', + 'var', + 'when', + 'while', +]) + +/** + * Converts a Postgres name to camelCase, backtick-escaping Kotlin keywords. + * + * @example + * ```ts + * formatForKotlinPropertyName('pokedex') // pokedex + * formatForKotlinPropertyName('pokemon_center') // pokemonCenter + * formatForKotlinPropertyName('victory-road') // victoryRoad + * formatForKotlinPropertyName('class') // `class` + * ``` + */ +function formatForKotlinPropertyName(name: string): string { + const words = name.split(/[^a-zA-Z0-9]+/).filter(Boolean) + const formatted = words + .map((word, index) => { + const lower = word.toLowerCase() + return index === 0 ? lower : `${lower[0].toUpperCase()}${lower.slice(1)}` + }) + .join('') + + return escapeKotlinIdentifier(formatted, name) +} + +/** + * Formats a Postgres enum value as a Kotlin enum entry. Values that are already + * valid identifiers are preserved; otherwise the value is sanitized and the + * original is retained via `@SerialName` by the caller. + */ +function formatForKotlinEnumEntry(value: string): string { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value) && !KOTLIN_HARD_KEYWORDS.has(value)) { + return value + } + + const sanitized = value.replace(/[^A-Za-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') + const safe = /^[A-Za-z_]/.test(sanitized) ? sanitized : `_${sanitized}` + return escapeKotlinIdentifier(safe || '_', value) +} + +function escapeKotlinIdentifier(identifier: string, original: string): string { + if (!identifier) { + return `\`${original}\`` + } + return KOTLIN_HARD_KEYWORDS.has(identifier) ? `\`${identifier}\`` : identifier +} diff --git a/test/server/typegen.ts b/test/server/typegen.ts index 50a0896b..a8da9196 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -6618,6 +6618,20 @@ test('typegen: swift w/ public access control', async () => { `) }) +test('typegen: kotlin', async () => { + const { body } = await app.inject({ method: 'GET', path: '/generators/kotlin' }) + expect(body).toMatchInlineSnapshot() +}) + +test('typegen: kotlin w/ internal visibility', async () => { + const { body } = await app.inject({ + method: 'GET', + path: '/generators/kotlin', + query: { visibility: 'internal' }, + }) + expect(body).toMatchInlineSnapshot() +}) + test('typegen: python', async () => { const { body } = await app.inject({ method: 'GET',