@sqldoc/templates 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +161 -0
- package/src/__tests__/dedent.test.ts +45 -0
- package/src/__tests__/docker-templates.test.ts +134 -0
- package/src/__tests__/go-structs.test.ts +184 -0
- package/src/__tests__/naming.test.ts +48 -0
- package/src/__tests__/python-dataclasses.test.ts +185 -0
- package/src/__tests__/rust-structs.test.ts +176 -0
- package/src/__tests__/tags-helpers.test.ts +72 -0
- package/src/__tests__/type-mapping.test.ts +332 -0
- package/src/__tests__/typescript.test.ts +202 -0
- package/src/cobol-copybook/index.ts +220 -0
- package/src/cobol-copybook/test/.gitignore +6 -0
- package/src/cobol-copybook/test/Dockerfile +7 -0
- package/src/csharp-records/index.ts +131 -0
- package/src/csharp-records/test/.gitignore +6 -0
- package/src/csharp-records/test/Dockerfile +6 -0
- package/src/diesel/index.ts +247 -0
- package/src/diesel/test/.gitignore +6 -0
- package/src/diesel/test/Dockerfile +16 -0
- package/src/drizzle/index.ts +255 -0
- package/src/drizzle/test/.gitignore +6 -0
- package/src/drizzle/test/Dockerfile +8 -0
- package/src/drizzle/test/test.ts +71 -0
- package/src/efcore/index.ts +190 -0
- package/src/efcore/test/.gitignore +6 -0
- package/src/efcore/test/Dockerfile +7 -0
- package/src/go-structs/index.ts +119 -0
- package/src/go-structs/test/.gitignore +6 -0
- package/src/go-structs/test/Dockerfile +13 -0
- package/src/go-structs/test/test.go +71 -0
- package/src/gorm/index.ts +134 -0
- package/src/gorm/test/.gitignore +6 -0
- package/src/gorm/test/Dockerfile +13 -0
- package/src/gorm/test/test.go +65 -0
- package/src/helpers/atlas.ts +43 -0
- package/src/helpers/enrich.ts +396 -0
- package/src/helpers/naming.ts +19 -0
- package/src/helpers/tags.ts +63 -0
- package/src/index.ts +24 -0
- package/src/java-records/index.ts +179 -0
- package/src/java-records/test/.gitignore +6 -0
- package/src/java-records/test/Dockerfile +11 -0
- package/src/java-records/test/Test.java +93 -0
- package/src/jpa/index.ts +279 -0
- package/src/jpa/test/.gitignore +6 -0
- package/src/jpa/test/Dockerfile +14 -0
- package/src/jpa/test/Test.java +111 -0
- package/src/json-schema/index.ts +351 -0
- package/src/json-schema/test/.gitignore +6 -0
- package/src/json-schema/test/Dockerfile +18 -0
- package/src/knex/index.ts +168 -0
- package/src/knex/test/.gitignore +6 -0
- package/src/knex/test/Dockerfile +7 -0
- package/src/knex/test/test.ts +75 -0
- package/src/kotlin-data/index.ts +147 -0
- package/src/kotlin-data/test/.gitignore +6 -0
- package/src/kotlin-data/test/Dockerfile +14 -0
- package/src/kotlin-data/test/Test.kt +82 -0
- package/src/kysely/index.ts +165 -0
- package/src/kysely/test/.gitignore +6 -0
- package/src/kysely/test/Dockerfile +8 -0
- package/src/kysely/test/test.ts +82 -0
- package/src/prisma/index.ts +387 -0
- package/src/prisma/test/.gitignore +6 -0
- package/src/prisma/test/Dockerfile +7 -0
- package/src/protobuf/index.ts +219 -0
- package/src/protobuf/test/.gitignore +6 -0
- package/src/protobuf/test/Dockerfile +6 -0
- package/src/pydantic/index.ts +272 -0
- package/src/pydantic/test/.gitignore +6 -0
- package/src/pydantic/test/Dockerfile +8 -0
- package/src/pydantic/test/test.py +63 -0
- package/src/python-dataclasses/index.ts +217 -0
- package/src/python-dataclasses/test/.gitignore +6 -0
- package/src/python-dataclasses/test/Dockerfile +8 -0
- package/src/python-dataclasses/test/test.py +63 -0
- package/src/rust-structs/index.ts +152 -0
- package/src/rust-structs/test/.gitignore +6 -0
- package/src/rust-structs/test/Dockerfile +22 -0
- package/src/rust-structs/test/test.rs +82 -0
- package/src/sqlalchemy/index.ts +258 -0
- package/src/sqlalchemy/test/.gitignore +6 -0
- package/src/sqlalchemy/test/Dockerfile +8 -0
- package/src/sqlalchemy/test/test.py +61 -0
- package/src/sqlc/index.ts +148 -0
- package/src/sqlc/test/.gitignore +6 -0
- package/src/sqlc/test/Dockerfile +13 -0
- package/src/sqlc/test/test.go +91 -0
- package/src/tags/dedent.ts +28 -0
- package/src/tags/index.ts +14 -0
- package/src/types/index.ts +8 -0
- package/src/types/pg-to-csharp.ts +136 -0
- package/src/types/pg-to-go.ts +120 -0
- package/src/types/pg-to-java.ts +141 -0
- package/src/types/pg-to-kotlin.ts +119 -0
- package/src/types/pg-to-python.ts +120 -0
- package/src/types/pg-to-rust.ts +121 -0
- package/src/types/pg-to-ts.ts +173 -0
- package/src/typescript/index.ts +168 -0
- package/src/typescript/test/.gitignore +6 -0
- package/src/typescript/test/Dockerfile +8 -0
- package/src/typescript/test/test.ts +89 -0
- package/src/xsd/index.ts +191 -0
- package/src/xsd/test/.gitignore +6 -0
- package/src/xsd/test/Dockerfile +6 -0
- package/src/zod/index.ts +289 -0
- package/src/zod/test/.gitignore +6 -0
- package/src/zod/test/Dockerfile +6 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { defineTemplate } from '@sqldoc/ns-codegen'
|
|
2
|
+
import { activeTables, enrichRealm } from '../helpers/enrich.ts'
|
|
3
|
+
import { toCamelCase, toPascalCase, toScreamingSnake } from '../helpers/naming.ts'
|
|
4
|
+
import { pgToKotlin } from '../types/pg-to-kotlin.ts'
|
|
5
|
+
|
|
6
|
+
export default defineTemplate({
|
|
7
|
+
name: 'Kotlin Data Classes',
|
|
8
|
+
description: 'Generate Kotlin data classes from SQL schema',
|
|
9
|
+
language: 'kotlin',
|
|
10
|
+
|
|
11
|
+
generate(ctx) {
|
|
12
|
+
const schema = enrichRealm(ctx)
|
|
13
|
+
const classes: string[] = []
|
|
14
|
+
|
|
15
|
+
// Enums
|
|
16
|
+
for (const e of schema.enums) {
|
|
17
|
+
const className = toPascalCase(e.name)
|
|
18
|
+
const members = e.values.map((v) => ` ${toScreamingSnake(v)}`).join(',\n')
|
|
19
|
+
classes.push(`enum class ${className} {\n${members}\n}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Composite types as data classes
|
|
23
|
+
const composites = new Map<string, Array<{ name: string; type: string }>>()
|
|
24
|
+
for (const table of schema.tables) {
|
|
25
|
+
for (const col of table.columns) {
|
|
26
|
+
if (col.category === 'composite' && col.compositeFields?.length && !composites.has(col.pgType)) {
|
|
27
|
+
composites.set(col.pgType, col.compositeFields)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const [name, fields] of composites) {
|
|
32
|
+
const className = toPascalCase(name)
|
|
33
|
+
const ktFields = fields.map((f) => {
|
|
34
|
+
const ktType = pgToKotlin(f.type, false)
|
|
35
|
+
return ` val ${toCamelCase(f.name)}: ${ktType}`
|
|
36
|
+
})
|
|
37
|
+
classes.push(`data class ${className}(`)
|
|
38
|
+
classes.push(ktFields.join(',\n'))
|
|
39
|
+
classes.push(')')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const table of activeTables(schema)) {
|
|
43
|
+
const fields: string[] = []
|
|
44
|
+
for (const col of table.columns) {
|
|
45
|
+
let ktType: string
|
|
46
|
+
if (col.typeOverride) {
|
|
47
|
+
ktType = col.nullable ? `${col.typeOverride}?` : col.typeOverride
|
|
48
|
+
} else if (col.category === 'enum' && col.enumValues?.length) {
|
|
49
|
+
ktType = col.nullable ? `${toPascalCase(col.pgType)}?` : toPascalCase(col.pgType)
|
|
50
|
+
} else if (col.category === 'composite' && col.compositeFields?.length) {
|
|
51
|
+
const compositeType = toPascalCase(col.pgType)
|
|
52
|
+
ktType = col.nullable ? `${compositeType}?` : compositeType
|
|
53
|
+
} else {
|
|
54
|
+
ktType = pgToKotlin(col.pgType, col.nullable, col.category)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const defaultVal = col.nullable ? ' = null' : ''
|
|
58
|
+
fields.push(` val ${col.camelName}: ${ktType}${defaultVal}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
classes.push(`data class ${table.pascalName}(`)
|
|
62
|
+
classes.push(fields.join(',\n'))
|
|
63
|
+
classes.push(')')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Views (read-only — val only)
|
|
67
|
+
for (const view of schema.views.filter((v) => !v.skipped)) {
|
|
68
|
+
const fields: string[] = []
|
|
69
|
+
for (const col of view.columns) {
|
|
70
|
+
let ktType: string
|
|
71
|
+
if (col.typeOverride) {
|
|
72
|
+
ktType = col.nullable ? `${col.typeOverride}?` : col.typeOverride
|
|
73
|
+
} else if (col.category === 'enum' && col.enumValues?.length) {
|
|
74
|
+
ktType = col.nullable ? `${toPascalCase(col.pgType)}?` : toPascalCase(col.pgType)
|
|
75
|
+
} else if (col.category === 'composite' && col.compositeFields?.length) {
|
|
76
|
+
const compositeType = toPascalCase(col.pgType)
|
|
77
|
+
ktType = col.nullable ? `${compositeType}?` : compositeType
|
|
78
|
+
} else {
|
|
79
|
+
ktType = pgToKotlin(col.pgType, col.nullable, col.category)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const defaultVal = col.nullable ? ' = null' : ''
|
|
83
|
+
fields.push(` val ${col.camelName}: ${ktType}${defaultVal}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
classes.push(`/** Read-only (from view) */`)
|
|
87
|
+
classes.push(`data class ${view.pascalName}(`)
|
|
88
|
+
classes.push(fields.join(',\n'))
|
|
89
|
+
classes.push(')')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Functions (skip trigger functions)
|
|
93
|
+
for (const fn of schema.functions) {
|
|
94
|
+
const retRaw = fn.returnType?.type?.toLowerCase() ?? ''
|
|
95
|
+
if (retRaw === 'trigger') continue
|
|
96
|
+
|
|
97
|
+
const params = fn.args
|
|
98
|
+
.filter((a) => !a.name?.startsWith('_') && (a as any).mode !== 'OUT')
|
|
99
|
+
.map((a) => {
|
|
100
|
+
const argType = pgToKotlin(a.type, false, a.category)
|
|
101
|
+
return `${toCamelCase(a.name || 'arg')}: ${argType}`
|
|
102
|
+
})
|
|
103
|
+
.join(', ')
|
|
104
|
+
|
|
105
|
+
let retType: string
|
|
106
|
+
if (retRaw.startsWith('setof ')) {
|
|
107
|
+
const tableName = retRaw.replace('setof ', '')
|
|
108
|
+
const table = schema.tables.find((t) => t.name === tableName)
|
|
109
|
+
retType = table ? `List<${table.pascalName}>` : `List<${pgToKotlin(tableName, false)}>`
|
|
110
|
+
} else if (fn.returnType) {
|
|
111
|
+
retType = pgToKotlin(fn.returnType.type, false, fn.returnType.category)
|
|
112
|
+
} else {
|
|
113
|
+
retType = 'Unit'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
classes.push(`typealias ${fn.pascalName} = (${params}) -> ${retType}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (classes.length === 0) {
|
|
120
|
+
return { files: [] }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const imports = new Set<string>()
|
|
124
|
+
// Check if any imported types are used in generated content
|
|
125
|
+
const content = classes.join('\n')
|
|
126
|
+
if (content.includes('BigDecimal')) imports.add('import java.math.BigDecimal')
|
|
127
|
+
if (content.includes('OffsetDateTime')) imports.add('import java.time.OffsetDateTime')
|
|
128
|
+
if (content.includes('LocalDateTime')) imports.add('import java.time.LocalDateTime')
|
|
129
|
+
if (content.includes('LocalDate') && !content.includes('LocalDateTime')) imports.add('import java.time.LocalDate')
|
|
130
|
+
if (content.includes('LocalTime')) imports.add('import java.time.LocalTime')
|
|
131
|
+
if (content.includes('Duration')) imports.add('import java.time.Duration')
|
|
132
|
+
if (content.includes('UUID')) imports.add('import java.util.UUID')
|
|
133
|
+
|
|
134
|
+
const parts: string[] = []
|
|
135
|
+
const sortedImports = [...imports].sort()
|
|
136
|
+
if (sortedImports.length > 0) {
|
|
137
|
+
parts.push(sortedImports.join('\n'))
|
|
138
|
+
parts.push('')
|
|
139
|
+
}
|
|
140
|
+
parts.push(classes.join('\n\n'))
|
|
141
|
+
parts.push('')
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
files: [{ path: 'Models.kt', content: parts.join('\n') }],
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
FROM eclipse-temurin:21-jdk-alpine
|
|
2
|
+
RUN apk add --no-cache curl unzip zip bash
|
|
3
|
+
# Install Kotlin via SDKMAN
|
|
4
|
+
RUN curl -s https://get.sdkman.io | bash && \
|
|
5
|
+
bash -c "source /root/.sdkman/bin/sdkman-init.sh && sdk install kotlin"
|
|
6
|
+
# Download PostgreSQL JDBC driver
|
|
7
|
+
RUN mkdir -p /deps && \
|
|
8
|
+
curl -sL -o /deps/postgresql-42.7.4.jar https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.4/postgresql-42.7.4.jar
|
|
9
|
+
WORKDIR /app
|
|
10
|
+
COPY . .
|
|
11
|
+
# Step 1: compile the generated data classes + test
|
|
12
|
+
RUN bash -c "source /root/.sdkman/bin/sdkman-init.sh && kotlinc -cp /deps/* *.kt Test.kt -include-runtime -d out.jar"
|
|
13
|
+
# Step 2: run integration test against real DB
|
|
14
|
+
CMD ["java", "-cp", "out.jar:/deps/*", "TestKt"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import java.sql.DriverManager
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Integration test for @sqldoc/templates/kotlin-data
|
|
5
|
+
* Connects to real Postgres via JDBC, verifies generated data classes work with actual data.
|
|
6
|
+
*/
|
|
7
|
+
var failed = 0
|
|
8
|
+
|
|
9
|
+
fun assertEq(actual: Any?, expected: Any?, msg: String) {
|
|
10
|
+
if (actual != expected) {
|
|
11
|
+
System.err.println("FAIL: $msg (got $actual, expected $expected)")
|
|
12
|
+
failed++
|
|
13
|
+
} else {
|
|
14
|
+
println(" ok: $msg")
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
fun main() {
|
|
19
|
+
var dbUrl = System.getenv("DATABASE_URL")
|
|
20
|
+
if (dbUrl.isNullOrEmpty()) {
|
|
21
|
+
System.err.println("DATABASE_URL not set")
|
|
22
|
+
System.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Convert postgres(ql):// to jdbc:postgresql://, extracting userinfo for JDBC
|
|
26
|
+
val uri = java.net.URI(dbUrl.replaceFirst(Regex("^postgres(ql)?://"), "http://"))
|
|
27
|
+
val userInfo = uri.userInfo
|
|
28
|
+
var jdbcUrl = "jdbc:postgresql://${uri.host}:${if (uri.port > 0) uri.port else 5432}${uri.path}"
|
|
29
|
+
val query = uri.query
|
|
30
|
+
if (userInfo != null) {
|
|
31
|
+
val parts = userInfo.split(":", limit = 2)
|
|
32
|
+
val sep = if (query != null) "&" else "?"
|
|
33
|
+
jdbcUrl += (if (query != null) "?$query" else "") + "${sep}user=${parts[0]}&password=${parts.getOrElse(1) { "" }}"
|
|
34
|
+
} else if (query != null) {
|
|
35
|
+
jdbcUrl += "?$query"
|
|
36
|
+
}
|
|
37
|
+
dbUrl = jdbcUrl
|
|
38
|
+
|
|
39
|
+
println("--- kotlin-data integration test ---")
|
|
40
|
+
|
|
41
|
+
DriverManager.getConnection(dbUrl).use { conn ->
|
|
42
|
+
// 1. Query user and construct generated data class
|
|
43
|
+
conn.prepareStatement("SELECT id, email, name, age, is_active, created_at FROM users WHERE id = 1").use { ps ->
|
|
44
|
+
val rs = ps.executeQuery()
|
|
45
|
+
rs.next()
|
|
46
|
+
val user = Users(
|
|
47
|
+
id = rs.getLong("id"),
|
|
48
|
+
email = rs.getString("email"),
|
|
49
|
+
name = rs.getString("name"),
|
|
50
|
+
age = rs.getInt("age"),
|
|
51
|
+
isActive = rs.getBoolean("is_active"),
|
|
52
|
+
createdAt = rs.getObject("created_at", java.time.OffsetDateTime::class.java)
|
|
53
|
+
)
|
|
54
|
+
assertEq(user.email, "test@example.com", "user.email matches")
|
|
55
|
+
assertEq(user.name, "Test User", "user.name matches")
|
|
56
|
+
assertEq(user.age, 30, "user.age matches")
|
|
57
|
+
assertEq(user.isActive, true, "user.isActive matches")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Query post and construct generated data class
|
|
61
|
+
conn.prepareStatement("SELECT id, user_id, title, body, view_count FROM posts WHERE id = 1").use { ps ->
|
|
62
|
+
val rs = ps.executeQuery()
|
|
63
|
+
rs.next()
|
|
64
|
+
val post = Posts(
|
|
65
|
+
id = rs.getLong("id"),
|
|
66
|
+
userId = rs.getLong("user_id"),
|
|
67
|
+
title = rs.getString("title"),
|
|
68
|
+
body = rs.getString("body"),
|
|
69
|
+
viewCount = rs.getInt("view_count")
|
|
70
|
+
)
|
|
71
|
+
assertEq(post.title, "Hello World", "post.title matches")
|
|
72
|
+
assertEq(post.userId, 1L, "post.userId matches")
|
|
73
|
+
assertEq(post.viewCount, 42, "post.viewCount matches")
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (failed > 0) {
|
|
78
|
+
System.err.println("\n$failed assertion(s) failed")
|
|
79
|
+
System.exit(1)
|
|
80
|
+
}
|
|
81
|
+
println("\nAll assertions passed!")
|
|
82
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { defineTemplate } from '@sqldoc/ns-codegen'
|
|
2
|
+
import { activeTables, enrichRealm } from '../helpers/enrich.ts'
|
|
3
|
+
import { toCamelCase, toPascalCase } from '../helpers/naming.ts'
|
|
4
|
+
import { pgToTs, type TsTypeOptions } from '../types/pg-to-ts.ts'
|
|
5
|
+
|
|
6
|
+
export const configSchema = {
|
|
7
|
+
dateType: {
|
|
8
|
+
type: 'enum',
|
|
9
|
+
values: ['Date', 'dayjs', 'luxon', 'string'],
|
|
10
|
+
description: 'How to represent date/time types',
|
|
11
|
+
},
|
|
12
|
+
bigintType: {
|
|
13
|
+
type: 'enum',
|
|
14
|
+
values: ['number', 'bigint', 'string'],
|
|
15
|
+
description: 'How to represent bigint/bigserial columns',
|
|
16
|
+
},
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export default defineTemplate({
|
|
20
|
+
name: 'Kysely Database',
|
|
21
|
+
description: 'Generate Kysely Database interface and table types from SQL schema',
|
|
22
|
+
language: 'typescript',
|
|
23
|
+
configSchema,
|
|
24
|
+
|
|
25
|
+
generate(ctx) {
|
|
26
|
+
const config = ctx.config ?? {}
|
|
27
|
+
const options: TsTypeOptions = {
|
|
28
|
+
dateType: config.dateType,
|
|
29
|
+
bigintType: config.bigintType,
|
|
30
|
+
nullableStyle: 'null-union',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const schema = enrichRealm(ctx)
|
|
34
|
+
const lines: string[] = [
|
|
35
|
+
'// Generated by @sqldoc/templates/kysely -- DO NOT EDIT',
|
|
36
|
+
'',
|
|
37
|
+
"import type { Generated } from 'kysely'",
|
|
38
|
+
'',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
// Enums
|
|
42
|
+
for (const e of schema.enums) {
|
|
43
|
+
const values = e.values.map((v) => `'${v}'`).join(' | ')
|
|
44
|
+
lines.push(`export type ${e.pascalName} = ${values}`)
|
|
45
|
+
lines.push('')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Composite types (collected from columns)
|
|
49
|
+
const composites = new Map<string, Array<{ name: string; type: string }>>()
|
|
50
|
+
for (const table of schema.tables) {
|
|
51
|
+
for (const col of table.columns) {
|
|
52
|
+
if (col.category === 'composite' && col.compositeFields?.length && !composites.has(col.pgType)) {
|
|
53
|
+
composites.set(col.pgType, col.compositeFields)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const [name, fields] of composites) {
|
|
58
|
+
const typeName = toPascalCase(name)
|
|
59
|
+
lines.push(`export interface ${typeName} {`)
|
|
60
|
+
for (const f of fields) {
|
|
61
|
+
lines.push(` ${toCamelCase(f.name)}: ${pgToTs(f.type, false, options)}`)
|
|
62
|
+
}
|
|
63
|
+
lines.push('}')
|
|
64
|
+
lines.push('')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Generate per-table interfaces
|
|
68
|
+
const tableEntries: string[] = []
|
|
69
|
+
|
|
70
|
+
for (const table of activeTables(schema)) {
|
|
71
|
+
const interfaceName = `${table.pascalName}Table`
|
|
72
|
+
|
|
73
|
+
lines.push(`export interface ${interfaceName} {`)
|
|
74
|
+
|
|
75
|
+
for (const col of table.columns) {
|
|
76
|
+
let tsType = resolveType(col, options)
|
|
77
|
+
|
|
78
|
+
// Wrap auto-generated columns in Generated<T>
|
|
79
|
+
if (col.isSerial || col.raw.default !== undefined) {
|
|
80
|
+
tsType = `Generated<${tsType}>`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
lines.push(` ${col.name}: ${tsType}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lines.push('}')
|
|
87
|
+
lines.push('')
|
|
88
|
+
|
|
89
|
+
tableEntries.push(` ${table.name}: ${interfaceName}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Views (read-only)
|
|
93
|
+
for (const view of schema.views.filter((v) => !v.skipped)) {
|
|
94
|
+
const interfaceName = `${view.pascalName}View`
|
|
95
|
+
|
|
96
|
+
lines.push(`/** Read-only (from view) */`)
|
|
97
|
+
lines.push(`export interface ${interfaceName} {`)
|
|
98
|
+
|
|
99
|
+
for (const col of view.columns) {
|
|
100
|
+
const tsType = resolveType(col, options)
|
|
101
|
+
lines.push(` ${col.name}: ${tsType}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
lines.push('}')
|
|
105
|
+
lines.push('')
|
|
106
|
+
|
|
107
|
+
tableEntries.push(` ${view.name}: ${interfaceName}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Generate the Database interface
|
|
111
|
+
lines.push('export interface Database {')
|
|
112
|
+
lines.push(tableEntries.join('\n'))
|
|
113
|
+
lines.push('}')
|
|
114
|
+
lines.push('')
|
|
115
|
+
|
|
116
|
+
// Functions (skip trigger functions)
|
|
117
|
+
for (const fn of schema.functions) {
|
|
118
|
+
const retRaw = fn.returnType?.type?.toLowerCase() ?? ''
|
|
119
|
+
if (retRaw === 'trigger') continue
|
|
120
|
+
|
|
121
|
+
const params = fn.args
|
|
122
|
+
.filter((a) => !a.name?.startsWith('_') && (a as any).mode !== 'OUT')
|
|
123
|
+
.map((a) => {
|
|
124
|
+
const argType = pgToTs(a.type, false, options, a.category as any)
|
|
125
|
+
return `${toCamelCase(a.name || 'arg')}: ${argType}`
|
|
126
|
+
})
|
|
127
|
+
.join(', ')
|
|
128
|
+
|
|
129
|
+
let retType: string
|
|
130
|
+
if (retRaw.startsWith('setof ')) {
|
|
131
|
+
const tableName = retRaw.replace('setof ', '')
|
|
132
|
+
const table = schema.tables.find((t) => t.name === tableName)
|
|
133
|
+
retType = table ? `${table.pascalName}Table[]` : `${pgToTs(tableName, false, options)}[]`
|
|
134
|
+
} else if (fn.returnType) {
|
|
135
|
+
retType = pgToTs(fn.returnType.type, false, options, fn.returnType.category as any)
|
|
136
|
+
} else {
|
|
137
|
+
retType = 'void'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lines.push(`export type ${fn.pascalName} = (${params}) => ${retType}`)
|
|
141
|
+
lines.push('')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
files: [
|
|
146
|
+
{
|
|
147
|
+
path: 'database.ts',
|
|
148
|
+
content: lines.join('\n'),
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveType(col: any, options: TsTypeOptions): string {
|
|
154
|
+
if (col.typeOverride) return col.typeOverride
|
|
155
|
+
if (col.category === 'enum' && col.enumValues?.length) {
|
|
156
|
+
return col.nullable ? `${toPascalCase(col.pgType)} | null` : toPascalCase(col.pgType)
|
|
157
|
+
}
|
|
158
|
+
if (col.category === 'composite' && col.compositeFields?.length) {
|
|
159
|
+
const compositeType = toPascalCase(col.pgType)
|
|
160
|
+
return col.nullable ? `${compositeType} | null` : compositeType
|
|
161
|
+
}
|
|
162
|
+
return pgToTs(col.pgType, col.nullable, options, col.category)
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
FROM node:23-slim
|
|
2
|
+
WORKDIR /app
|
|
3
|
+
COPY . .
|
|
4
|
+
RUN npm init -y && npm pkg set type=module && npm install typescript@5 kysely@0.27 pg @types/pg @types/node --save-dev
|
|
5
|
+
# Step 1: typecheck the generated types
|
|
6
|
+
RUN npx tsc --noEmit --strict --esModuleInterop --module nodenext --moduleResolution nodenext database.ts
|
|
7
|
+
# Step 2: run integration test against real DB
|
|
8
|
+
CMD ["node", "--experimental-strip-types", "test.ts"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for @sqldoc/templates/kysely
|
|
3
|
+
* Connects to real Postgres via Kysely, verifies generated Database interface works.
|
|
4
|
+
*/
|
|
5
|
+
import { Kysely, PostgresDialect } from 'kysely'
|
|
6
|
+
import pg from 'pg'
|
|
7
|
+
import type { Database } from './database.ts'
|
|
8
|
+
|
|
9
|
+
const DATABASE_URL = process.env.DATABASE_URL
|
|
10
|
+
if (!DATABASE_URL) {
|
|
11
|
+
console.error('DATABASE_URL not set')
|
|
12
|
+
process.exit(1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const db = new Kysely<Database>({
|
|
16
|
+
dialect: new PostgresDialect({
|
|
17
|
+
pool: new pg.Pool({ connectionString: DATABASE_URL }),
|
|
18
|
+
}),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
let failed = 0
|
|
22
|
+
function assert(condition: boolean, msg: string) {
|
|
23
|
+
if (!condition) {
|
|
24
|
+
console.error(`FAIL: ${msg}`)
|
|
25
|
+
failed++
|
|
26
|
+
} else {
|
|
27
|
+
console.log(` ok: ${msg}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function run() {
|
|
32
|
+
try {
|
|
33
|
+
console.log('--- kysely integration test ---')
|
|
34
|
+
|
|
35
|
+
// 1. Query known seeded user using Kysely's type-safe query builder
|
|
36
|
+
const user = await db.selectFrom('users').selectAll().where('id', '=', 1).executeTakeFirstOrThrow()
|
|
37
|
+
|
|
38
|
+
assert(user.email === 'test@example.com', 'user email matches')
|
|
39
|
+
assert(user.name === 'Test User', 'user name matches')
|
|
40
|
+
assert(user.is_active === true, 'user is_active matches')
|
|
41
|
+
|
|
42
|
+
// 2. Query known seeded post
|
|
43
|
+
const post = await db.selectFrom('posts').selectAll().where('id', '=', 1).executeTakeFirstOrThrow()
|
|
44
|
+
|
|
45
|
+
assert(post.title === 'Hello World', 'post title matches')
|
|
46
|
+
|
|
47
|
+
// 3. Insert a new post via type-safe insert
|
|
48
|
+
await db
|
|
49
|
+
.insertInto('posts')
|
|
50
|
+
.values({
|
|
51
|
+
user_id: 1,
|
|
52
|
+
title: 'Post from kysely',
|
|
53
|
+
body: 'test body',
|
|
54
|
+
view_count: 0,
|
|
55
|
+
})
|
|
56
|
+
.execute()
|
|
57
|
+
|
|
58
|
+
// 4. Read it back
|
|
59
|
+
const newPost = await db
|
|
60
|
+
.selectFrom('posts')
|
|
61
|
+
.selectAll()
|
|
62
|
+
.where('title', '=', 'Post from kysely')
|
|
63
|
+
.executeTakeFirstOrThrow()
|
|
64
|
+
|
|
65
|
+
assert(newPost.title === 'Post from kysely', 'inserted post title matches')
|
|
66
|
+
// pg returns bigint columns as strings; use loose equality for numeric comparison
|
|
67
|
+
assert(Number(newPost.user_id) === 1, 'inserted post user_id matches')
|
|
68
|
+
|
|
69
|
+
if (failed > 0) {
|
|
70
|
+
console.error(`\n${failed} assertion(s) failed`)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
console.log('\nAll assertions passed!')
|
|
74
|
+
} finally {
|
|
75
|
+
await db.destroy()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
run().catch((err) => {
|
|
80
|
+
console.error(err)
|
|
81
|
+
process.exit(1)
|
|
82
|
+
})
|