@leonardovida-md/drizzle-neo-duckdb 1.0.0

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.
@@ -0,0 +1,853 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import type { RowData } from './client.ts';
3
+ import type { DuckDBDatabase } from './driver.ts';
4
+
5
+ const SYSTEM_SCHEMAS = new Set(['information_schema', 'pg_catalog']);
6
+
7
+ export interface IntrospectOptions {
8
+ schemas?: string[];
9
+ includeViews?: boolean;
10
+ useCustomTimeTypes?: boolean;
11
+ mapJsonAsDuckDbJson?: boolean;
12
+ importBasePath?: string;
13
+ }
14
+
15
+ interface DuckDbTableRow extends RowData {
16
+ schema_name: string;
17
+ table_name: string;
18
+ table_type: string;
19
+ }
20
+
21
+ interface DuckDbColumnRow extends RowData {
22
+ schema_name: string;
23
+ table_name: string;
24
+ column_name: string;
25
+ column_index: number;
26
+ column_default: string | null;
27
+ is_nullable: boolean;
28
+ data_type: string;
29
+ character_maximum_length: number | null;
30
+ numeric_precision: number | null;
31
+ numeric_scale: number | null;
32
+ internal: boolean | null;
33
+ }
34
+
35
+ interface DuckDbConstraintRow extends RowData {
36
+ schema_name: string;
37
+ table_name: string;
38
+ constraint_name: string;
39
+ constraint_type: string;
40
+ constraint_text: string | null;
41
+ constraint_column_names: string[] | null;
42
+ referenced_table: string | null;
43
+ referenced_column_names: string[] | null;
44
+ }
45
+
46
+ interface DuckDbIndexRow extends RowData {
47
+ schema_name: string;
48
+ table_name: string;
49
+ index_name: string;
50
+ is_unique: boolean | null;
51
+ expressions: string | null;
52
+ }
53
+
54
+ export interface IntrospectedColumn {
55
+ name: string;
56
+ dataType: string;
57
+ columnDefault: string | null;
58
+ nullable: boolean;
59
+ characterLength: number | null;
60
+ numericPrecision: number | null;
61
+ numericScale: number | null;
62
+ }
63
+
64
+ export interface IntrospectedConstraint {
65
+ name: string;
66
+ type: string;
67
+ columns: string[];
68
+ referencedTable?: {
69
+ name: string;
70
+ schema: string;
71
+ columns: string[];
72
+ };
73
+ rawExpression?: string | null;
74
+ }
75
+
76
+ export interface IntrospectedTable {
77
+ schema: string;
78
+ name: string;
79
+ kind: 'table' | 'view';
80
+ columns: IntrospectedColumn[];
81
+ constraints: IntrospectedConstraint[];
82
+ indexes: DuckDbIndexRow[];
83
+ }
84
+
85
+ export interface IntrospectResult {
86
+ files: {
87
+ schemaTs: string;
88
+ metaJson: IntrospectedTable[];
89
+ relationsTs?: string;
90
+ };
91
+ }
92
+
93
+ type ImportBuckets = {
94
+ drizzle: Set<string>;
95
+ pgCore: Set<string>;
96
+ local: Set<string>;
97
+ };
98
+
99
+ const DEFAULT_IMPORT_BASE = '@leonardovida-md/drizzle-neo-duckdb';
100
+
101
+ export async function introspect(
102
+ db: DuckDBDatabase,
103
+ opts: IntrospectOptions = {}
104
+ ): Promise<IntrospectResult> {
105
+ const schemas = await resolveSchemas(db, opts.schemas);
106
+ const includeViews = opts.includeViews ?? false;
107
+
108
+ const tables = await loadTables(db, schemas, includeViews);
109
+ const columns = await loadColumns(db, schemas);
110
+ const constraints = await loadConstraints(db, schemas);
111
+ const indexes = await loadIndexes(db, schemas);
112
+
113
+ const grouped = buildTables(tables, columns, constraints, indexes);
114
+
115
+ const schemaTs = emitSchema(grouped, {
116
+ useCustomTimeTypes: opts.useCustomTimeTypes ?? true,
117
+ mapJsonAsDuckDbJson: opts.mapJsonAsDuckDbJson ?? true,
118
+ importBasePath: opts.importBasePath ?? DEFAULT_IMPORT_BASE,
119
+ });
120
+
121
+ return {
122
+ files: {
123
+ schemaTs,
124
+ metaJson: grouped,
125
+ },
126
+ };
127
+ }
128
+
129
+ async function resolveSchemas(
130
+ db: DuckDBDatabase,
131
+ targetSchemas?: string[]
132
+ ): Promise<string[]> {
133
+ if (targetSchemas?.length) {
134
+ return targetSchemas;
135
+ }
136
+
137
+ const rows = await db.execute<{ schema_name: string }>(
138
+ sql`select schema_name from information_schema.schemata`
139
+ );
140
+
141
+ return rows
142
+ .map((row) => row.schema_name)
143
+ .filter((name) => !SYSTEM_SCHEMAS.has(name));
144
+ }
145
+
146
+ async function loadTables(
147
+ db: DuckDBDatabase,
148
+ schemas: string[],
149
+ includeViews: boolean
150
+ ): Promise<DuckDbTableRow[]> {
151
+ const schemaFragments = schemas.map((schema) => sql`${schema}`);
152
+
153
+ return await db.execute<DuckDbTableRow>(
154
+ sql`
155
+ select table_schema as schema_name, table_name, table_type
156
+ from information_schema.tables
157
+ where table_schema in (${sql.join(schemaFragments, sql.raw(', '))})
158
+ and ${includeViews ? sql`1 = 1` : sql`table_type = 'BASE TABLE'`}
159
+ order by table_schema, table_name
160
+ `
161
+ );
162
+ }
163
+
164
+ async function loadColumns(
165
+ db: DuckDBDatabase,
166
+ schemas: string[]
167
+ ): Promise<DuckDbColumnRow[]> {
168
+ const schemaFragments = schemas.map((schema) => sql`${schema}`);
169
+ return await db.execute<DuckDbColumnRow>(
170
+ sql`
171
+ select
172
+ schema_name,
173
+ table_name,
174
+ column_name,
175
+ column_index,
176
+ column_default,
177
+ is_nullable,
178
+ data_type,
179
+ character_maximum_length,
180
+ numeric_precision,
181
+ numeric_scale,
182
+ internal
183
+ from duckdb_columns()
184
+ where schema_name in (${sql.join(schemaFragments, sql.raw(', '))})
185
+ order by schema_name, table_name, column_index
186
+ `
187
+ );
188
+ }
189
+
190
+ async function loadConstraints(
191
+ db: DuckDBDatabase,
192
+ schemas: string[]
193
+ ): Promise<DuckDbConstraintRow[]> {
194
+ const schemaFragments = schemas.map((schema) => sql`${schema}`);
195
+ return await db.execute<DuckDbConstraintRow>(
196
+ sql`
197
+ select
198
+ schema_name,
199
+ table_name,
200
+ constraint_name,
201
+ constraint_type,
202
+ constraint_text,
203
+ constraint_column_names,
204
+ referenced_table,
205
+ referenced_column_names
206
+ from duckdb_constraints()
207
+ where schema_name in (${sql.join(schemaFragments, sql.raw(', '))})
208
+ order by schema_name, table_name, constraint_index
209
+ `
210
+ );
211
+ }
212
+
213
+ async function loadIndexes(
214
+ db: DuckDBDatabase,
215
+ schemas: string[]
216
+ ): Promise<DuckDbIndexRow[]> {
217
+ const schemaFragments = schemas.map((schema) => sql`${schema}`);
218
+ return await db.execute<DuckDbIndexRow>(
219
+ sql`
220
+ select
221
+ schema_name,
222
+ table_name,
223
+ index_name,
224
+ is_unique,
225
+ expressions
226
+ from duckdb_indexes()
227
+ where schema_name in (${sql.join(schemaFragments, sql.raw(', '))})
228
+ order by schema_name, table_name, index_name
229
+ `
230
+ );
231
+ }
232
+
233
+ function buildTables(
234
+ tables: DuckDbTableRow[],
235
+ columns: DuckDbColumnRow[],
236
+ constraints: DuckDbConstraintRow[],
237
+ indexes: DuckDbIndexRow[]
238
+ ): IntrospectedTable[] {
239
+ const byTable: Record<string, IntrospectedTable> = {};
240
+ for (const table of tables) {
241
+ const key = tableKey(table.schema_name, table.table_name);
242
+ byTable[key] = {
243
+ schema: table.schema_name,
244
+ name: table.table_name,
245
+ kind: table.table_type === 'VIEW' ? 'view' : 'table',
246
+ columns: [],
247
+ constraints: [],
248
+ indexes: [],
249
+ };
250
+ }
251
+
252
+ for (const column of columns) {
253
+ if (column.internal) {
254
+ continue;
255
+ }
256
+ const key = tableKey(column.schema_name, column.table_name);
257
+ const table = byTable[key];
258
+ if (!table) {
259
+ continue;
260
+ }
261
+ table.columns.push({
262
+ name: column.column_name,
263
+ dataType: column.data_type,
264
+ columnDefault: column.column_default,
265
+ nullable: column.is_nullable,
266
+ characterLength: column.character_maximum_length,
267
+ numericPrecision: column.numeric_precision,
268
+ numericScale: column.numeric_scale,
269
+ });
270
+ }
271
+
272
+ for (const constraint of constraints) {
273
+ const key = tableKey(constraint.schema_name, constraint.table_name);
274
+ const table = byTable[key];
275
+ if (!table) {
276
+ continue;
277
+ }
278
+ if (!constraint.constraint_column_names?.length) {
279
+ continue;
280
+ }
281
+ table.constraints.push({
282
+ name: constraint.constraint_name,
283
+ type: constraint.constraint_type,
284
+ columns: constraint.constraint_column_names ?? [],
285
+ referencedTable:
286
+ constraint.referenced_table && constraint.referenced_column_names
287
+ ? {
288
+ schema: constraint.schema_name,
289
+ name: constraint.referenced_table,
290
+ columns: constraint.referenced_column_names,
291
+ }
292
+ : undefined,
293
+ rawExpression: constraint.constraint_text,
294
+ });
295
+ }
296
+
297
+ for (const index of indexes) {
298
+ const key = tableKey(index.schema_name, index.table_name);
299
+ const table = byTable[key];
300
+ if (!table) {
301
+ continue;
302
+ }
303
+ table.indexes.push(index);
304
+ }
305
+
306
+ return Object.values(byTable);
307
+ }
308
+
309
+ interface EmitOptions {
310
+ useCustomTimeTypes: boolean;
311
+ mapJsonAsDuckDbJson: boolean;
312
+ importBasePath: string;
313
+ }
314
+
315
+ function emitSchema(
316
+ catalog: IntrospectedTable[],
317
+ options: EmitOptions
318
+ ): string {
319
+ const imports: ImportBuckets = {
320
+ drizzle: new Set(),
321
+ pgCore: new Set(),
322
+ local: new Set(),
323
+ };
324
+
325
+ imports.pgCore.add('pgSchema');
326
+
327
+ const sorted = [...catalog].sort((a, b) =>
328
+ a.schema === b.schema
329
+ ? a.name.localeCompare(b.name)
330
+ : a.schema.localeCompare(b.schema)
331
+ );
332
+
333
+ const lines: string[] = [];
334
+
335
+ for (const schema of uniqueSchemas(sorted)) {
336
+ imports.pgCore.add('pgSchema');
337
+ const schemaVar = toSchemaIdentifier(schema);
338
+ lines.push(
339
+ `export const ${schemaVar} = pgSchema(${JSON.stringify(schema)});`,
340
+ ''
341
+ );
342
+
343
+ const tables = sorted.filter((table) => table.schema === schema);
344
+ for (const table of tables) {
345
+ lines.push(...emitTable(schemaVar, table, imports, options));
346
+ lines.push('');
347
+ }
348
+ }
349
+
350
+ const importsBlock = renderImports(imports, options.importBasePath);
351
+ return [importsBlock, ...lines].join('\n').trim() + '\n';
352
+ }
353
+
354
+ function emitTable(
355
+ schemaVar: string,
356
+ table: IntrospectedTable,
357
+ imports: ImportBuckets,
358
+ options: EmitOptions
359
+ ): string[] {
360
+ const tableVar = toIdentifier(table.name);
361
+ const columnLines: string[] = [];
362
+ for (const column of table.columns) {
363
+ columnLines.push(
364
+ ` ${columnProperty(column.name)}: ${emitColumn(
365
+ column,
366
+ imports,
367
+ options
368
+ )},`
369
+ );
370
+ }
371
+
372
+ const constraintBlock = emitConstraints(table, imports);
373
+
374
+ const tableLines: string[] = [];
375
+ tableLines.push(
376
+ `export const ${tableVar} = ${schemaVar}.table(${JSON.stringify(
377
+ table.name
378
+ )}, {`
379
+ );
380
+ tableLines.push(...columnLines);
381
+ tableLines.push(
382
+ `}${constraintBlock ? ',' : ''}${constraintBlock ? ` ${constraintBlock}` : ''});`
383
+ );
384
+
385
+ return tableLines;
386
+ }
387
+
388
+ function emitConstraints(
389
+ table: IntrospectedTable,
390
+ imports: ImportBuckets
391
+ ): string {
392
+ const constraints = table.constraints.filter((constraint) =>
393
+ ['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE'].includes(constraint.type)
394
+ );
395
+ if (!constraints.length) {
396
+ return '';
397
+ }
398
+
399
+ const entries: string[] = [];
400
+
401
+ for (const constraint of constraints) {
402
+ const key = toIdentifier(constraint.name || `${table.name}_constraint`);
403
+ if (constraint.type === 'PRIMARY KEY') {
404
+ imports.pgCore.add('primaryKey');
405
+ entries.push(
406
+ `${key}: primaryKey({ columns: [${constraint.columns
407
+ .map((col) => `t.${toIdentifier(col)}`)
408
+ .join(', ')}], name: ${JSON.stringify(constraint.name)} })`
409
+ );
410
+ } else if (
411
+ constraint.type === 'UNIQUE' &&
412
+ constraint.columns.length > 1
413
+ ) {
414
+ imports.pgCore.add('unique');
415
+ entries.push(
416
+ `${key}: unique(${JSON.stringify(constraint.name)}).on(${constraint.columns
417
+ .map((col) => `t.${toIdentifier(col)}`)
418
+ .join(', ')})`
419
+ );
420
+ } else if (constraint.type === 'FOREIGN KEY' && constraint.referencedTable) {
421
+ imports.pgCore.add('foreignKey');
422
+ const targetTable = toIdentifier(constraint.referencedTable.name);
423
+ entries.push(
424
+ `${key}: foreignKey({ columns: [${constraint.columns
425
+ .map((col) => `t.${toIdentifier(col)}`)
426
+ .join(', ')}], foreignColumns: [${constraint.referencedTable.columns
427
+ .map((col) => `${targetTable}.${toIdentifier(col)}`)
428
+ .join(', ')}], name: ${JSON.stringify(constraint.name)} })`
429
+ );
430
+ } else if (
431
+ constraint.type === 'UNIQUE' &&
432
+ constraint.columns.length === 1
433
+ ) {
434
+ const columnName = constraint.columns[0];
435
+ entries.push(
436
+ `${key}: t.${toIdentifier(columnName)}.unique(${JSON.stringify(
437
+ constraint.name
438
+ )})`
439
+ );
440
+ }
441
+ }
442
+
443
+ if (!entries.length) {
444
+ return '';
445
+ }
446
+
447
+ const lines: string[] = ['(t) => ({'];
448
+ for (const entry of entries) {
449
+ lines.push(` ${entry},`);
450
+ }
451
+ lines.push('})');
452
+ return lines.join('\n');
453
+ }
454
+
455
+ interface ColumnEmitOptions extends EmitOptions {}
456
+
457
+ function emitColumn(
458
+ column: IntrospectedColumn,
459
+ imports: ImportBuckets,
460
+ options: ColumnEmitOptions
461
+ ): string {
462
+ const mapping = mapDuckDbType(column, imports, options);
463
+ let builder = mapping.builder;
464
+
465
+ if (!column.nullable) {
466
+ builder += '.notNull()';
467
+ }
468
+
469
+ const defaultFragment = buildDefault(column.columnDefault);
470
+ if (defaultFragment) {
471
+ imports.drizzle.add('sql');
472
+ builder += defaultFragment;
473
+ }
474
+
475
+ return builder;
476
+ }
477
+
478
+ function buildDefault(defaultValue: string | null): string {
479
+ if (!defaultValue) {
480
+ return '';
481
+ }
482
+ const trimmed = defaultValue.trim();
483
+ if (!trimmed || trimmed.toUpperCase() === 'NULL') {
484
+ return '';
485
+ }
486
+
487
+ if (/^nextval\(/i.test(trimmed)) {
488
+ return `.default(sql\`${trimmed}\`)`;
489
+ }
490
+ if (/^current_timestamp(?:\(\))?$/i.test(trimmed) || /^now\(\)$/i.test(trimmed)) {
491
+ return `.defaultNow()`;
492
+ }
493
+ if (trimmed === 'true' || trimmed === 'false') {
494
+ return `.default(${trimmed})`;
495
+ }
496
+ const numberValue = Number(trimmed);
497
+ if (!Number.isNaN(numberValue)) {
498
+ return `.default(${trimmed})`;
499
+ }
500
+ const stringLiteralMatch = /^'(.*)'$/.exec(trimmed);
501
+ if (stringLiteralMatch) {
502
+ const value = stringLiteralMatch[1]?.replace(/''/g, "'");
503
+ return `.default(${JSON.stringify(value)})`;
504
+ }
505
+
506
+ return '';
507
+ }
508
+
509
+ interface TypeMappingResult {
510
+ builder: string;
511
+ }
512
+
513
+ function mapDuckDbType(
514
+ column: IntrospectedColumn,
515
+ imports: ImportBuckets,
516
+ options: ColumnEmitOptions
517
+ ): TypeMappingResult {
518
+ const raw = column.dataType.trim();
519
+ const upper = raw.toUpperCase();
520
+
521
+ if (upper === 'BOOLEAN' || upper === 'BOOL') {
522
+ imports.pgCore.add('boolean');
523
+ return { builder: `boolean(${columnName(column.name)})` };
524
+ }
525
+
526
+ if (
527
+ upper === 'SMALLINT' ||
528
+ upper === 'INT2' ||
529
+ upper === 'INT16' ||
530
+ upper === 'TINYINT'
531
+ ) {
532
+ imports.pgCore.add('integer');
533
+ return { builder: `integer(${columnName(column.name)})` };
534
+ }
535
+
536
+ if (
537
+ upper === 'INTEGER' ||
538
+ upper === 'INT' ||
539
+ upper === 'INT4' ||
540
+ upper === 'SIGNED'
541
+ ) {
542
+ imports.pgCore.add('integer');
543
+ return { builder: `integer(${columnName(column.name)})` };
544
+ }
545
+
546
+ if (upper === 'BIGINT' || upper === 'INT8' || upper === 'UBIGINT') {
547
+ imports.pgCore.add('bigint');
548
+ return { builder: `bigint(${columnName(column.name)})` };
549
+ }
550
+
551
+ const decimalMatch = /^DECIMAL\((\d+),(\d+)\)/i.exec(upper);
552
+ const numericMatch = /^NUMERIC\((\d+),(\d+)\)/i.exec(upper);
553
+ if (decimalMatch || numericMatch) {
554
+ imports.pgCore.add('numeric');
555
+ const [, precision, scale] = decimalMatch ?? numericMatch!;
556
+ return {
557
+ builder: `numeric(${columnName(column.name)}, { precision: ${precision}, scale: ${scale} })`,
558
+ };
559
+ }
560
+
561
+ if (upper.startsWith('DECIMAL') || upper.startsWith('NUMERIC')) {
562
+ imports.pgCore.add('numeric');
563
+ const precision = column.numericPrecision;
564
+ const scale = column.numericScale;
565
+ const options: string[] = [];
566
+ if (precision !== null && precision !== undefined) {
567
+ options.push(`precision: ${precision}`);
568
+ }
569
+ if (scale !== null && scale !== undefined) {
570
+ options.push(`scale: ${scale}`);
571
+ }
572
+ const suffix = options.length ? `, { ${options.join(', ')} }` : '';
573
+ return { builder: `numeric(${columnName(column.name)}${suffix})` };
574
+ }
575
+
576
+ if (upper === 'REAL' || upper === 'FLOAT4') {
577
+ imports.pgCore.add('real');
578
+ return { builder: `real(${columnName(column.name)})` };
579
+ }
580
+
581
+ if (upper === 'DOUBLE' || upper === 'DOUBLE PRECISION' || upper === 'FLOAT') {
582
+ imports.pgCore.add('doublePrecision');
583
+ return { builder: `doublePrecision(${columnName(column.name)})` };
584
+ }
585
+
586
+ if (upper.startsWith('CHAR(') || upper === 'CHAR') {
587
+ imports.pgCore.add('char');
588
+ const length = column.characterLength;
589
+ const lengthPart =
590
+ typeof length === 'number' ? `, { length: ${length} }` : '';
591
+ return { builder: `char(${columnName(column.name)}${lengthPart})` };
592
+ }
593
+
594
+ if (upper.startsWith('VARCHAR')) {
595
+ imports.pgCore.add('varchar');
596
+ const length = column.characterLength;
597
+ const lengthPart =
598
+ typeof length === 'number' ? `, { length: ${length} }` : '';
599
+ return { builder: `varchar(${columnName(column.name)}${lengthPart})` };
600
+ }
601
+
602
+ if (upper === 'TEXT' || upper === 'STRING') {
603
+ imports.pgCore.add('text');
604
+ return { builder: `text(${columnName(column.name)})` };
605
+ }
606
+
607
+ if (upper === 'UUID') {
608
+ imports.pgCore.add('uuid');
609
+ return { builder: `uuid(${columnName(column.name)})` };
610
+ }
611
+
612
+ if (upper === 'JSON') {
613
+ if (options.mapJsonAsDuckDbJson) {
614
+ imports.local.add('duckDbJson');
615
+ return { builder: `duckDbJson(${columnName(column.name)})` };
616
+ }
617
+ imports.pgCore.add('text');
618
+ return { builder: `text(${columnName(column.name)}) /* JSON */` };
619
+ }
620
+
621
+ if (upper === 'INET') {
622
+ imports.local.add('duckDbInet');
623
+ return { builder: `duckDbInet(${columnName(column.name)})` };
624
+ }
625
+
626
+ if (upper === 'INTERVAL') {
627
+ imports.local.add('duckDbInterval');
628
+ return { builder: `duckDbInterval(${columnName(column.name)})` };
629
+ }
630
+
631
+ if (upper === 'BLOB' || upper === 'BYTEA' || upper === 'VARBINARY') {
632
+ imports.local.add('duckDbBlob');
633
+ return { builder: `duckDbBlob(${columnName(column.name)})` };
634
+ }
635
+
636
+ const arrayMatch = /^(.*)\[(\d+)\]$/.exec(upper);
637
+ if (arrayMatch) {
638
+ imports.local.add('duckDbArray');
639
+ const [, base, length] = arrayMatch;
640
+ return {
641
+ builder: `duckDbArray(${columnName(
642
+ column.name
643
+ )}, ${JSON.stringify(base)}, ${Number(length)})`,
644
+ };
645
+ }
646
+
647
+ const listMatch = /^(.*)\[\]$/.exec(upper);
648
+ if (listMatch) {
649
+ imports.local.add('duckDbList');
650
+ const [, base] = listMatch;
651
+ return {
652
+ builder: `duckDbList(${columnName(
653
+ column.name
654
+ )}, ${JSON.stringify(base)})`,
655
+ };
656
+ }
657
+
658
+ if (upper.startsWith('STRUCT')) {
659
+ imports.local.add('duckDbStruct');
660
+ const inner = upper.replace(/^STRUCT\s*\(/i, '').replace(/\)$/, '');
661
+ const fields = parseStructFields(inner);
662
+ const entries = fields.map(
663
+ ({ name, type }) => `${JSON.stringify(name)}: ${JSON.stringify(type)}`
664
+ );
665
+ return {
666
+ builder: `duckDbStruct(${columnName(
667
+ column.name
668
+ )}, { ${entries.join(', ')} })`,
669
+ };
670
+ }
671
+
672
+ if (upper.startsWith('MAP(')) {
673
+ imports.local.add('duckDbMap');
674
+ const valueType = parseMapValue(upper);
675
+ return {
676
+ builder: `duckDbMap(${columnName(
677
+ column.name
678
+ )}, ${JSON.stringify(valueType)})`,
679
+ };
680
+ }
681
+
682
+ if (upper.startsWith('TIMESTAMP WITH TIME ZONE')) {
683
+ if (options.useCustomTimeTypes) {
684
+ imports.local.add('duckDbTimestamp');
685
+ } else {
686
+ imports.pgCore.add('timestamp');
687
+ }
688
+ const factory = options.useCustomTimeTypes
689
+ ? `duckDbTimestamp(${columnName(column.name)}, { withTimezone: true })`
690
+ : `timestamp(${columnName(column.name)}, { withTimezone: true })`;
691
+ return { builder: factory };
692
+ }
693
+
694
+ if (upper.startsWith('TIMESTAMP')) {
695
+ if (options.useCustomTimeTypes) {
696
+ imports.local.add('duckDbTimestamp');
697
+ return {
698
+ builder: `duckDbTimestamp(${columnName(column.name)})`,
699
+ };
700
+ }
701
+ imports.pgCore.add('timestamp');
702
+ return { builder: `timestamp(${columnName(column.name)})` };
703
+ }
704
+
705
+ if (upper === 'TIME') {
706
+ if (options.useCustomTimeTypes) {
707
+ imports.local.add('duckDbTime');
708
+ return { builder: `duckDbTime(${columnName(column.name)})` };
709
+ }
710
+ imports.pgCore.add('time');
711
+ return { builder: `time(${columnName(column.name)})` };
712
+ }
713
+
714
+ if (upper === 'DATE') {
715
+ if (options.useCustomTimeTypes) {
716
+ imports.local.add('duckDbDate');
717
+ return { builder: `duckDbDate(${columnName(column.name)})` };
718
+ }
719
+ imports.pgCore.add('date');
720
+ return { builder: `date(${columnName(column.name)})` };
721
+ }
722
+
723
+ // Fallback: keep as text to avoid runtime failures.
724
+ imports.pgCore.add('text');
725
+ return {
726
+ builder: `text(${columnName(
727
+ column.name
728
+ )}) /* TODO: verify type ${upper} */`,
729
+ };
730
+ }
731
+
732
+ function parseStructFields(
733
+ inner: string
734
+ ): Array<{ name: string; type: string }> {
735
+ const result: Array<{ name: string; type: string }> = [];
736
+ for (const part of splitTopLevel(inner, ',')) {
737
+ const trimmed = part.trim();
738
+ if (!trimmed) continue;
739
+ const match = /^"?([^"]+)"?\s+(.*)$/i.exec(trimmed);
740
+ if (!match) {
741
+ continue;
742
+ }
743
+ const [, name, type] = match;
744
+ result.push({ name, type: type.trim() });
745
+ }
746
+ return result;
747
+ }
748
+
749
+ function parseMapValue(raw: string): string {
750
+ const inner = raw.replace(/^MAP\(/i, '').replace(/\)$/, '');
751
+ const parts = splitTopLevel(inner, ',');
752
+ if (parts.length < 2) {
753
+ return 'TEXT';
754
+ }
755
+ return parts[1]?.trim() ?? 'TEXT';
756
+ }
757
+
758
+ function splitTopLevel(input: string, delimiter: string): string[] {
759
+ const parts: string[] = [];
760
+ let depth = 0;
761
+ let current = '';
762
+ for (let i = 0; i < input.length; i += 1) {
763
+ const char = input[i]!;
764
+ if (char === '(') depth += 1;
765
+ if (char === ')') depth = Math.max(0, depth - 1);
766
+ if (char === delimiter && depth === 0) {
767
+ parts.push(current);
768
+ current = '';
769
+ continue;
770
+ }
771
+ current += char;
772
+ }
773
+ if (current) {
774
+ parts.push(current);
775
+ }
776
+ return parts;
777
+ }
778
+
779
+ function tableKey(schema: string, table: string): string {
780
+ return `${schema}.${table}`;
781
+ }
782
+
783
+ function toIdentifier(name: string): string {
784
+ const cleaned = name.replace(/[^A-Za-z0-9_]/g, '_');
785
+ const parts = cleaned.split('_').filter(Boolean);
786
+ const base = parts
787
+ .map((part, index) =>
788
+ index === 0 ? part.toLowerCase() : capitalize(part.toLowerCase())
789
+ )
790
+ .join('');
791
+ const candidate = base || 'item';
792
+ return /^[A-Za-z_]/.test(candidate) ? candidate : `t${candidate}`;
793
+ }
794
+
795
+ function toSchemaIdentifier(schema: string): string {
796
+ const base = toIdentifier(schema);
797
+ return base.endsWith('Schema') ? base : `${base}Schema`;
798
+ }
799
+
800
+ function columnProperty(column: string): string {
801
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
802
+ return toIdentifier(column);
803
+ }
804
+ return JSON.stringify(column);
805
+ }
806
+
807
+ function columnName(name: string): string {
808
+ return JSON.stringify(name);
809
+ }
810
+
811
+ function capitalize(value: string): string {
812
+ if (!value) return value;
813
+ return value[0]!.toUpperCase() + value.slice(1);
814
+ }
815
+
816
+ function uniqueSchemas(tables: IntrospectedTable[]): string[] {
817
+ const seen = new Set<string>();
818
+ const result: string[] = [];
819
+ for (const table of tables) {
820
+ if (!seen.has(table.schema)) {
821
+ seen.add(table.schema);
822
+ result.push(table.schema);
823
+ }
824
+ }
825
+ return result;
826
+ }
827
+
828
+ function renderImports(imports: ImportBuckets, importBasePath: string): string {
829
+ const lines: string[] = [];
830
+ const drizzle = [...imports.drizzle];
831
+ if (drizzle.length) {
832
+ lines.push(`import { ${drizzle.sort().join(', ')} } from 'drizzle-orm';`);
833
+ }
834
+
835
+ const pgCore = [...imports.pgCore];
836
+ if (pgCore.length) {
837
+ lines.push(
838
+ `import { ${pgCore
839
+ .sort()
840
+ .join(', ')} } from 'drizzle-orm/pg-core';`
841
+ );
842
+ }
843
+
844
+ const local = [...imports.local];
845
+ if (local.length) {
846
+ lines.push(
847
+ `import { ${local.sort().join(', ')} } from '${importBasePath}';`
848
+ );
849
+ }
850
+
851
+ lines.push('');
852
+ return lines.join('\n');
853
+ }