diff --git a/src/server/routes/generators/kotlin.ts b/src/server/routes/generators/kotlin.ts new file mode 100644 index 00000000..feb3dbbb --- /dev/null +++ b/src/server/routes/generators/kotlin.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from 'fastify' +import { PostgresMeta } from '../../../lib/index.js' +import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' +import { apply as applyKotlinTemplate } 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 + } + }>('/', 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 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) + }) +} 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/templates/kotlin.ts b/src/server/templates/kotlin.ts new file mode 100644 index 00000000..84336cb6 --- /dev/null +++ b/src/server/templates/kotlin.ts @@ -0,0 +1,404 @@ +import type { + PostgresColumn, + PostgresMaterializedView, + 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' + +type KotlinProperty = { + formattedName: string + formattedType: string + rawName: string + needsSerialName: boolean +} + +type KotlinDataClass = { + formattedClassName: string + properties: KotlinProperty[] +} + +type KotlinEnum = { + formattedEnumName: string + cases: { formattedName: string; rawValue: string }[] +} + +function pgEnumToKotlinEnum(pgEnum: PostgresType): KotlinEnum { + return { + formattedEnumName: formatForKotlinTypeName(pgEnum.name), + cases: pgEnum.enums.map((value) => ({ + formattedName: formatForKotlinEnumCase(value), + rawValue: value, + })), + } +} + +function pgTypeToKotlinDataClass( + table: PostgresTable | PostgresForeignTable | PostgresView | PostgresMaterializedView, + columns: PostgresColumn[] | undefined, + operation: Operation, + { + types, + views, + tables, + }: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] } +): 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 + } + + const formattedName = formatForKotlinPropertyName(column.name) + + return { + rawName: column.name, + formattedName, + formattedType: pgTypeToKotlinType(column.format, nullable, { types, views, tables }), + needsSerialName: formattedName !== column.name, + } + }) ?? [] + + return { + formattedClassName: `${formatForKotlinTypeName(table.name)}${operation}`, + properties, + } +} + +function pgCompositeTypeToKotlinDataClass( + type: PostgresType, + { + types, + views, + tables, + }: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] } +): KotlinDataClass { + const typeWithRetrievedAttributes = { + ...type, + attributes: type.attributes.map((attribute) => { + const resolvedType = types.find((t) => t.id === attribute.type_id) + return { ...attribute, type: resolvedType } + }), + } + + const properties: KotlinProperty[] = typeWithRetrievedAttributes.attributes.map((attribute) => { + const formattedName = formatForKotlinPropertyName(attribute.name) + return { + rawName: attribute.name, + formattedName, + formattedType: pgTypeToKotlinType(attribute.type!.format, false, { types, views, tables }), + needsSerialName: formattedName !== attribute.name, + } + }) + + return { + formattedClassName: formatForKotlinTypeName(type.name), + properties, + } +} + +function generateKotlinEnum(enum_: KotlinEnum, level: number): string[] { + const output: string[] = [] + output.push(`${indent(level)}@Serializable`) + output.push(`${indent(level)}enum class ${enum_.formattedEnumName} {`) + enum_.cases.forEach((case_, index) => { + const comma = index < enum_.cases.length - 1 ? ',' : '' + output.push( + `${indent(level + 1)}@SerialName("${case_.rawValue}") ${case_.formattedName}${comma}` + ) + }) + output.push(`${indent(level)}}`) + return output +} + +function generateKotlinDataClass(dataClass: KotlinDataClass, level: number): string[] { + const output: string[] = [] + + if (dataClass.properties.length === 0) { + output.push(`${indent(level)}@Serializable`) + output.push(`${indent(level)}class ${dataClass.formattedClassName}`) + return output + } + + output.push(`${indent(level)}@Serializable`) + output.push(`${indent(level)}data class ${dataClass.formattedClassName}(`) + dataClass.properties.forEach((prop, index) => { + const comma = index < dataClass.properties.length - 1 ? ',' : '' + if (prop.needsSerialName) { + output.push(`${indent(level + 1)}@SerialName("${prop.rawName}")`) + } + const defaultValue = prop.formattedType.endsWith('?') ? ' = null' : '' + output.push( + `${indent(level + 1)}val ${prop.formattedName}: ${prop.formattedType}${defaultValue}${comma}` + ) + }) + output.push(`${indent(level)})`) + return output +} + +export const apply = ({ + schemas, + tables, + foreignTables, + views, + materializedViews, + columns, + types, +}: GeneratorMetadata): string => { + 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 output: string[] = [ + 'import kotlinx.serialization.SerialName', + 'import kotlinx.serialization.Serializable', + '', + ] + + schemas + .sort(({ name: a }, { name: b }) => a.localeCompare(b)) + .forEach((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)) + + // Enums + schemaEnums.forEach((enum_) => { + output.push(...generateKotlinEnum(pgEnumToKotlinEnum(enum_), 0)) + output.push('') + }) + + // Tables: Select, Insert, Update + schemaTables.forEach((table) => { + ;(['Select', 'Insert', 'Update'] as Operation[]).forEach((operation) => { + const dataClass = pgTypeToKotlinDataClass(table, columnsByTableId[table.id], operation, { + types, + views, + tables, + }) + output.push(...generateKotlinDataClass(dataClass, 0)) + output.push('') + }) + }) + + // Views: Select only + schemaViews.forEach((view) => { + const dataClass = pgTypeToKotlinDataClass(view, columnsByTableId[view.id], 'Select', { + types, + views, + tables, + }) + output.push(...generateKotlinDataClass(dataClass, 0)) + output.push('') + }) + + // Composite types + schemaCompositeTypes.forEach((type) => { + const dataClass = pgCompositeTypeToKotlinDataClass(type, { types, views, tables }) + output.push(...generateKotlinDataClass(dataClass, 0)) + output.push('') + }) + }) + + // Remove trailing empty line + while (output.length > 0 && output[output.length - 1] === '') { + output.pop() + } + + return output.join('\n') +} + +// Maps PostgreSQL types to Kotlin types +const pgTypeToKotlinType = ( + pgType: string, + nullable: boolean, + { + types, + views, + tables, + }: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] } +): string => { + let kotlinType: string + + if (pgType === 'bool') { + kotlinType = 'Boolean' + } else if (pgType === 'int2') { + kotlinType = 'Short' + } else if (pgType === 'int4') { + kotlinType = 'Int' + } else if (pgType === 'int8') { + kotlinType = 'Long' + } else if (pgType === 'float4') { + kotlinType = 'Float' + } else if (pgType === 'float8') { + kotlinType = 'Double' + } else if (['numeric', 'decimal'].includes(pgType)) { + kotlinType = 'Double' + } else if (pgType === 'uuid') { + kotlinType = 'String' + } else if ( + [ + 'bytea', + 'bpchar', + 'varchar', + 'date', + 'text', + 'citext', + 'time', + 'timetz', + 'timestamp', + 'timestamptz', + 'vector', + ].includes(pgType) + ) { + kotlinType = 'String' + } else if (['json', 'jsonb'].includes(pgType)) { + kotlinType = 'kotlinx.serialization.json.JsonElement' + } else if (pgType === 'void') { + kotlinType = 'Unit' + } else if (pgType === 'record') { + kotlinType = 'kotlinx.serialization.json.JsonObject' + } else if (pgType.startsWith('_')) { + kotlinType = `List<${pgTypeToKotlinType(pgType.substring(1), false, { types, views, tables })}>` + } else { + const enumType = types.find((type) => type.name === pgType && type.enums.length > 0) + const compositeType = [...types, ...views, ...tables].find((type) => type.name === pgType) + + if (enumType) { + kotlinType = formatForKotlinTypeName(enumType.name) + } else if (compositeType) { + kotlinType = `${formatForKotlinTypeName(compositeType.name)}Select` + } else { + kotlinType = 'kotlinx.serialization.json.JsonElement' + } + } + + return `${kotlinType}${nullable ? '?' : ''}` +} + +function indent(level: number): string { + return ' '.repeat(level) +} + +/** + * Converts a Postgres name to PascalCase for Kotlin type names. + * + * @example + * formatForKotlinTypeName('pokedex') // Pokedex + * formatForKotlinTypeName('pokemon_center') // PokemonCenter + * formatForKotlinTypeName('victory-road') // VictoryRoad + */ +function formatForKotlinTypeName(name: string): string { + let prefix = '' + if (name.startsWith('_')) { + prefix = '_' + name = name.slice(1) + } + + return ( + prefix + + name + .split(/[^a-zA-Z0-9]+/) + .map((word) => (word ? `${word[0].toUpperCase()}${word.slice(1)}` : '')) + .join('') + ) +} + +const KOTLIN_KEYWORDS = [ + '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 for Kotlin property names. + * + * @example + * formatForKotlinPropertyName('pokedex') // pokedex + * formatForKotlinPropertyName('pokemon_center') // pokemonCenter + * formatForKotlinPropertyName('event_type') // eventType + */ +function formatForKotlinPropertyName(name: string): string { + const propertyName = name + .split(/[^a-zA-Z0-9]/) + .map((word, index) => { + const lowerWord = word.toLowerCase() + return index !== 0 ? lowerWord.charAt(0).toUpperCase() + lowerWord.slice(1) : lowerWord + }) + .join('') + + return KOTLIN_KEYWORDS.includes(propertyName) ? `\`${propertyName}\`` : propertyName +} + +/** + * Converts a Postgres enum value to UPPER_SNAKE_CASE for Kotlin enum cases. + * + * @example + * formatForKotlinEnumCase('new') // NEW + * formatForKotlinEnumCase('in_progress') // IN_PROGRESS + * formatForKotlinEnumCase('ACTIVE') // ACTIVE + */ +function formatForKotlinEnumCase(name: string): string { + // If already UPPER_SNAKE_CASE, keep as-is + if (/^[A-Z][A-Z0-9_]*$/.test(name)) { + return name + } + + return name + .replace(/([a-z])([A-Z])/g, '$1_$2') + .replace(/[^a-zA-Z0-9]+/g, '_') + .toUpperCase() +} diff --git a/test/server/typegen.ts b/test/server/typegen.ts index ae693f50..341a1f96 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -6891,6 +6891,440 @@ test('typegen: python', async () => { `) }) +test('typegen: kotlin', async () => { + const { body } = await app.inject({ method: 'GET', path: '/generators/kotlin' }) + expect(body).toMatchInlineSnapshot(` + "import kotlinx.serialization.SerialName + import kotlinx.serialization.Serializable + + @Serializable + enum class MemeStatus { + @SerialName("new") NEW, + @SerialName("old") OLD, + @SerialName("retired") RETIRED + } + + @Serializable + enum class UserStatus { + @SerialName("ACTIVE") ACTIVE, + @SerialName("INACTIVE") INACTIVE + } + + @Serializable + data class CategorySelect( + val id: Int, + val name: String + ) + + @Serializable + data class CategoryInsert( + val id: Int? = null, + val name: String + ) + + @Serializable + data class CategoryUpdate( + val id: Int? = null, + val name: String? = null + ) + + @Serializable + class EmptySelect + + @Serializable + class EmptyInsert + + @Serializable + class EmptyUpdate + + @Serializable + data class EventsSelect( + @SerialName("created_at") + val createdAt: String, + val data: kotlinx.serialization.json.JsonElement? = null, + @SerialName("event_type") + val eventType: String? = null, + val id: Long + ) + + @Serializable + data class EventsInsert( + @SerialName("created_at") + val createdAt: String? = null, + val data: kotlinx.serialization.json.JsonElement? = null, + @SerialName("event_type") + val eventType: String? = null, + val id: Long? = null + ) + + @Serializable + data class EventsUpdate( + @SerialName("created_at") + val createdAt: String? = null, + val data: kotlinx.serialization.json.JsonElement? = null, + @SerialName("event_type") + val eventType: String? = null, + val id: Long? = null + ) + + @Serializable + data class Events2024Select( + @SerialName("created_at") + val createdAt: String, + val data: kotlinx.serialization.json.JsonElement? = null, + @SerialName("event_type") + val eventType: String? = null, + val id: Long + ) + + @Serializable + data class Events2024Insert( + @SerialName("created_at") + val createdAt: String? = null, + val data: kotlinx.serialization.json.JsonElement? = null, + @SerialName("event_type") + val eventType: String? = null, + val id: Long + ) + + @Serializable + data class Events2024Update( + @SerialName("created_at") + val createdAt: String? = null, + val data: kotlinx.serialization.json.JsonElement? = null, + @SerialName("event_type") + val eventType: String? = null, + val id: Long? = null + ) + + @Serializable + data class Events2025Select( + @SerialName("created_at") + val createdAt: String, + val data: kotlinx.serialization.json.JsonElement? = null, + @SerialName("event_type") + val eventType: String? = null, + val id: Long + ) + + @Serializable + data class Events2025Insert( + @SerialName("created_at") + val createdAt: String? = null, + val data: kotlinx.serialization.json.JsonElement? = null, + @SerialName("event_type") + val eventType: String? = null, + val id: Long + ) + + @Serializable + data class Events2025Update( + @SerialName("created_at") + val createdAt: String? = null, + val data: kotlinx.serialization.json.JsonElement? = null, + @SerialName("event_type") + val eventType: String? = null, + val id: Long? = null + ) + + @Serializable + data class ForeignTableSelect( + val id: Long, + val name: String? = null, + val status: UserStatus? = null + ) + + @Serializable + data class ForeignTableInsert( + val id: Long, + val name: String? = null, + val status: UserStatus? = null + ) + + @Serializable + data class ForeignTableUpdate( + val id: Long? = null, + val name: String? = null, + val status: UserStatus? = null + ) + + @Serializable + data class IntervalTestSelect( + @SerialName("duration_optional") + val durationOptional: IntervalSelect? = null, + @SerialName("duration_required") + val durationRequired: IntervalSelect, + val id: Long + ) + + @Serializable + data class IntervalTestInsert( + @SerialName("duration_optional") + val durationOptional: IntervalSelect? = null, + @SerialName("duration_required") + val durationRequired: IntervalSelect, + val id: Long? = null + ) + + @Serializable + data class IntervalTestUpdate( + @SerialName("duration_optional") + val durationOptional: IntervalSelect? = null, + @SerialName("duration_required") + val durationRequired: IntervalSelect? = null, + val id: Long? = null + ) + + @Serializable + data class MemesSelect( + val category: Int? = null, + @SerialName("created_at") + val createdAt: String, + val id: Int, + val metadata: kotlinx.serialization.json.JsonElement? = null, + val name: String, + val status: MemeStatus? = null + ) + + @Serializable + data class MemesInsert( + val category: Int? = null, + @SerialName("created_at") + val createdAt: String, + val id: Int? = null, + val metadata: kotlinx.serialization.json.JsonElement? = null, + val name: String, + val status: MemeStatus? = null + ) + + @Serializable + data class MemesUpdate( + val category: Int? = null, + @SerialName("created_at") + val createdAt: String? = null, + val id: Int? = null, + val metadata: kotlinx.serialization.json.JsonElement? = null, + val name: String? = null, + val status: MemeStatus? = null + ) + + @Serializable + data class TableWithOtherTablesRowTypeSelect( + val col1: UserDetailsSelect? = null, + val col2: AViewSelect? = null + ) + + @Serializable + data class TableWithOtherTablesRowTypeInsert( + val col1: UserDetailsSelect? = null, + val col2: AViewSelect? = null + ) + + @Serializable + data class TableWithOtherTablesRowTypeUpdate( + val col1: UserDetailsSelect? = null, + val col2: AViewSelect? = null + ) + + @Serializable + data class TableWithPrimaryKeyOtherThanIdSelect( + val name: String? = null, + @SerialName("other_id") + val otherId: Long + ) + + @Serializable + data class TableWithPrimaryKeyOtherThanIdInsert( + val name: String? = null, + @SerialName("other_id") + val otherId: Long? = null + ) + + @Serializable + data class TableWithPrimaryKeyOtherThanIdUpdate( + val name: String? = null, + @SerialName("other_id") + val otherId: Long? = null + ) + + @Serializable + data class TodosSelect( + val details: String? = null, + val id: Long, + @SerialName("user-id") + val userId: Long + ) + + @Serializable + data class TodosInsert( + val details: String? = null, + val id: Long? = null, + @SerialName("user-id") + val userId: Long + ) + + @Serializable + data class TodosUpdate( + val details: String? = null, + val id: Long? = null, + @SerialName("user-id") + val userId: Long? = null + ) + + @Serializable + data class UserDetailsSelect( + val details: String? = null, + @SerialName("user_id") + val userId: Long + ) + + @Serializable + data class UserDetailsInsert( + val details: String? = null, + @SerialName("user_id") + val userId: Long + ) + + @Serializable + data class UserDetailsUpdate( + val details: String? = null, + @SerialName("user_id") + val userId: Long? = null + ) + + @Serializable + data class UsersSelect( + val decimal: Double? = null, + val id: Long, + val name: String? = null, + val status: UserStatus? = null, + @SerialName("user_uuid") + val userUuid: String? = null + ) + + @Serializable + data class UsersInsert( + val decimal: Double? = null, + val id: Long? = null, + val name: String? = null, + val status: UserStatus? = null, + @SerialName("user_uuid") + val userUuid: String? = null + ) + + @Serializable + data class UsersUpdate( + val decimal: Double? = null, + val id: Long? = null, + val name: String? = null, + val status: UserStatus? = null, + @SerialName("user_uuid") + val userUuid: String? = null + ) + + @Serializable + data class UsersAuditSelect( + @SerialName("created_at") + val createdAt: String? = null, + val id: Long, + @SerialName("previous_value") + val previousValue: kotlinx.serialization.json.JsonElement? = null, + @SerialName("user_id") + val userId: Long? = null + ) + + @Serializable + data class UsersAuditInsert( + @SerialName("created_at") + val createdAt: String? = null, + val id: Long? = null, + @SerialName("previous_value") + val previousValue: kotlinx.serialization.json.JsonElement? = null, + @SerialName("user_id") + val userId: Long? = null + ) + + @Serializable + data class UsersAuditUpdate( + @SerialName("created_at") + val createdAt: String? = null, + val id: Long? = null, + @SerialName("previous_value") + val previousValue: kotlinx.serialization.json.JsonElement? = null, + @SerialName("user_id") + val userId: Long? = null + ) + + @Serializable + data class AViewSelect( + val id: Long? = null + ) + + @Serializable + data class TodosMatviewSelect( + val details: String? = null, + val id: Long? = null, + @SerialName("user-id") + val userId: Long? = null + ) + + @Serializable + data class TodosViewSelect( + val details: String? = null, + val id: Long? = null, + @SerialName("user-id") + val userId: Long? = null + ) + + @Serializable + data class UserTodosSummaryViewSelect( + @SerialName("todo_count") + val todoCount: Long? = null, + @SerialName("todo_details") + val todoDetails: List? = null, + @SerialName("user_id") + val userId: Long? = null, + @SerialName("user_name") + val userName: String? = null, + @SerialName("user_status") + val userStatus: UserStatus? = null + ) + + @Serializable + data class UsersViewSelect( + val decimal: Double? = null, + val id: Long? = null, + val name: String? = null, + val status: UserStatus? = null, + @SerialName("user_uuid") + val userUuid: String? = null + ) + + @Serializable + data class UsersViewWithMultipleRefsToUsersSelect( + @SerialName("initial_id") + val initialId: Long? = null, + @SerialName("initial_name") + val initialName: String? = null, + @SerialName("second_id") + val secondId: Long? = null, + @SerialName("second_name") + val secondName: String? = null + ) + + @Serializable + data class CompositeTypeWithArrayAttribute( + @SerialName("my_text_array") + val myTextArray: kotlinx.serialization.json.JsonElement + ) + + @Serializable + data class CompositeTypeWithRecordAttribute( + val todo: TodosSelect + )" + `) +}) + test('typegen: python w/ excluded/included schemas', async () => { // Create a test schema with some tables await app.inject({