@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.
Files changed (108) hide show
  1. package/package.json +161 -0
  2. package/src/__tests__/dedent.test.ts +45 -0
  3. package/src/__tests__/docker-templates.test.ts +134 -0
  4. package/src/__tests__/go-structs.test.ts +184 -0
  5. package/src/__tests__/naming.test.ts +48 -0
  6. package/src/__tests__/python-dataclasses.test.ts +185 -0
  7. package/src/__tests__/rust-structs.test.ts +176 -0
  8. package/src/__tests__/tags-helpers.test.ts +72 -0
  9. package/src/__tests__/type-mapping.test.ts +332 -0
  10. package/src/__tests__/typescript.test.ts +202 -0
  11. package/src/cobol-copybook/index.ts +220 -0
  12. package/src/cobol-copybook/test/.gitignore +6 -0
  13. package/src/cobol-copybook/test/Dockerfile +7 -0
  14. package/src/csharp-records/index.ts +131 -0
  15. package/src/csharp-records/test/.gitignore +6 -0
  16. package/src/csharp-records/test/Dockerfile +6 -0
  17. package/src/diesel/index.ts +247 -0
  18. package/src/diesel/test/.gitignore +6 -0
  19. package/src/diesel/test/Dockerfile +16 -0
  20. package/src/drizzle/index.ts +255 -0
  21. package/src/drizzle/test/.gitignore +6 -0
  22. package/src/drizzle/test/Dockerfile +8 -0
  23. package/src/drizzle/test/test.ts +71 -0
  24. package/src/efcore/index.ts +190 -0
  25. package/src/efcore/test/.gitignore +6 -0
  26. package/src/efcore/test/Dockerfile +7 -0
  27. package/src/go-structs/index.ts +119 -0
  28. package/src/go-structs/test/.gitignore +6 -0
  29. package/src/go-structs/test/Dockerfile +13 -0
  30. package/src/go-structs/test/test.go +71 -0
  31. package/src/gorm/index.ts +134 -0
  32. package/src/gorm/test/.gitignore +6 -0
  33. package/src/gorm/test/Dockerfile +13 -0
  34. package/src/gorm/test/test.go +65 -0
  35. package/src/helpers/atlas.ts +43 -0
  36. package/src/helpers/enrich.ts +396 -0
  37. package/src/helpers/naming.ts +19 -0
  38. package/src/helpers/tags.ts +63 -0
  39. package/src/index.ts +24 -0
  40. package/src/java-records/index.ts +179 -0
  41. package/src/java-records/test/.gitignore +6 -0
  42. package/src/java-records/test/Dockerfile +11 -0
  43. package/src/java-records/test/Test.java +93 -0
  44. package/src/jpa/index.ts +279 -0
  45. package/src/jpa/test/.gitignore +6 -0
  46. package/src/jpa/test/Dockerfile +14 -0
  47. package/src/jpa/test/Test.java +111 -0
  48. package/src/json-schema/index.ts +351 -0
  49. package/src/json-schema/test/.gitignore +6 -0
  50. package/src/json-schema/test/Dockerfile +18 -0
  51. package/src/knex/index.ts +168 -0
  52. package/src/knex/test/.gitignore +6 -0
  53. package/src/knex/test/Dockerfile +7 -0
  54. package/src/knex/test/test.ts +75 -0
  55. package/src/kotlin-data/index.ts +147 -0
  56. package/src/kotlin-data/test/.gitignore +6 -0
  57. package/src/kotlin-data/test/Dockerfile +14 -0
  58. package/src/kotlin-data/test/Test.kt +82 -0
  59. package/src/kysely/index.ts +165 -0
  60. package/src/kysely/test/.gitignore +6 -0
  61. package/src/kysely/test/Dockerfile +8 -0
  62. package/src/kysely/test/test.ts +82 -0
  63. package/src/prisma/index.ts +387 -0
  64. package/src/prisma/test/.gitignore +6 -0
  65. package/src/prisma/test/Dockerfile +7 -0
  66. package/src/protobuf/index.ts +219 -0
  67. package/src/protobuf/test/.gitignore +6 -0
  68. package/src/protobuf/test/Dockerfile +6 -0
  69. package/src/pydantic/index.ts +272 -0
  70. package/src/pydantic/test/.gitignore +6 -0
  71. package/src/pydantic/test/Dockerfile +8 -0
  72. package/src/pydantic/test/test.py +63 -0
  73. package/src/python-dataclasses/index.ts +217 -0
  74. package/src/python-dataclasses/test/.gitignore +6 -0
  75. package/src/python-dataclasses/test/Dockerfile +8 -0
  76. package/src/python-dataclasses/test/test.py +63 -0
  77. package/src/rust-structs/index.ts +152 -0
  78. package/src/rust-structs/test/.gitignore +6 -0
  79. package/src/rust-structs/test/Dockerfile +22 -0
  80. package/src/rust-structs/test/test.rs +82 -0
  81. package/src/sqlalchemy/index.ts +258 -0
  82. package/src/sqlalchemy/test/.gitignore +6 -0
  83. package/src/sqlalchemy/test/Dockerfile +8 -0
  84. package/src/sqlalchemy/test/test.py +61 -0
  85. package/src/sqlc/index.ts +148 -0
  86. package/src/sqlc/test/.gitignore +6 -0
  87. package/src/sqlc/test/Dockerfile +13 -0
  88. package/src/sqlc/test/test.go +91 -0
  89. package/src/tags/dedent.ts +28 -0
  90. package/src/tags/index.ts +14 -0
  91. package/src/types/index.ts +8 -0
  92. package/src/types/pg-to-csharp.ts +136 -0
  93. package/src/types/pg-to-go.ts +120 -0
  94. package/src/types/pg-to-java.ts +141 -0
  95. package/src/types/pg-to-kotlin.ts +119 -0
  96. package/src/types/pg-to-python.ts +120 -0
  97. package/src/types/pg-to-rust.ts +121 -0
  98. package/src/types/pg-to-ts.ts +173 -0
  99. package/src/typescript/index.ts +168 -0
  100. package/src/typescript/test/.gitignore +6 -0
  101. package/src/typescript/test/Dockerfile +8 -0
  102. package/src/typescript/test/test.ts +89 -0
  103. package/src/xsd/index.ts +191 -0
  104. package/src/xsd/test/.gitignore +6 -0
  105. package/src/xsd/test/Dockerfile +6 -0
  106. package/src/zod/index.ts +289 -0
  107. package/src/zod/test/.gitignore +6 -0
  108. package/src/zod/test/Dockerfile +6 -0
@@ -0,0 +1,387 @@
1
+ import { defineTemplate } from '@sqldoc/ns-codegen'
2
+ import { activeTables, type EnrichedTable, enrichRealm } from '../helpers/enrich.ts'
3
+ import { toPascalCase } from '../helpers/naming.ts'
4
+
5
+ export const configSchema = {
6
+ provider: {
7
+ type: 'string',
8
+ description: 'Database provider (default: postgresql)',
9
+ },
10
+ } as const
11
+
12
+ /** Map a PostgreSQL type to a Prisma type */
13
+ function pgToPrisma(pgType: string): string {
14
+ const normalized = pgType.toLowerCase().trim()
15
+
16
+ // Handle arrays
17
+ if (normalized.endsWith('[]')) {
18
+ return `${pgToPrisma(normalized.slice(0, -2))}[]`
19
+ }
20
+ if (normalized.startsWith('_')) {
21
+ return `${pgToPrisma(normalized.slice(1))}[]`
22
+ }
23
+
24
+ // Strip length specifiers
25
+ const baseType = normalized.replace(/\(\d+(?:,\s*\d+)?\)/, '').trim()
26
+
27
+ const map: Record<string, string> = {
28
+ // Numeric
29
+ smallint: 'Int',
30
+ int2: 'Int',
31
+ integer: 'Int',
32
+ int: 'Int',
33
+ int4: 'Int',
34
+ bigint: 'BigInt',
35
+ int8: 'BigInt',
36
+ serial: 'Int',
37
+ serial4: 'Int',
38
+ bigserial: 'BigInt',
39
+ serial8: 'BigInt',
40
+ smallserial: 'Int',
41
+ serial2: 'Int',
42
+ real: 'Float',
43
+ float4: 'Float',
44
+ 'double precision': 'Float',
45
+ float8: 'Float',
46
+ numeric: 'Decimal',
47
+ decimal: 'Decimal',
48
+ money: 'String',
49
+
50
+ // String
51
+ text: 'String',
52
+ varchar: 'String',
53
+ 'character varying': 'String',
54
+ char: 'String',
55
+ character: 'String',
56
+ name: 'String',
57
+ citext: 'String',
58
+
59
+ // Boolean
60
+ boolean: 'Boolean',
61
+ bool: 'Boolean',
62
+
63
+ // Date/Time
64
+ timestamp: 'DateTime',
65
+ 'timestamp without time zone': 'DateTime',
66
+ timestamptz: 'DateTime',
67
+ 'timestamp with time zone': 'DateTime',
68
+ date: 'DateTime',
69
+ time: 'String',
70
+ 'time without time zone': 'String',
71
+ timetz: 'String',
72
+ 'time with time zone': 'String',
73
+ interval: 'String',
74
+
75
+ // Binary
76
+ bytea: 'Bytes',
77
+
78
+ // JSON
79
+ json: 'Json',
80
+ jsonb: 'Json',
81
+
82
+ // UUID
83
+ uuid: 'String',
84
+
85
+ // Network
86
+ inet: 'String',
87
+ cidr: 'String',
88
+ macaddr: 'String',
89
+ macaddr8: 'String',
90
+
91
+ // Other
92
+ xml: 'String',
93
+ tsvector: 'String',
94
+ tsquery: 'String',
95
+ oid: 'Int',
96
+ point: 'String',
97
+ line: 'String',
98
+ box: 'String',
99
+ circle: 'String',
100
+ polygon: 'String',
101
+ path: 'String',
102
+ }
103
+
104
+ return map[baseType] ?? 'String'
105
+ }
106
+
107
+ /** Check if column has a unique index */
108
+ function isUnique(table: EnrichedTable, colName: string): boolean {
109
+ return (
110
+ table.raw.indexes?.some(
111
+ (idx) => idx.unique === true && idx.parts?.length === 1 && idx.parts[0].column === colName,
112
+ ) ?? false
113
+ )
114
+ }
115
+
116
+ /** Format a Prisma default expression */
117
+ function formatPrismaDefault(defaultValue: string | undefined, isSerial: boolean): string | undefined {
118
+ if (isSerial) return '@default(autoincrement())'
119
+ if (!defaultValue) return undefined
120
+
121
+ if (defaultValue === 'now()' || defaultValue === 'CURRENT_TIMESTAMP') return '@default(now())'
122
+ if (defaultValue === 'gen_random_uuid()') return '@default(uuid())'
123
+ if (defaultValue === 'true' || defaultValue === 'false') return `@default(${defaultValue})`
124
+ if (/^\d+$/.test(defaultValue)) return `@default(${defaultValue})`
125
+
126
+ // Check if it looks like an expression (has parens)
127
+ if (defaultValue.includes('(')) return `@default(dbgenerated("${defaultValue}"))`
128
+
129
+ return `@default("${defaultValue}")`
130
+ }
131
+
132
+ export default defineTemplate({
133
+ name: 'Prisma Schema',
134
+ description: 'Generate Prisma schema models from SQL schema',
135
+ language: 'sql',
136
+ configSchema,
137
+
138
+ generate(ctx) {
139
+ const schema = enrichRealm(ctx)
140
+ const tables = activeTables(schema)
141
+
142
+ // Build model name map for relations
143
+ const modelNameMap = new Map<string, string>()
144
+ for (const table of tables) {
145
+ modelNameMap.set(table.name, table.pascalName)
146
+ }
147
+
148
+ const provider = (ctx.config as any)?.provider ?? 'postgresql'
149
+
150
+ // Pre-compute: count how many FK relations target each model, to decide if we need named relations
151
+ // Also track reverse relations that need to be added to target models
152
+ const reverseRelations = new Map<string, Array<{ fromTable: string; relName: string; needsName: boolean }>>()
153
+ // Count per-model how many FKs point to the same target, keyed by "sourceTable -> targetTable"
154
+ const fkCountByTarget = new Map<string, Map<string, number>>()
155
+
156
+ for (const table of tables) {
157
+ if (table.belongsTo.length > 0) {
158
+ const targetCounts = new Map<string, number>()
159
+ for (const rel of table.belongsTo) {
160
+ targetCounts.set(rel.foreignTable, (targetCounts.get(rel.foreignTable) ?? 0) + 1)
161
+ }
162
+ fkCountByTarget.set(table.name, targetCounts)
163
+ }
164
+ }
165
+
166
+ // Determine which relations need explicit names
167
+ interface FkRelation {
168
+ column: string
169
+ refTable: string
170
+ refColumn: string
171
+ constraintName: string
172
+ relName: string
173
+ relationName: string | undefined
174
+ }
175
+
176
+ const tableRelations = new Map<string, FkRelation[]>()
177
+
178
+ for (const table of tables) {
179
+ if (table.belongsTo.length === 0) continue
180
+
181
+ const targetCounts = fkCountByTarget.get(table.name) ?? new Map()
182
+ const relations: FkRelation[] = []
183
+
184
+ for (const rel of table.belongsTo) {
185
+ const relName = rel.column.replace(/_id$/, '')
186
+ // Need explicit relation name for self-relations or multiple FKs to the same target
187
+ const isSelfRelation = rel.foreignTable === table.name
188
+ const needsRelationName = isSelfRelation || (targetCounts.get(rel.foreignTable) ?? 0) > 1
189
+ const relationName = needsRelationName ? rel.constraintName || `${table.name}_${rel.column}` : undefined
190
+
191
+ relations.push({
192
+ column: rel.column,
193
+ refTable: rel.foreignTable,
194
+ refColumn: rel.foreignColumn,
195
+ constraintName: rel.constraintName,
196
+ relName,
197
+ relationName,
198
+ })
199
+
200
+ // Track reverse relation
201
+ if (!reverseRelations.has(rel.foreignTable)) {
202
+ reverseRelations.set(rel.foreignTable, [])
203
+ }
204
+ reverseRelations.get(rel.foreignTable)!.push({
205
+ fromTable: table.name,
206
+ relName: needsRelationName ? relName : (modelNameMap.get(table.name) ?? toPascalCase(table.name)),
207
+ needsName: needsRelationName,
208
+ })
209
+ }
210
+
211
+ tableRelations.set(table.name, relations)
212
+ }
213
+
214
+ const blocks: string[] = [
215
+ '// Generated by @sqldoc/templates/prisma -- DO NOT EDIT',
216
+ '',
217
+ `datasource db {`,
218
+ ` provider = "${provider}"`,
219
+ ` url = env("DATABASE_URL")`,
220
+ `}`,
221
+ '',
222
+ `generator client {`,
223
+ ` provider = "prisma-client-js"`,
224
+ `}`,
225
+ '',
226
+ ]
227
+
228
+ // Enums
229
+ for (const e of schema.enums) {
230
+ blocks.push(`enum ${e.pascalName} {`)
231
+ for (const val of e.values) {
232
+ blocks.push(` ${val}`)
233
+ }
234
+ blocks.push('}')
235
+ blocks.push('')
236
+ }
237
+
238
+ for (const table of tables) {
239
+ const modelName = table.pascalName
240
+ const fieldLines: string[] = []
241
+
242
+ // Detect composite primary key (more than one PK column)
243
+ const pkCols = table.columns.filter((c: any) => c.isPrimaryKey)
244
+ const isCompositePk = pkCols.length > 1
245
+
246
+ for (const col of table.columns) {
247
+ let prismaType: string
248
+ if (col.typeOverride) {
249
+ prismaType = col.typeOverride
250
+ } else if (col.category === 'enum' && col.enumValues?.length) {
251
+ prismaType = toPascalCase(col.pgType)
252
+ } else {
253
+ prismaType = pgToPrisma(col.pgType)
254
+ }
255
+
256
+ if (col.nullable && !prismaType.endsWith('[]')) {
257
+ prismaType += '?'
258
+ }
259
+
260
+ const attrs: string[] = []
261
+
262
+ // For composite PKs, use @@id at the model level instead of @id on each column
263
+ if (col.isPrimaryKey && !isCompositePk) {
264
+ attrs.push('@id')
265
+ }
266
+
267
+ const defaultAttr = formatPrismaDefault(col.defaultValue, col.isSerial)
268
+ if (defaultAttr) {
269
+ attrs.push(defaultAttr)
270
+ } else if (col.isSerial) {
271
+ attrs.push('@default(autoincrement())')
272
+ }
273
+
274
+ if (isUnique(table, col.name)) {
275
+ attrs.push('@unique')
276
+ }
277
+
278
+ // Map column name to Prisma field name + @map
279
+ const fieldName = col.name
280
+ const attrStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : ''
281
+ fieldLines.push(` ${fieldName} ${prismaType}${attrStr}`)
282
+ }
283
+
284
+ // Add relation fields for foreign keys on this table
285
+ const relations = tableRelations.get(table.name) ?? []
286
+ for (const rel of relations) {
287
+ const refModelName = modelNameMap.get(rel.refTable) ?? toPascalCase(rel.refTable)
288
+ const relNameAttr = rel.relationName ? `, name: "${rel.relationName}"` : ''
289
+ // If the FK column is nullable, the relation field must also be optional
290
+ const fkCol = table.columns.find((c: any) => c.name === rel.column)
291
+ const optionalMark = fkCol?.nullable ? '?' : ''
292
+ fieldLines.push(
293
+ ` ${rel.relName} ${refModelName}${optionalMark} @relation(fields: [${rel.column}], references: [${rel.refColumn}]${relNameAttr})`,
294
+ )
295
+ }
296
+
297
+ // Add reverse relation fields (other models that reference this one)
298
+ const reverseRels = reverseRelations.get(table.name) ?? []
299
+ // Group by source table to handle naming
300
+ const reverseBySource = new Map<string, typeof reverseRels>()
301
+ for (const rev of reverseRels) {
302
+ if (!reverseBySource.has(rev.fromTable)) {
303
+ reverseBySource.set(rev.fromTable, [])
304
+ }
305
+ reverseBySource.get(rev.fromTable)!.push(rev)
306
+ }
307
+
308
+ for (const [sourceTable, rels] of reverseBySource) {
309
+ const sourceModelName = modelNameMap.get(sourceTable) ?? toPascalCase(sourceTable)
310
+ if (rels.length === 1 && !rels[0].needsName) {
311
+ // Simple reverse: just add ModelName[]
312
+ const fieldName = sourceTable
313
+ fieldLines.push(` ${fieldName} ${sourceModelName}[]`)
314
+ } else {
315
+ // Multiple relations from same source: need named relations
316
+ for (const rel of rels) {
317
+ const sourceRelations = tableRelations.get(sourceTable) ?? []
318
+ const matchingRel = sourceRelations.find((r) => r.relName === rel.relName)
319
+ const relationName = matchingRel?.relationName
320
+ const relNameAttr = relationName ? `(name: "${relationName}")` : ''
321
+ const fieldName = `${rel.relName}_${sourceTable}`
322
+ fieldLines.push(` ${fieldName} ${sourceModelName}[] @relation${relNameAttr}`)
323
+ }
324
+ }
325
+ }
326
+
327
+ // Add @@id for composite primary keys
328
+ if (isCompositePk) {
329
+ fieldLines.push('')
330
+ fieldLines.push(` @@id([${pkCols.map((c: any) => c.name).join(', ')}])`)
331
+ }
332
+
333
+ blocks.push(`model ${modelName} {`)
334
+ blocks.push(fieldLines.join('\n'))
335
+ blocks.push('}')
336
+ blocks.push('')
337
+ }
338
+
339
+ // Views (read-only — represented as Prisma models with @@map)
340
+ for (const view of schema.views.filter((v) => !v.skipped)) {
341
+ const fieldLines: string[] = []
342
+
343
+ // Views need a dummy @id — use the first column as a stand-in
344
+ const firstCol = view.columns[0]
345
+
346
+ for (const col of view.columns) {
347
+ let prismaType: string
348
+ if (col.typeOverride) {
349
+ prismaType = col.typeOverride
350
+ } else if (col.category === 'enum' && col.enumValues?.length) {
351
+ prismaType = toPascalCase(col.pgType)
352
+ } else {
353
+ prismaType = pgToPrisma(col.pgType)
354
+ }
355
+
356
+ if (col.nullable && !prismaType.endsWith('[]')) {
357
+ prismaType += '?'
358
+ }
359
+
360
+ const attrs: string[] = []
361
+ if (firstCol && col.name === firstCol.name) {
362
+ attrs.push('@id')
363
+ }
364
+ const attrStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : ''
365
+ fieldLines.push(` ${col.name} ${prismaType}${attrStr}`)
366
+ }
367
+
368
+ fieldLines.push('')
369
+ fieldLines.push(` @@map("${view.name}")`)
370
+
371
+ blocks.push(`/// Read-only (from view)`)
372
+ blocks.push(`model ${view.pascalName} {`)
373
+ blocks.push(fieldLines.join('\n'))
374
+ blocks.push('}')
375
+ blocks.push('')
376
+ }
377
+
378
+ return {
379
+ files: [
380
+ {
381
+ path: 'schema.prisma',
382
+ content: blocks.join('\n'),
383
+ },
384
+ ],
385
+ }
386
+ },
387
+ })
@@ -0,0 +1,6 @@
1
+ # Generated by codegen — only Dockerfile and test scripts are tracked
2
+ *
3
+ !.gitignore
4
+ !Dockerfile
5
+ !test.*
6
+ !Test.*
@@ -0,0 +1,7 @@
1
+ FROM node:23-slim
2
+ WORKDIR /app
3
+ COPY . .
4
+ RUN npm init -y && npm install prisma@6 --save-dev
5
+ ENV DATABASE_URL="postgresql://user:pass@localhost:5432/db"
6
+ RUN npx prisma validate --schema=schema.prisma
7
+ CMD ["echo", "ok"]
@@ -0,0 +1,219 @@
1
+ import { defineTemplate } from '@sqldoc/ns-codegen'
2
+ import { activeTables, enrichRealm } from '../helpers/enrich.ts'
3
+ import { toPascalCase, toScreamingSnake } from '../helpers/naming.ts'
4
+
5
+ const PG_TO_PROTO: Record<string, string> = {
6
+ smallint: 'int32',
7
+ int2: 'int32',
8
+ integer: 'int32',
9
+ int: 'int32',
10
+ int4: 'int32',
11
+ bigint: 'int64',
12
+ int8: 'int64',
13
+ serial: 'int32',
14
+ serial4: 'int32',
15
+ bigserial: 'int64',
16
+ serial8: 'int64',
17
+ smallserial: 'int32',
18
+ serial2: 'int32',
19
+ real: 'float',
20
+ float4: 'float',
21
+ 'double precision': 'double',
22
+ float8: 'double',
23
+ numeric: 'string',
24
+ decimal: 'string',
25
+ money: 'string',
26
+ text: 'string',
27
+ varchar: 'string',
28
+ 'character varying': 'string',
29
+ char: 'string',
30
+ character: 'string',
31
+ name: 'string',
32
+ citext: 'string',
33
+ boolean: 'bool',
34
+ bool: 'bool',
35
+ timestamp: 'google.protobuf.Timestamp',
36
+ 'timestamp without time zone': 'google.protobuf.Timestamp',
37
+ timestamptz: 'google.protobuf.Timestamp',
38
+ 'timestamp with time zone': 'google.protobuf.Timestamp',
39
+ date: 'string',
40
+ time: 'string',
41
+ 'time without time zone': 'string',
42
+ timetz: 'string',
43
+ interval: 'string',
44
+ bytea: 'bytes',
45
+ json: 'string',
46
+ jsonb: 'string',
47
+ uuid: 'string',
48
+ inet: 'string',
49
+ xml: 'string',
50
+ }
51
+
52
+ function pgToProto(pgType: string): { type: string; repeated: boolean } {
53
+ const normalized = pgType.toLowerCase().trim()
54
+
55
+ if (normalized.endsWith('[]')) {
56
+ return { type: pgToProto(normalized.slice(0, -2)).type, repeated: true }
57
+ }
58
+ if (normalized.startsWith('_')) {
59
+ return { type: pgToProto(normalized.slice(1)).type, repeated: true }
60
+ }
61
+
62
+ const base = normalized.replace(/\(\d+(?:,\s*\d+)?\)/, '').trim()
63
+ return { type: PG_TO_PROTO[base] ?? 'string', repeated: false }
64
+ }
65
+
66
+ export default defineTemplate({
67
+ name: 'Protocol Buffers',
68
+ description: 'Generate Protocol Buffers message definitions from SQL schema',
69
+ language: 'protobuf',
70
+
71
+ generate(ctx) {
72
+ const schema = enrichRealm(ctx)
73
+ const allColumns = [...schema.tables.flatMap((t) => t.columns), ...schema.views.flatMap((v) => v.columns)]
74
+ const needsTimestamp = allColumns.some((c) => {
75
+ const base = c.pgType
76
+ .toLowerCase()
77
+ .replace(/\(\d+(?:,\s*\d+)?\)/, '')
78
+ .trim()
79
+ return base.startsWith('timestamp')
80
+ })
81
+
82
+ const lines: string[] = [
83
+ '// Generated by @sqldoc/templates/protobuf -- DO NOT EDIT',
84
+ 'syntax = "proto3";',
85
+ '',
86
+ 'package schema;',
87
+ '',
88
+ ]
89
+
90
+ if (needsTimestamp) {
91
+ lines.push('import "google/protobuf/timestamp.proto";')
92
+ lines.push('')
93
+ }
94
+
95
+ // Enums
96
+ for (const e of schema.enums) {
97
+ const enumName = toPascalCase(e.name)
98
+ lines.push(`enum ${enumName} {`)
99
+ lines.push(` ${toScreamingSnake(e.name)}_UNSPECIFIED = 0;`)
100
+ e.values.forEach((v, i) => {
101
+ lines.push(` ${toScreamingSnake(e.name)}_${toScreamingSnake(v)} = ${i + 1};`)
102
+ })
103
+ lines.push('}')
104
+ lines.push('')
105
+ }
106
+
107
+ // Composite types as messages
108
+ const composites = new Map<string, Array<{ name: string; type: string }>>()
109
+ for (const table of schema.tables) {
110
+ for (const col of table.columns) {
111
+ if (col.category === 'composite' && col.compositeFields?.length && !composites.has(col.pgType)) {
112
+ composites.set(col.pgType, col.compositeFields)
113
+ }
114
+ }
115
+ }
116
+ for (const [name, fields] of composites) {
117
+ const msgName = toPascalCase(name)
118
+ lines.push(`message ${msgName} {`)
119
+ let fieldNum = 1
120
+ for (const f of fields) {
121
+ const { type, repeated } = pgToProto(f.type)
122
+ const prefix = repeated ? 'repeated ' : ''
123
+ lines.push(` ${prefix}${type} ${f.name} = ${fieldNum};`)
124
+ fieldNum++
125
+ }
126
+ lines.push('}')
127
+ lines.push('')
128
+ }
129
+
130
+ for (const table of activeTables(schema)) {
131
+ lines.push(`message ${table.pascalName} {`)
132
+
133
+ let fieldNum = 1
134
+ for (const col of table.columns) {
135
+ if (col.category === 'enum' && col.enumValues?.length) {
136
+ const enumType = toPascalCase(col.pgType)
137
+ const prefix = col.nullable ? 'optional ' : ''
138
+ lines.push(` ${prefix}${enumType} ${col.name} = ${fieldNum};`)
139
+ } else if (col.category === 'composite' && col.compositeFields?.length) {
140
+ const compositeType = toPascalCase(col.pgType)
141
+ const prefix = col.nullable ? 'optional ' : ''
142
+ lines.push(` ${prefix}${compositeType} ${col.name} = ${fieldNum};`)
143
+ } else {
144
+ const { type, repeated } = pgToProto(col.pgType)
145
+ const prefix = repeated ? 'repeated ' : col.nullable ? 'optional ' : ''
146
+ lines.push(` ${prefix}${type} ${col.name} = ${fieldNum};`)
147
+ }
148
+ fieldNum++
149
+ }
150
+
151
+ lines.push('}')
152
+ lines.push('')
153
+ }
154
+
155
+ // Views (read-only)
156
+ for (const view of schema.views.filter((v) => !v.skipped)) {
157
+ lines.push(`// Read-only (from view)`)
158
+ lines.push(`message ${view.pascalName} {`)
159
+
160
+ let fieldNum = 1
161
+ for (const col of view.columns) {
162
+ if (col.category === 'enum' && col.enumValues?.length) {
163
+ const enumType = toPascalCase(col.pgType)
164
+ const prefix = col.nullable ? 'optional ' : ''
165
+ lines.push(` ${prefix}${enumType} ${col.name} = ${fieldNum};`)
166
+ } else if (col.category === 'composite' && col.compositeFields?.length) {
167
+ const compositeType = toPascalCase(col.pgType)
168
+ const prefix = col.nullable ? 'optional ' : ''
169
+ lines.push(` ${prefix}${compositeType} ${col.name} = ${fieldNum};`)
170
+ } else {
171
+ const { type, repeated } = pgToProto(col.pgType)
172
+ const prefix = repeated ? 'repeated ' : col.nullable ? 'optional ' : ''
173
+ lines.push(` ${prefix}${type} ${col.name} = ${fieldNum};`)
174
+ }
175
+ fieldNum++
176
+ }
177
+
178
+ lines.push('}')
179
+ lines.push('')
180
+ }
181
+
182
+ // Functions (skip trigger functions)
183
+ for (const fn of schema.functions) {
184
+ const retRaw = fn.returnType?.type?.toLowerCase() ?? ''
185
+ if (retRaw === 'trigger') continue
186
+
187
+ // Request message
188
+ const reqName = `${fn.pascalName}Request`
189
+ lines.push(`message ${reqName} {`)
190
+ let fieldNum = 1
191
+ for (const a of fn.args.filter((a) => !a.name?.startsWith('_') && (a as any).mode !== 'OUT')) {
192
+ const { type, repeated } = pgToProto(a.type)
193
+ const prefix = repeated ? 'repeated ' : ''
194
+ lines.push(` ${prefix}${type} ${a.name || 'arg'} = ${fieldNum};`)
195
+ fieldNum++
196
+ }
197
+ lines.push('}')
198
+ lines.push('')
199
+
200
+ // Response message
201
+ const respName = `${fn.pascalName}Response`
202
+ lines.push(`message ${respName} {`)
203
+ if (retRaw.startsWith('setof ')) {
204
+ const tableName = retRaw.replace('setof ', '')
205
+ const table = schema.tables.find((t) => t.name === tableName)
206
+ const retType = table ? table.pascalName : toPascalCase(tableName)
207
+ lines.push(` repeated ${retType} results = 1;`)
208
+ } else if (fn.returnType) {
209
+ const { type, repeated } = pgToProto(fn.returnType.type)
210
+ const prefix = repeated ? 'repeated ' : ''
211
+ lines.push(` ${prefix}${type} result = 1;`)
212
+ }
213
+ lines.push('}')
214
+ lines.push('')
215
+ }
216
+
217
+ return { files: [{ path: 'schema.proto', content: lines.join('\n') }] }
218
+ },
219
+ })
@@ -0,0 +1,6 @@
1
+ # Generated by codegen — only Dockerfile and test scripts are tracked
2
+ *
3
+ !.gitignore
4
+ !Dockerfile
5
+ !test.*
6
+ !Test.*
@@ -0,0 +1,6 @@
1
+ FROM alpine:3.20
2
+ RUN apk add --no-cache protobuf protobuf-dev
3
+ WORKDIR /app
4
+ COPY . .
5
+ RUN protoc --proto_path=/usr/include --proto_path=. --descriptor_set_out=/dev/null schema.proto
6
+ CMD ["echo", "ok"]