@leonardovida-md/drizzle-neo-duckdb 1.1.0 → 1.1.2

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/src/columns.ts CHANGED
@@ -7,11 +7,13 @@ import {
7
7
  wrapMap,
8
8
  wrapBlob,
9
9
  wrapJson,
10
+ wrapTimestamp,
10
11
  type ListValueWrapper,
11
12
  type ArrayValueWrapper,
12
13
  type MapValueWrapper,
13
14
  type BlobValueWrapper,
14
15
  type JsonValueWrapper,
16
+ type TimestampValueWrapper,
15
17
  } from './value-wrappers-core.ts';
16
18
 
17
19
  type IntColType =
@@ -63,7 +65,7 @@ type StructColType = `STRUCT (${string})`;
63
65
 
64
66
  type Primitive = AnyColType | ListColType | ArrayColType | StructColType;
65
67
 
66
- function coerceArrayString(value: string): unknown[] | undefined {
68
+ export function coerceArrayString(value: string): unknown[] | undefined {
67
69
  const trimmed = value.trim();
68
70
  if (!trimmed) {
69
71
  return [];
@@ -86,7 +88,7 @@ function coerceArrayString(value: string): unknown[] | undefined {
86
88
  return undefined;
87
89
  }
88
90
 
89
- function formatLiteral(value: unknown, typeHint?: string): string {
91
+ export function formatLiteral(value: unknown, typeHint?: string): string {
90
92
  if (value === null || value === undefined) {
91
93
  return 'NULL';
92
94
  }
@@ -123,7 +125,7 @@ function formatLiteral(value: unknown, typeHint?: string): string {
123
125
  return `'${escaped}'`;
124
126
  }
125
127
 
126
- function buildListLiteral(values: unknown[], elementType?: string): SQL {
128
+ export function buildListLiteral(values: unknown[], elementType?: string): SQL {
127
129
  if (values.length === 0) {
128
130
  return sql`[]`;
129
131
  }
@@ -135,7 +137,7 @@ function buildListLiteral(values: unknown[], elementType?: string): SQL {
135
137
  return sql`list_value(${sql.join(chunks, sql.raw(', '))})`;
136
138
  }
137
139
 
138
- function buildStructLiteral(
140
+ export function buildStructLiteral(
139
141
  value: Record<string, unknown>,
140
142
  schema?: Record<string, Primitive>
141
143
  ): SQL {
@@ -154,7 +156,7 @@ function buildStructLiteral(
154
156
  return sql`struct_pack(${sql.join(parts, sql.raw(', '))})`;
155
157
  }
156
158
 
157
- function buildMapLiteral(
159
+ export function buildMapLiteral(
158
160
  value: Record<string, unknown>,
159
161
  valueType?: string
160
162
  ): SQL {
@@ -188,11 +190,11 @@ export const duckDbList = <TData = unknown>(
188
190
  }
189
191
  if (typeof value === 'string') {
190
192
  const parsed = coerceArrayString(value);
191
- if (parsed) {
193
+ if (parsed !== undefined) {
192
194
  return parsed as TData[];
193
195
  }
194
196
  }
195
- return [] as TData[];
197
+ return value as unknown as TData[];
196
198
  },
197
199
  })(name);
198
200
 
@@ -219,11 +221,11 @@ export const duckDbArray = <TData = unknown>(
219
221
  }
220
222
  if (typeof value === 'string') {
221
223
  const parsed = coerceArrayString(value);
222
- if (parsed) {
224
+ if (parsed !== undefined) {
223
225
  return parsed as TData[];
224
226
  }
225
227
  }
226
- return [] as TData[];
228
+ return value as unknown as TData[];
227
229
  },
228
230
  })(name);
229
231
 
@@ -355,12 +357,35 @@ interface TimestampOptions {
355
357
  withTimezone?: boolean;
356
358
  mode?: TimestampMode;
357
359
  precision?: number;
360
+ bindMode?: 'auto' | 'bind' | 'literal';
361
+ }
362
+
363
+ function shouldBindTimestamp(options: TimestampOptions): boolean {
364
+ const bindMode = options.bindMode ?? 'auto';
365
+ if (bindMode === 'bind') return true;
366
+ if (bindMode === 'literal') return false;
367
+
368
+ const isBun =
369
+ typeof process !== 'undefined' &&
370
+ typeof process.versions?.bun !== 'undefined';
371
+ if (isBun) return false;
372
+
373
+ const forceLiteral =
374
+ typeof process !== 'undefined'
375
+ ? process.env.DRIZZLE_DUCKDB_FORCE_LITERAL_TIMESTAMPS
376
+ : undefined;
377
+
378
+ if (forceLiteral && forceLiteral !== '0') {
379
+ return false;
380
+ }
381
+
382
+ return true;
358
383
  }
359
384
 
360
385
  export const duckDbTimestamp = (name: string, options: TimestampOptions = {}) =>
361
386
  customType<{
362
387
  data: Date | string;
363
- driverData: SQL | string | Date;
388
+ driverData: SQL | string | Date | TimestampValueWrapper;
364
389
  }>({
365
390
  dataType() {
366
391
  if (options.withTimezone) {
@@ -369,14 +394,36 @@ export const duckDbTimestamp = (name: string, options: TimestampOptions = {}) =>
369
394
  const precision = options.precision ? `(${options.precision})` : '';
370
395
  return `TIMESTAMP${precision}`;
371
396
  },
372
- toDriver(value: Date | string) {
373
- // Use SQL literals for timestamps due to Bun/DuckDB bigint binding issues
397
+ toDriver(
398
+ value: Date | string
399
+ ): SQL | string | Date | TimestampValueWrapper {
400
+ if (shouldBindTimestamp(options)) {
401
+ return wrapTimestamp(
402
+ value,
403
+ options.withTimezone ?? false,
404
+ options.precision
405
+ );
406
+ }
407
+
374
408
  const iso = value instanceof Date ? value.toISOString() : value;
375
409
  const normalized = iso.replace('T', ' ').replace('Z', '+00');
376
410
  const typeKeyword = options.withTimezone ? 'TIMESTAMPTZ' : 'TIMESTAMP';
377
411
  return sql.raw(`${typeKeyword} '${normalized}'`);
378
412
  },
379
- fromDriver(value: Date | string | SQL) {
413
+ fromDriver(value: Date | string | SQL | TimestampValueWrapper) {
414
+ if (
415
+ value &&
416
+ typeof value === 'object' &&
417
+ 'kind' in value &&
418
+ (value as TimestampValueWrapper).kind === 'timestamp'
419
+ ) {
420
+ const wrapped = value as TimestampValueWrapper;
421
+ return wrapped.data instanceof Date
422
+ ? wrapped.data
423
+ : typeof wrapped.data === 'number' || typeof wrapped.data === 'bigint'
424
+ ? new Date(Number(wrapped.data) / 1000)
425
+ : wrapped.data;
426
+ }
380
427
  if (options.mode === 'string') {
381
428
  if (value instanceof Date) {
382
429
  return value.toISOString().replace('T', ' ').replace('Z', '+00');
package/src/dialect.ts CHANGED
@@ -19,18 +19,64 @@ import {
19
19
  type QueryTypingsValue,
20
20
  } from 'drizzle-orm';
21
21
 
22
+ const enum SavepointSupport {
23
+ Unknown = 0,
24
+ Yes = 1,
25
+ No = 2,
26
+ }
27
+
22
28
  export class DuckDBDialect extends PgDialect {
23
29
  static readonly [entityKind]: string = 'DuckDBPgDialect';
30
+ // Track if PG JSON columns were detected during the current query preparation.
31
+ // Reset before each query via DuckDBSession to keep detection per-query.
24
32
  private hasPgJsonColumn = false;
33
+ // Track savepoint support per-dialect instance to avoid cross-contamination
34
+ // when multiple database connections with different capabilities exist.
35
+ private savepointsSupported: SavepointSupport = SavepointSupport.Unknown;
36
+
37
+ /**
38
+ * Reset the PG JSON detection flag. Should be called before preparing a new query.
39
+ */
40
+ resetPgJsonFlag(): void {
41
+ this.hasPgJsonColumn = false;
42
+ }
43
+
44
+ /**
45
+ * Mark that a PG JSON/JSONB column was detected during query preparation.
46
+ */
47
+ markPgJsonDetected(): void {
48
+ this.hasPgJsonColumn = true;
49
+ }
25
50
 
26
51
  assertNoPgJsonColumns(): void {
27
52
  if (this.hasPgJsonColumn) {
28
53
  throw new Error(
29
- 'Pg JSON/JSONB columns are not supported in DuckDB. Replace them with duckDbJson() to use DuckDBs native JSON type.'
54
+ "Pg JSON/JSONB columns are not supported in DuckDB. Replace them with duckDbJson() to use DuckDB's native JSON type."
30
55
  );
31
56
  }
32
57
  }
33
58
 
59
+ /**
60
+ * Check if savepoints are known to be unsupported for this dialect instance.
61
+ */
62
+ areSavepointsUnsupported(): boolean {
63
+ return this.savepointsSupported === SavepointSupport.No;
64
+ }
65
+
66
+ /**
67
+ * Mark that savepoints are supported for this dialect instance.
68
+ */
69
+ markSavepointsSupported(): void {
70
+ this.savepointsSupported = SavepointSupport.Yes;
71
+ }
72
+
73
+ /**
74
+ * Mark that savepoints are not supported for this dialect instance.
75
+ */
76
+ markSavepointsUnsupported(): void {
77
+ this.savepointsSupported = SavepointSupport.No;
78
+ }
79
+
34
80
  override async migrate(
35
81
  migrations: MigrationMeta[],
36
82
  session: PgSession,
@@ -117,8 +163,10 @@ export class DuckDBDialect extends PgDialect {
117
163
  encoder: DriverValueEncoder<unknown, unknown>
118
164
  ): QueryTypingsValue {
119
165
  if (is(encoder, PgJsonb) || is(encoder, PgJson)) {
120
- this.hasPgJsonColumn = true;
121
- return 'none';
166
+ this.markPgJsonDetected();
167
+ throw new Error(
168
+ "Pg JSON/JSONB columns are not supported in DuckDB. Replace them with duckDbJson() to use DuckDB's native JSON type."
169
+ );
122
170
  } else if (is(encoder, PgNumeric)) {
123
171
  return 'decimal';
124
172
  } else if (is(encoder, PgTime)) {
package/src/driver.ts CHANGED
@@ -23,19 +23,32 @@ import { DuckDBSession } from './session.ts';
23
23
  import { DuckDBDialect } from './dialect.ts';
24
24
  import { DuckDBSelectBuilder } from './select-builder.ts';
25
25
  import { aliasFields } from './sql/selection.ts';
26
- import type { ExecuteInBatchesOptions, RowData } from './client.ts';
27
- import { isPool } from './client.ts';
26
+ import type {
27
+ ExecuteBatchesRawChunk,
28
+ ExecuteInBatchesOptions,
29
+ RowData,
30
+ } from './client.ts';
31
+ import { closeClientConnection, isPool } from './client.ts';
28
32
  import {
29
33
  createDuckDBConnectionPool,
30
34
  resolvePoolSize,
31
35
  type DuckDBPoolConfig,
32
36
  type PoolPreset,
33
37
  } from './pool.ts';
38
+ import {
39
+ resolvePrepareCacheOption,
40
+ resolveRewriteArraysOption,
41
+ type PreparedStatementCacheConfig,
42
+ type PrepareCacheOption,
43
+ type RewriteArraysMode,
44
+ type RewriteArraysOption,
45
+ } from './options.ts';
34
46
 
35
47
  export interface PgDriverOptions {
36
48
  logger?: Logger;
37
- rewriteArrays?: boolean;
49
+ rewriteArrays?: RewriteArraysMode;
38
50
  rejectStringArrayLiterals?: boolean;
51
+ prepareCache?: PreparedStatementCacheConfig;
39
52
  }
40
53
 
41
54
  export class DuckDBDriver {
@@ -52,8 +65,9 @@ export class DuckDBDriver {
52
65
  ): DuckDBSession<Record<string, unknown>, TablesRelationalConfig> {
53
66
  return new DuckDBSession(this.client, this.dialect, schema, {
54
67
  logger: this.options.logger,
55
- rewriteArrays: this.options.rewriteArrays,
68
+ rewriteArrays: this.options.rewriteArrays ?? 'auto',
56
69
  rejectStringArrayLiterals: this.options.rejectStringArrayLiterals,
70
+ prepareCache: this.options.prepareCache,
57
71
  });
58
72
  }
59
73
  }
@@ -69,8 +83,9 @@ export interface DuckDBConnectionConfig {
69
83
  export interface DuckDBDrizzleConfig<
70
84
  TSchema extends Record<string, unknown> = Record<string, never>,
71
85
  > extends DrizzleConfig<TSchema> {
72
- rewriteArrays?: boolean;
86
+ rewriteArrays?: RewriteArraysOption;
73
87
  rejectStringArrayLiterals?: boolean;
88
+ prepareCache?: PrepareCacheOption;
74
89
  /** Pool configuration. Use preset name, size config, or false to disable. */
75
90
  pool?: DuckDBPoolConfig | PoolPreset | false;
76
91
  }
@@ -111,6 +126,8 @@ function createFromClient<
111
126
  instance?: DuckDBInstance
112
127
  ): DuckDBDatabase<TSchema, ExtractTablesWithRelations<TSchema>> {
113
128
  const dialect = new DuckDBDialect();
129
+ const rewriteArraysMode = resolveRewriteArraysOption(config.rewriteArrays);
130
+ const prepareCache = resolvePrepareCacheOption(config.prepareCache);
114
131
 
115
132
  const logger =
116
133
  config.logger === true ? new DefaultLogger() : config.logger || undefined;
@@ -131,8 +148,9 @@ function createFromClient<
131
148
 
132
149
  const driver = new DuckDBDriver(client, dialect, {
133
150
  logger,
134
- rewriteArrays: config.rewriteArrays,
151
+ rewriteArrays: rewriteArraysMode,
135
152
  rejectStringArrayLiterals: config.rejectStringArrayLiterals,
153
+ prepareCache,
136
154
  });
137
155
  const session = driver.createSession(schema);
138
156
 
@@ -290,7 +308,20 @@ export class DuckDBDatabase<
290
308
  if (isPool(this.$client) && this.$client.close) {
291
309
  await this.$client.close();
292
310
  }
293
- // Note: DuckDBInstance doesn't have a close method in the current API
311
+ if (!isPool(this.$client)) {
312
+ await closeClientConnection(this.$client);
313
+ }
314
+ if (this.$instance) {
315
+ const maybeClosable = this.$instance as unknown as {
316
+ close?: () => Promise<void> | void;
317
+ closeSync?: () => void;
318
+ };
319
+ if (typeof maybeClosable.close === 'function') {
320
+ await maybeClosable.close();
321
+ } else if (typeof maybeClosable.closeSync === 'function') {
322
+ maybeClosable.closeSync();
323
+ }
324
+ }
294
325
  }
295
326
 
296
327
  select(): DuckDBSelectBuilder<undefined>;
@@ -317,6 +348,13 @@ export class DuckDBDatabase<
317
348
  return this.session.executeBatches<T>(query, options);
318
349
  }
319
350
 
351
+ executeBatchesRaw(
352
+ query: SQL,
353
+ options: ExecuteInBatchesOptions = {}
354
+ ): AsyncGenerator<ExecuteBatchesRawChunk, void, void> {
355
+ return this.session.executeBatchesRaw(query, options);
356
+ }
357
+
320
358
  executeArrow(query: SQL): Promise<unknown> {
321
359
  return this.session.executeArrow(query);
322
360
  }
package/src/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from './client.ts';
7
7
  export * from './pool.ts';
8
8
  export * from './olap.ts';
9
9
  export * from './value-wrappers.ts';
10
+ export * from './options.ts';
package/src/introspect.ts CHANGED
@@ -535,7 +535,7 @@ function emitColumn(
535
535
  return builder;
536
536
  }
537
537
 
538
- function buildDefault(defaultValue: string | null): string {
538
+ export function buildDefault(defaultValue: string | null): string {
539
539
  if (!defaultValue) {
540
540
  return '';
541
541
  }
@@ -707,6 +707,22 @@ function mapDuckDbType(
707
707
  return { builder: `text(${columnName(column.name)}) /* JSON */` };
708
708
  }
709
709
 
710
+ if (upper.startsWith('ENUM')) {
711
+ imports.pgCore.add('text');
712
+ const enumLiteral = raw.replace(/^ENUM\s*/i, '').trim();
713
+ return {
714
+ builder: `text(${columnName(column.name)}) /* ENUM ${enumLiteral} */`,
715
+ };
716
+ }
717
+
718
+ if (upper.startsWith('UNION')) {
719
+ imports.pgCore.add('text');
720
+ const unionLiteral = raw.replace(/^UNION\s*/i, '').trim();
721
+ return {
722
+ builder: `text(${columnName(column.name)}) /* UNION ${unionLiteral} */`,
723
+ };
724
+ }
725
+
710
726
  if (upper === 'INET') {
711
727
  imports.local.add('duckDbInet');
712
728
  return { builder: `duckDbInet(${columnName(column.name)})` };
@@ -788,15 +804,16 @@ function mapDuckDbType(
788
804
  }
789
805
 
790
806
  // Fallback: keep as text to avoid runtime failures.
807
+ // Unknown types are mapped to text with a comment indicating the original type.
791
808
  imports.pgCore.add('text');
792
809
  return {
793
810
  builder: `text(${columnName(
794
811
  column.name
795
- )}) /* TODO: verify type ${upper} */`,
812
+ )}) /* unsupported DuckDB type: ${upper} */`,
796
813
  };
797
814
  }
798
815
 
799
- function parseStructFields(
816
+ export function parseStructFields(
800
817
  inner: string
801
818
  ): Array<{ name: string; type: string }> {
802
819
  const result: Array<{ name: string; type: string }> = [];
@@ -813,7 +830,7 @@ function parseStructFields(
813
830
  return result;
814
831
  }
815
832
 
816
- function parseMapValue(raw: string): string {
833
+ export function parseMapValue(raw: string): string {
817
834
  const inner = raw.replace(/^MAP\(/i, '').replace(/\)$/, '');
818
835
  const parts = splitTopLevel(inner, ',');
819
836
  if (parts.length < 2) {
@@ -822,7 +839,7 @@ function parseMapValue(raw: string): string {
822
839
  return parts[1]?.trim() ?? 'TEXT';
823
840
  }
824
841
 
825
- function splitTopLevel(input: string, delimiter: string): string[] {
842
+ export function splitTopLevel(input: string, delimiter: string): string[] {
826
843
  const parts: string[] = [];
827
844
  let depth = 0;
828
845
  let current = '';
@@ -847,7 +864,7 @@ function tableKey(schema: string, table: string): string {
847
864
  return `${schema}.${table}`;
848
865
  }
849
866
 
850
- function toIdentifier(name: string): string {
867
+ export function toIdentifier(name: string): string {
851
868
  const cleaned = name.replace(/[^A-Za-z0-9_]/g, '_');
852
869
  const parts = cleaned.split('_').filter(Boolean);
853
870
  const base = parts
package/src/options.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type RewriteArraysMode = 'auto' | 'always' | 'never';
2
+
3
+ export type RewriteArraysOption = boolean | RewriteArraysMode;
4
+
5
+ const DEFAULT_REWRITE_ARRAYS_MODE: RewriteArraysMode = 'auto';
6
+
7
+ export function resolveRewriteArraysOption(
8
+ value?: RewriteArraysOption
9
+ ): RewriteArraysMode {
10
+ if (value === undefined) return DEFAULT_REWRITE_ARRAYS_MODE;
11
+ if (value === true) return 'auto';
12
+ if (value === false) return 'never';
13
+ return value;
14
+ }
15
+
16
+ export type PrepareCacheOption = boolean | number | { size?: number };
17
+
18
+ export interface PreparedStatementCacheConfig {
19
+ size: number;
20
+ }
21
+
22
+ const DEFAULT_PREPARED_CACHE_SIZE = 32;
23
+
24
+ export function resolvePrepareCacheOption(
25
+ option?: PrepareCacheOption
26
+ ): PreparedStatementCacheConfig | undefined {
27
+ if (!option) return undefined;
28
+
29
+ if (option === true) {
30
+ return { size: DEFAULT_PREPARED_CACHE_SIZE };
31
+ }
32
+
33
+ if (typeof option === 'number') {
34
+ const size = Math.max(1, Math.floor(option));
35
+ return { size };
36
+ }
37
+
38
+ const size = option.size ?? DEFAULT_PREPARED_CACHE_SIZE;
39
+ return { size: Math.max(1, Math.floor(size)) };
40
+ }