@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,255 @@
|
|
|
1
|
+
import { defineTemplate } from '@sqldoc/ns-codegen'
|
|
2
|
+
import { activeTables, enrichRealm } from '../helpers/enrich.ts'
|
|
3
|
+
import { toCamelCase } from '../helpers/naming.ts'
|
|
4
|
+
|
|
5
|
+
export const configSchema = {
|
|
6
|
+
schemaName: {
|
|
7
|
+
type: 'string',
|
|
8
|
+
description: 'PostgreSQL schema name override',
|
|
9
|
+
},
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
/** Track which drizzle-orm/pg-core imports are used */
|
|
13
|
+
interface ImportTracker {
|
|
14
|
+
used: Set<string>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Map a PostgreSQL type to a Drizzle column builder call */
|
|
18
|
+
function pgToDrizzle(pgType: string, colName: string, tracker: ImportTracker): string {
|
|
19
|
+
const normalized = pgType.toLowerCase().trim()
|
|
20
|
+
|
|
21
|
+
// Strip length specifiers for lookup
|
|
22
|
+
const baseType = normalized.replace(/\(\d+(?:,\s*\d+)?\)/, '').trim()
|
|
23
|
+
|
|
24
|
+
const map: Record<string, [string, string]> = {
|
|
25
|
+
// [drizzle-function-name, import-name]
|
|
26
|
+
smallint: ['smallint', 'smallint'],
|
|
27
|
+
int2: ['smallint', 'smallint'],
|
|
28
|
+
integer: ['integer', 'integer'],
|
|
29
|
+
int: ['integer', 'integer'],
|
|
30
|
+
int4: ['integer', 'integer'],
|
|
31
|
+
bigint: ['bigint', 'bigint'],
|
|
32
|
+
int8: ['bigint', 'bigint'],
|
|
33
|
+
serial: ['serial', 'serial'],
|
|
34
|
+
serial4: ['serial', 'serial'],
|
|
35
|
+
bigserial: ['bigserial', 'bigserial'],
|
|
36
|
+
serial8: ['bigserial', 'bigserial'],
|
|
37
|
+
smallserial: ['smallserial', 'smallserial'],
|
|
38
|
+
serial2: ['smallserial', 'smallserial'],
|
|
39
|
+
real: ['real', 'real'],
|
|
40
|
+
float4: ['real', 'real'],
|
|
41
|
+
'double precision': ['doublePrecision', 'doublePrecision'],
|
|
42
|
+
float8: ['doublePrecision', 'doublePrecision'],
|
|
43
|
+
numeric: ['numeric', 'numeric'],
|
|
44
|
+
decimal: ['numeric', 'numeric'],
|
|
45
|
+
money: ['text', 'text'],
|
|
46
|
+
|
|
47
|
+
text: ['text', 'text'],
|
|
48
|
+
varchar: ['varchar', 'varchar'],
|
|
49
|
+
'character varying': ['varchar', 'varchar'],
|
|
50
|
+
char: ['char', 'char'],
|
|
51
|
+
character: ['char', 'char'],
|
|
52
|
+
name: ['text', 'text'],
|
|
53
|
+
citext: ['text', 'text'],
|
|
54
|
+
|
|
55
|
+
boolean: ['boolean', 'boolean'],
|
|
56
|
+
bool: ['boolean', 'boolean'],
|
|
57
|
+
|
|
58
|
+
timestamp: ['timestamp', 'timestamp'],
|
|
59
|
+
'timestamp without time zone': ['timestamp', 'timestamp'],
|
|
60
|
+
timestamptz: ['timestamp', 'timestamp'],
|
|
61
|
+
'timestamp with time zone': ['timestamp', 'timestamp'],
|
|
62
|
+
date: ['date', 'date'],
|
|
63
|
+
time: ['time', 'time'],
|
|
64
|
+
'time without time zone': ['time', 'time'],
|
|
65
|
+
timetz: ['time', 'time'],
|
|
66
|
+
'time with time zone': ['time', 'time'],
|
|
67
|
+
interval: ['interval', 'interval'],
|
|
68
|
+
|
|
69
|
+
bytea: ['text', 'text'],
|
|
70
|
+
|
|
71
|
+
json: ['json', 'json'],
|
|
72
|
+
jsonb: ['jsonb', 'jsonb'],
|
|
73
|
+
|
|
74
|
+
uuid: ['uuid', 'uuid'],
|
|
75
|
+
|
|
76
|
+
inet: ['inet', 'inet'],
|
|
77
|
+
cidr: ['text', 'text'],
|
|
78
|
+
macaddr: ['macaddr', 'macaddr'],
|
|
79
|
+
macaddr8: ['macaddr8', 'macaddr8'],
|
|
80
|
+
|
|
81
|
+
xml: ['text', 'text'],
|
|
82
|
+
tsvector: ['text', 'text'],
|
|
83
|
+
tsquery: ['text', 'text'],
|
|
84
|
+
oid: ['integer', 'integer'],
|
|
85
|
+
point: ['text', 'text'],
|
|
86
|
+
line: ['text', 'text'],
|
|
87
|
+
box: ['text', 'text'],
|
|
88
|
+
circle: ['text', 'text'],
|
|
89
|
+
polygon: ['text', 'text'],
|
|
90
|
+
path: ['text', 'text'],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const entry = map[baseType]
|
|
94
|
+
if (entry) {
|
|
95
|
+
tracker.used.add(entry[1])
|
|
96
|
+
// bigint and bigserial require a mode option in drizzle-orm 0.38+
|
|
97
|
+
if (entry[0] === 'bigint' || entry[0] === 'bigserial') {
|
|
98
|
+
return `${entry[0]}('${colName}', { mode: 'number' })`
|
|
99
|
+
}
|
|
100
|
+
return `${entry[0]}('${colName}')`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Fallback: arrays and unknowns
|
|
104
|
+
if (normalized.endsWith('[]') || normalized.startsWith('_')) {
|
|
105
|
+
tracker.used.add('text')
|
|
106
|
+
return `text('${colName}')`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
tracker.used.add('text')
|
|
110
|
+
return `text('${colName}')`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Format a default expression for Drizzle */
|
|
114
|
+
function formatDefault(defaultValue: string | undefined): string | undefined {
|
|
115
|
+
if (!defaultValue) return undefined
|
|
116
|
+
// Heuristic: expressions with parens or uppercase keywords are likely SQL expressions
|
|
117
|
+
if (defaultValue.includes('(') || /^[A-Z_]+$/.test(defaultValue)) {
|
|
118
|
+
return `sql\`${defaultValue}\``
|
|
119
|
+
}
|
|
120
|
+
if (defaultValue === 'true' || defaultValue === 'false') return defaultValue
|
|
121
|
+
if (/^\d+$/.test(defaultValue)) return defaultValue
|
|
122
|
+
return `'${defaultValue}'`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default defineTemplate({
|
|
126
|
+
name: 'Drizzle Schema',
|
|
127
|
+
description: 'Generate Drizzle ORM pgTable schema definitions from SQL schema',
|
|
128
|
+
language: 'typescript',
|
|
129
|
+
configSchema,
|
|
130
|
+
|
|
131
|
+
generate(ctx) {
|
|
132
|
+
const schema = enrichRealm(ctx)
|
|
133
|
+
const tables = activeTables(schema)
|
|
134
|
+
const tracker: ImportTracker = { used: new Set(['pgTable']) }
|
|
135
|
+
const tableBlocks: string[] = []
|
|
136
|
+
|
|
137
|
+
// We'll need a map from table name to camelCase variable name for FK references
|
|
138
|
+
const tableVarMap = new Map<string, string>()
|
|
139
|
+
for (const table of tables) {
|
|
140
|
+
tableVarMap.set(table.name, toCamelCase(table.pascalName))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Enums
|
|
144
|
+
const enumBlocks: string[] = []
|
|
145
|
+
if (schema.enums.length > 0) {
|
|
146
|
+
tracker.used.add('pgEnum')
|
|
147
|
+
for (const e of schema.enums) {
|
|
148
|
+
const values = e.values.map((v) => `'${v}'`).join(', ')
|
|
149
|
+
enumBlocks.push(`export const ${toCamelCase(e.name)}Enum = pgEnum('${e.name}', [${values}])`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const table of tables) {
|
|
154
|
+
const varName = toCamelCase(table.pascalName)
|
|
155
|
+
|
|
156
|
+
const colLines: string[] = []
|
|
157
|
+
|
|
158
|
+
for (const col of table.columns) {
|
|
159
|
+
let builder: string
|
|
160
|
+
if (col.typeOverride) {
|
|
161
|
+
tracker.used.add('text')
|
|
162
|
+
builder = `text('${col.name}')`
|
|
163
|
+
} else if (col.category === 'enum' && col.enumValues?.length) {
|
|
164
|
+
builder = `${toCamelCase(col.pgType)}Enum('${col.name}')`
|
|
165
|
+
} else {
|
|
166
|
+
builder = pgToDrizzle(col.pgType, col.name, tracker)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Add modifiers
|
|
170
|
+
if (col.isPrimaryKey) {
|
|
171
|
+
builder += '.primaryKey()'
|
|
172
|
+
}
|
|
173
|
+
if (!col.nullable && !col.isSerial) {
|
|
174
|
+
builder += '.notNull()'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const defaultExpr = formatDefault(col.defaultValue)
|
|
178
|
+
if (defaultExpr) {
|
|
179
|
+
if (defaultExpr.startsWith('sql`')) {
|
|
180
|
+
tracker.used.add('sql')
|
|
181
|
+
builder += `.default(${defaultExpr})`
|
|
182
|
+
} else {
|
|
183
|
+
builder += `.default(${defaultExpr})`
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Foreign key references (skip self-references to avoid circular implicit-any in TS)
|
|
188
|
+
if (col.foreignKey && col.foreignKey.table !== table.name) {
|
|
189
|
+
const refVar = tableVarMap.get(col.foreignKey.table) ?? toCamelCase(col.foreignKey.table)
|
|
190
|
+
const refCol = col.foreignKey.column
|
|
191
|
+
builder += `.references(() => ${refVar}.${toCamelCase(refCol)})`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
colLines.push(` ${toCamelCase(col.name)}: ${builder},`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
tableBlocks.push(`export const ${varName} = pgTable('${table.name}', {\n${colLines.join('\n')}\n})`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Views (read-only)
|
|
201
|
+
for (const view of schema.views.filter((v) => !v.skipped)) {
|
|
202
|
+
const varName = toCamelCase(view.pascalName)
|
|
203
|
+
tracker.used.add('pgView')
|
|
204
|
+
|
|
205
|
+
const colLines: string[] = []
|
|
206
|
+
for (const col of view.columns) {
|
|
207
|
+
let builder: string
|
|
208
|
+
if (col.typeOverride) {
|
|
209
|
+
tracker.used.add('text')
|
|
210
|
+
builder = `text('${col.name}')`
|
|
211
|
+
} else if (col.category === 'enum' && col.enumValues?.length) {
|
|
212
|
+
builder = `${toCamelCase(col.pgType)}Enum('${col.name}')`
|
|
213
|
+
} else {
|
|
214
|
+
builder = pgToDrizzle(col.pgType, col.name, tracker)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
colLines.push(` ${toCamelCase(col.name)}: ${builder},`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
tableBlocks.push(
|
|
221
|
+
`/** Read-only (from view) */\nexport const ${varName} = pgView('${view.name}', {\n${colLines.join('\n')}\n})`,
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Build imports
|
|
226
|
+
const importNames = Array.from(tracker.used).sort()
|
|
227
|
+
const sqlImport = importNames.includes('sql')
|
|
228
|
+
const pgCoreImports = importNames.filter((n) => n !== 'sql')
|
|
229
|
+
|
|
230
|
+
const lines: string[] = [
|
|
231
|
+
'// Generated by @sqldoc/templates/drizzle -- DO NOT EDIT',
|
|
232
|
+
'',
|
|
233
|
+
`import { ${pgCoreImports.join(', ')} } from 'drizzle-orm/pg-core'`,
|
|
234
|
+
]
|
|
235
|
+
if (sqlImport) {
|
|
236
|
+
lines.push("import { sql } from 'drizzle-orm'")
|
|
237
|
+
}
|
|
238
|
+
lines.push('')
|
|
239
|
+
if (enumBlocks.length > 0) {
|
|
240
|
+
lines.push(enumBlocks.join('\n'))
|
|
241
|
+
lines.push('')
|
|
242
|
+
}
|
|
243
|
+
lines.push(tableBlocks.join('\n\n'))
|
|
244
|
+
lines.push('')
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
files: [
|
|
248
|
+
{
|
|
249
|
+
path: 'schema.ts',
|
|
250
|
+
content: lines.join('\n'),
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
})
|
|
@@ -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 drizzle-orm@0.38 pg @types/pg @types/node --save-dev
|
|
5
|
+
# Step 1: typecheck the generated schema
|
|
6
|
+
RUN npx tsc --noEmit --strict --esModuleInterop --module nodenext --moduleResolution nodenext --allowImportingTsExtensions --skipLibCheck *.ts
|
|
7
|
+
# Step 2: run integration test against real DB
|
|
8
|
+
CMD ["node", "--experimental-strip-types", "test.ts"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for @sqldoc/templates/drizzle
|
|
3
|
+
* Connects to real Postgres via drizzle-orm, verifies generated schema works.
|
|
4
|
+
*/
|
|
5
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
6
|
+
import { eq } from 'drizzle-orm'
|
|
7
|
+
import pg from 'pg'
|
|
8
|
+
import * as schema from './schema.ts'
|
|
9
|
+
|
|
10
|
+
const DATABASE_URL = process.env.DATABASE_URL
|
|
11
|
+
if (!DATABASE_URL) {
|
|
12
|
+
console.error('DATABASE_URL not set')
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
|
17
|
+
const db = drizzle(pool, { schema })
|
|
18
|
+
|
|
19
|
+
let failed = 0
|
|
20
|
+
function assert(condition: boolean, msg: string) {
|
|
21
|
+
if (!condition) {
|
|
22
|
+
console.error(`FAIL: ${msg}`)
|
|
23
|
+
failed++
|
|
24
|
+
} else {
|
|
25
|
+
console.log(` ok: ${msg}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function run() {
|
|
30
|
+
try {
|
|
31
|
+
console.log('--- drizzle integration test ---')
|
|
32
|
+
|
|
33
|
+
// 1. Query known seeded user
|
|
34
|
+
const users = await db.select().from(schema.users).where(eq(schema.users.id, 1))
|
|
35
|
+
assert(users.length === 1, 'seeded user found')
|
|
36
|
+
assert(users[0].email === 'test@example.com', 'user email matches')
|
|
37
|
+
assert(users[0].name === 'Test User', 'user name matches')
|
|
38
|
+
|
|
39
|
+
// 2. Query known seeded post
|
|
40
|
+
const posts = await db.select().from(schema.posts).where(eq(schema.posts.id, 1))
|
|
41
|
+
assert(posts.length === 1, 'seeded post found')
|
|
42
|
+
assert(posts[0].title === 'Hello World', 'post title matches')
|
|
43
|
+
|
|
44
|
+
// 3. Insert a new post
|
|
45
|
+
await db.insert(schema.posts).values({
|
|
46
|
+
userId: 1,
|
|
47
|
+
title: 'Post from drizzle',
|
|
48
|
+
body: 'test body',
|
|
49
|
+
viewCount: 0,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// 4. Read it back
|
|
53
|
+
const newPosts = await db.select().from(schema.posts).where(eq(schema.posts.title, 'Post from drizzle'))
|
|
54
|
+
assert(newPosts.length === 1, 'inserted post found')
|
|
55
|
+
assert(newPosts[0].title === 'Post from drizzle', 'inserted post title matches')
|
|
56
|
+
assert(Number(newPosts[0].userId) === 1, 'inserted post user_id matches')
|
|
57
|
+
|
|
58
|
+
if (failed > 0) {
|
|
59
|
+
console.error(`\n${failed} assertion(s) failed`)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
console.log('\nAll assertions passed!')
|
|
63
|
+
} finally {
|
|
64
|
+
await pool.end()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
run().catch((err) => {
|
|
69
|
+
console.error(err)
|
|
70
|
+
process.exit(1)
|
|
71
|
+
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { defineTemplate } from '@sqldoc/ns-codegen'
|
|
2
|
+
import { activeTables, enrichRealm, type TagEntry } from '../helpers/enrich.ts'
|
|
3
|
+
import { toPascalCase } from '../helpers/naming.ts'
|
|
4
|
+
import { pgToCsharp } from '../types/pg-to-csharp.ts'
|
|
5
|
+
|
|
6
|
+
// C# reference types that get [Required] when non-nullable
|
|
7
|
+
const REFERENCE_TYPES = new Set(['string', 'byte[]', 'IPAddress'])
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract varchar length from pgType, e.g. varchar(255) -> 255
|
|
11
|
+
*/
|
|
12
|
+
function getVarcharLength(pgType: string): number | undefined {
|
|
13
|
+
const match = pgType.match(/(?:varchar|character varying)\((\d+)\)/i)
|
|
14
|
+
return match ? parseInt(match[1], 10) : undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate EF Core data annotations from @validate tags.
|
|
19
|
+
*/
|
|
20
|
+
function getValidationAnnotations(colTags: TagEntry[]): string[] {
|
|
21
|
+
const annotations: string[] = []
|
|
22
|
+
|
|
23
|
+
for (const tag of colTags) {
|
|
24
|
+
if (tag.namespace !== 'validate') continue
|
|
25
|
+
|
|
26
|
+
if (tag.tag === 'length') {
|
|
27
|
+
const args = tag.args as Record<string, unknown>
|
|
28
|
+
if (args.max !== undefined) {
|
|
29
|
+
annotations.push(` [MaxLength(${args.max})]`)
|
|
30
|
+
}
|
|
31
|
+
} else if (tag.tag === 'range') {
|
|
32
|
+
const args = tag.args as Record<string, unknown>
|
|
33
|
+
const min = args.min ?? 0
|
|
34
|
+
const max = args.max ?? 0
|
|
35
|
+
annotations.push(` [Range(${min}, ${max})]`)
|
|
36
|
+
} else if (tag.tag === 'pattern') {
|
|
37
|
+
const pattern = Array.isArray(tag.args) ? tag.args[0] : undefined
|
|
38
|
+
if (pattern) {
|
|
39
|
+
annotations.push(` [RegularExpression(@"${pattern}")]`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return annotations
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default defineTemplate({
|
|
48
|
+
name: 'EF Core Entities',
|
|
49
|
+
description: 'Generate Entity Framework Core entity classes with data annotations from SQL schema',
|
|
50
|
+
language: 'csharp',
|
|
51
|
+
|
|
52
|
+
generate(ctx) {
|
|
53
|
+
const schema = enrichRealm(ctx)
|
|
54
|
+
const classes: string[] = []
|
|
55
|
+
|
|
56
|
+
// Enums
|
|
57
|
+
for (const e of schema.enums) {
|
|
58
|
+
const enumName = toPascalCase(e.name)
|
|
59
|
+
const members = e.values.map((v) => ` ${toPascalCase(v)}`).join(',\n')
|
|
60
|
+
classes.push(`public enum ${enumName}\n{\n${members}\n}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Composite types as [Owned] classes
|
|
64
|
+
const composites = new Map<string, Array<{ name: string; type: string }>>()
|
|
65
|
+
for (const table of schema.tables) {
|
|
66
|
+
for (const col of table.columns) {
|
|
67
|
+
if (col.category === 'composite' && col.compositeFields?.length && !composites.has(col.pgType)) {
|
|
68
|
+
composites.set(col.pgType, col.compositeFields)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const [name, fields] of composites) {
|
|
73
|
+
const className = toPascalCase(name)
|
|
74
|
+
const propertyLines: string[] = []
|
|
75
|
+
for (const f of fields) {
|
|
76
|
+
const csType = pgToCsharp(f.type, false)
|
|
77
|
+
propertyLines.push(` public ${csType} ${toPascalCase(f.name)} { get; set; }`)
|
|
78
|
+
propertyLines.push('')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
classes.push(`[Owned]`)
|
|
82
|
+
classes.push(`public class ${className}`)
|
|
83
|
+
classes.push('{')
|
|
84
|
+
classes.push(propertyLines.join('\n'))
|
|
85
|
+
classes.push('}')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const table of activeTables(schema)) {
|
|
89
|
+
const propertyLines: string[] = []
|
|
90
|
+
for (const col of table.columns) {
|
|
91
|
+
let csType: string
|
|
92
|
+
if (col.typeOverride) {
|
|
93
|
+
csType = col.nullable ? `${col.typeOverride}?` : col.typeOverride
|
|
94
|
+
} else if (col.category === 'enum' && col.enumValues?.length) {
|
|
95
|
+
csType = col.nullable ? `${toPascalCase(col.pgType)}?` : toPascalCase(col.pgType)
|
|
96
|
+
} else if (col.category === 'composite' && col.compositeFields?.length) {
|
|
97
|
+
const compositeType = toPascalCase(col.pgType)
|
|
98
|
+
csType = col.nullable ? `${compositeType}?` : compositeType
|
|
99
|
+
} else {
|
|
100
|
+
csType = pgToCsharp(col.pgType, col.nullable, col.category)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const annotations: string[] = []
|
|
104
|
+
|
|
105
|
+
// PK annotation
|
|
106
|
+
if (col.isPrimaryKey) {
|
|
107
|
+
annotations.push(' [Key]')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// FK annotation
|
|
111
|
+
if (col.foreignKey) {
|
|
112
|
+
annotations.push(` [ForeignKey("${col.pascalName}")]`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Required for non-nullable reference types
|
|
116
|
+
const baseType = csType.replace('?', '')
|
|
117
|
+
if (!col.nullable && REFERENCE_TYPES.has(baseType) && !col.isPrimaryKey) {
|
|
118
|
+
annotations.push(' [Required]')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// MaxLength for varchar
|
|
122
|
+
const varcharLen = getVarcharLength(col.pgType)
|
|
123
|
+
if (varcharLen) {
|
|
124
|
+
annotations.push(` [MaxLength(${varcharLen})]`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Validation annotations from @validate tags
|
|
128
|
+
const validationAnnotations = getValidationAnnotations(col.tags)
|
|
129
|
+
annotations.push(...validationAnnotations)
|
|
130
|
+
|
|
131
|
+
if (annotations.length > 0) {
|
|
132
|
+
propertyLines.push(annotations.join('\n'))
|
|
133
|
+
}
|
|
134
|
+
propertyLines.push(` public ${csType} ${col.pascalName} { get; set; }`)
|
|
135
|
+
propertyLines.push('')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
classes.push(`public class ${table.pascalName}`)
|
|
139
|
+
classes.push('{')
|
|
140
|
+
classes.push(propertyLines.join('\n'))
|
|
141
|
+
classes.push('}')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Views (read-only — [Keyless] entities)
|
|
145
|
+
for (const view of schema.views.filter((v) => !v.skipped)) {
|
|
146
|
+
const propertyLines: string[] = []
|
|
147
|
+
for (const col of view.columns) {
|
|
148
|
+
let csType: string
|
|
149
|
+
if (col.typeOverride) {
|
|
150
|
+
csType = col.nullable ? `${col.typeOverride}?` : col.typeOverride
|
|
151
|
+
} else if (col.category === 'enum' && col.enumValues?.length) {
|
|
152
|
+
csType = col.nullable ? `${toPascalCase(col.pgType)}?` : toPascalCase(col.pgType)
|
|
153
|
+
} else if (col.category === 'composite' && col.compositeFields?.length) {
|
|
154
|
+
const compositeType = toPascalCase(col.pgType)
|
|
155
|
+
csType = col.nullable ? `${compositeType}?` : compositeType
|
|
156
|
+
} else {
|
|
157
|
+
csType = pgToCsharp(col.pgType, col.nullable, col.category)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
propertyLines.push(` public ${csType} ${col.pascalName} { get; }`)
|
|
161
|
+
propertyLines.push('')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
classes.push(`/// <summary>Read-only (from view)</summary>`)
|
|
165
|
+
classes.push(`[Keyless]`)
|
|
166
|
+
classes.push(`public class ${view.pascalName}`)
|
|
167
|
+
classes.push('{')
|
|
168
|
+
classes.push(propertyLines.join('\n'))
|
|
169
|
+
classes.push('}')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (classes.length === 0) {
|
|
173
|
+
return { files: [] }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const parts: string[] = []
|
|
177
|
+
parts.push('using System.ComponentModel.DataAnnotations;')
|
|
178
|
+
parts.push('using System.ComponentModel.DataAnnotations.Schema;')
|
|
179
|
+
parts.push('using Microsoft.EntityFrameworkCore;')
|
|
180
|
+
parts.push('')
|
|
181
|
+
parts.push('namespace Generated;')
|
|
182
|
+
parts.push('')
|
|
183
|
+
parts.push(classes.join('\n\n'))
|
|
184
|
+
parts.push('')
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
files: [{ path: 'Models.cs', content: parts.join('\n') }],
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine
|
|
2
|
+
WORKDIR /app
|
|
3
|
+
RUN dotnet new classlib -n TypeCheck --force && rm TypeCheck/Class1.cs
|
|
4
|
+
RUN dotnet add TypeCheck/ package Microsoft.EntityFrameworkCore --version 9.0.0
|
|
5
|
+
COPY *.cs TypeCheck/
|
|
6
|
+
RUN dotnet build TypeCheck/
|
|
7
|
+
CMD ["echo", "ok"]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { defineTemplate } from '@sqldoc/ns-codegen'
|
|
2
|
+
import { activeTables, enrichRealm } from '../helpers/enrich.ts'
|
|
3
|
+
import { toPascalCase } from '../helpers/naming.ts'
|
|
4
|
+
import { pgToGo } from '../types/pg-to-go.ts'
|
|
5
|
+
|
|
6
|
+
export default defineTemplate({
|
|
7
|
+
name: 'Go Structs',
|
|
8
|
+
description: 'Generate plain Go structs with json struct tags from SQL schema',
|
|
9
|
+
language: 'go',
|
|
10
|
+
|
|
11
|
+
generate(ctx) {
|
|
12
|
+
const schema = enrichRealm(ctx)
|
|
13
|
+
const allImports = new Set<string>()
|
|
14
|
+
const structBlocks: string[] = []
|
|
15
|
+
|
|
16
|
+
// Enums
|
|
17
|
+
for (const e of schema.enums) {
|
|
18
|
+
const typeName = toPascalCase(e.name)
|
|
19
|
+
structBlocks.push(`type ${typeName} string`)
|
|
20
|
+
structBlocks.push('')
|
|
21
|
+
const constLines = e.values.map((v) => {
|
|
22
|
+
const constName = `${typeName}${toPascalCase(v)}`
|
|
23
|
+
return `\t${constName} ${typeName} = "${v}"`
|
|
24
|
+
})
|
|
25
|
+
structBlocks.push(`const (\n${constLines.join('\n')}\n)`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Composite types (collected from columns)
|
|
29
|
+
const composites = new Map<string, Array<{ name: string; type: string }>>()
|
|
30
|
+
for (const table of schema.tables) {
|
|
31
|
+
for (const col of table.columns) {
|
|
32
|
+
if (col.category === 'composite' && col.compositeFields?.length && !composites.has(col.pgType)) {
|
|
33
|
+
composites.set(col.pgType, col.compositeFields)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const view of schema.views) {
|
|
38
|
+
for (const col of view.columns) {
|
|
39
|
+
if (col.category === 'composite' && col.compositeFields?.length && !composites.has(col.pgType)) {
|
|
40
|
+
composites.set(col.pgType, col.compositeFields)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const [name, fields] of composites) {
|
|
45
|
+
const typeName = toPascalCase(name)
|
|
46
|
+
const fieldLines = fields.map((f) => {
|
|
47
|
+
const mapped = pgToGo(f.type, false)
|
|
48
|
+
for (const imp of mapped.imports) allImports.add(imp)
|
|
49
|
+
const jsonTag = `\`json:"${f.name}"\``
|
|
50
|
+
return `\t${toPascalCase(f.name)} ${mapped.type} ${jsonTag}`
|
|
51
|
+
})
|
|
52
|
+
structBlocks.push(`type ${typeName} struct {\n${fieldLines.join('\n')}\n}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Helper to resolve Go type for a column
|
|
56
|
+
function resolveGoType(col: any): string {
|
|
57
|
+
if (col.typeOverride) return col.typeOverride
|
|
58
|
+
if (col.category === 'enum' && col.enumValues?.length) {
|
|
59
|
+
return col.nullable ? `*${toPascalCase(col.pgType)}` : toPascalCase(col.pgType)
|
|
60
|
+
}
|
|
61
|
+
if (col.category === 'composite' && col.compositeFields?.length) {
|
|
62
|
+
return col.nullable ? `*${toPascalCase(col.pgType)}` : toPascalCase(col.pgType)
|
|
63
|
+
}
|
|
64
|
+
const mapped = pgToGo(col.pgType, col.nullable, col.category)
|
|
65
|
+
for (const imp of mapped.imports) allImports.add(imp)
|
|
66
|
+
return mapped.type
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const table of activeTables(schema)) {
|
|
70
|
+
const fields: string[] = []
|
|
71
|
+
|
|
72
|
+
for (const col of table.columns) {
|
|
73
|
+
const goType = resolveGoType(col)
|
|
74
|
+
|
|
75
|
+
const jsonTag = col.nullable ? `\`json:"${col.name},omitempty"\`` : `\`json:"${col.name}"\``
|
|
76
|
+
|
|
77
|
+
fields.push(`\t${col.pascalName} ${goType} ${jsonTag}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
structBlocks.push(`type ${table.pascalName} struct {\n${fields.join('\n')}\n}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Views (read-only)
|
|
84
|
+
for (const view of schema.views.filter((v) => !v.skipped)) {
|
|
85
|
+
const fields: string[] = []
|
|
86
|
+
|
|
87
|
+
for (const col of view.columns) {
|
|
88
|
+
const goType = resolveGoType(col)
|
|
89
|
+
|
|
90
|
+
const jsonTag = col.nullable ? `\`json:"${col.name},omitempty"\`` : `\`json:"${col.name}"\``
|
|
91
|
+
|
|
92
|
+
fields.push(`\t${col.pascalName} ${goType} ${jsonTag}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
structBlocks.push(
|
|
96
|
+
`// ${view.pascalName} is read-only (from view)\ntype ${view.pascalName} struct {\n${fields.join('\n')}\n}`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let importBlock = ''
|
|
101
|
+
if (allImports.size > 0) {
|
|
102
|
+
const sorted = [...allImports].sort()
|
|
103
|
+
if (sorted.length === 1) {
|
|
104
|
+
importBlock = `import "${sorted[0]}"\n\n`
|
|
105
|
+
} else {
|
|
106
|
+
importBlock = `import (\n${sorted.map((i) => `\t"${i}"`).join('\n')}\n)\n\n`
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const content = `// Generated by @sqldoc/templates/go-structs -- DO NOT EDIT
|
|
111
|
+
package models
|
|
112
|
+
|
|
113
|
+
${importBlock}${structBlocks.join('\n\n')}\n`
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
files: [{ path: 'models.go', content }],
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
FROM golang:1.24-alpine
|
|
2
|
+
WORKDIR /app
|
|
3
|
+
# Set up Go module with models as a sub-package
|
|
4
|
+
RUN go mod init sqldoc-test
|
|
5
|
+
RUN mkdir -p models cmd/test
|
|
6
|
+
COPY models.go models/
|
|
7
|
+
COPY test.go cmd/test/main.go
|
|
8
|
+
ENV GOTOOLCHAIN=auto
|
|
9
|
+
RUN go get github.com/jackc/pgx/v5 && go mod tidy
|
|
10
|
+
# Step 1: typecheck/compile all packages
|
|
11
|
+
RUN go build -o /usr/local/bin/test-runner ./cmd/test
|
|
12
|
+
# Step 2: run integration test against real DB
|
|
13
|
+
CMD ["test-runner"]
|