@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,25 @@
1
+ import type { MigrationConfig } from 'drizzle-orm/migrator';
2
+ import { readMigrationFiles } from 'drizzle-orm/migrator';
3
+ import type { DuckDBDatabase } from './driver.ts';
4
+ import type { PgSession } from 'drizzle-orm/pg-core/session';
5
+
6
+ export type DuckDbMigrationConfig = MigrationConfig | string;
7
+
8
+ export async function migrate<TSchema extends Record<string, unknown>>(
9
+ db: DuckDBDatabase<TSchema>,
10
+ config: DuckDbMigrationConfig
11
+ ) {
12
+ const migrationConfig: MigrationConfig =
13
+ typeof config === 'string'
14
+ ? { migrationsFolder: config }
15
+ : config;
16
+
17
+ const migrations = readMigrationFiles(migrationConfig);
18
+
19
+ await db.dialect.migrate(
20
+ migrations,
21
+ // Need to work around omitted internal types from drizzle...
22
+ db.session as unknown as PgSession,
23
+ migrationConfig
24
+ );
25
+ }
@@ -0,0 +1,114 @@
1
+ import { is } from 'drizzle-orm/entity';
2
+ import {
3
+ PgSelectBase,
4
+ PgSelectBuilder,
5
+ type CreatePgSelectFromBuilderMode,
6
+ type SelectedFields,
7
+ type TableLikeHasEmptySelection,
8
+ } from 'drizzle-orm/pg-core/query-builders';
9
+ import {
10
+ PgColumn,
11
+ PgTable,
12
+ type PgSession,
13
+ } from 'drizzle-orm/pg-core';
14
+ import { Subquery, ViewBaseConfig, type SQLWrapper } from 'drizzle-orm';
15
+ import { PgViewBase } from 'drizzle-orm/pg-core/view-base';
16
+ import type {
17
+ GetSelectTableName,
18
+ GetSelectTableSelection,
19
+ } from 'drizzle-orm/query-builders/select.types';
20
+ import { SQL, type ColumnsSelection } from 'drizzle-orm/sql/sql';
21
+ import { aliasFields } from './sql/selection.ts';
22
+ import type { DuckDBDialect } from './dialect.ts';
23
+ import { getTableColumns, type DrizzleTypeError } from 'drizzle-orm/utils';
24
+
25
+ interface PgViewBaseInternal<
26
+ TName extends string = string,
27
+ TExisting extends boolean = boolean,
28
+ TSelectedFields extends ColumnsSelection = ColumnsSelection
29
+ > extends PgViewBase<TName, TExisting, TSelectedFields> {
30
+ [ViewBaseConfig]?: {
31
+ selectedFields: SelectedFields;
32
+ };
33
+ }
34
+
35
+ export class DuckDBSelectBuilder<
36
+ TSelection extends SelectedFields | undefined,
37
+ TBuilderMode extends 'db' | 'qb' = 'db'
38
+ > extends PgSelectBuilder<TSelection, TBuilderMode> {
39
+ private _fields: TSelection;
40
+ private _session: PgSession | undefined;
41
+ private _dialect: DuckDBDialect;
42
+ private _withList: Subquery[] = [];
43
+ private _distinct:
44
+ | boolean
45
+ | {
46
+ on: (PgColumn | SQLWrapper)[];
47
+ }
48
+ | undefined;
49
+
50
+ constructor(config: {
51
+ fields: TSelection;
52
+ session: PgSession | undefined;
53
+ dialect: DuckDBDialect;
54
+ withList?: Subquery[];
55
+ distinct?:
56
+ | boolean
57
+ | {
58
+ on: (PgColumn | SQLWrapper)[];
59
+ };
60
+ }) {
61
+ super(config);
62
+ this._fields = config.fields;
63
+ this._session = config.session;
64
+ this._dialect = config.dialect;
65
+ if (config.withList) {
66
+ this._withList = config.withList;
67
+ }
68
+ this._distinct = config.distinct;
69
+ }
70
+
71
+ from<TFrom extends PgTable | Subquery | PgViewBaseInternal | SQL>(
72
+ source: TableLikeHasEmptySelection<TFrom> extends true
73
+ ? DrizzleTypeError<"Cannot reference a data-modifying statement subquery if it doesn't contain a `returning` clause">
74
+ : TFrom
75
+ ): CreatePgSelectFromBuilderMode<
76
+ TBuilderMode,
77
+ GetSelectTableName<TFrom>,
78
+ TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
79
+ TSelection extends undefined ? 'single' : 'partial'
80
+ > {
81
+ const isPartialSelect = !!this._fields;
82
+ const src = source as TFrom;
83
+
84
+ let fields: SelectedFields;
85
+ if (this._fields) {
86
+ fields = this._fields;
87
+ } else if (is(src, Subquery)) {
88
+ fields = Object.fromEntries(
89
+ Object.keys(src._.selectedFields).map((key) => [
90
+ key,
91
+ src[
92
+ key as unknown as keyof typeof src
93
+ ] as unknown as SelectedFields[string],
94
+ ])
95
+ );
96
+ } else if (is(src, PgViewBase)) {
97
+ fields = src[ViewBaseConfig]?.selectedFields as SelectedFields;
98
+ } else if (is(src, SQL)) {
99
+ fields = {};
100
+ } else {
101
+ fields = aliasFields(getTableColumns<PgTable>(src), !isPartialSelect);
102
+ }
103
+
104
+ return new PgSelectBase({
105
+ table: src,
106
+ fields,
107
+ isPartialSelect,
108
+ session: this._session,
109
+ dialect: this._dialect,
110
+ withList: this._withList,
111
+ distinct: this._distinct,
112
+ }) as any;
113
+ }
114
+ }
package/src/session.ts ADDED
@@ -0,0 +1,274 @@
1
+ import { entityKind } from 'drizzle-orm/entity';
2
+ import type { Logger } from 'drizzle-orm/logger';
3
+ import { NoopLogger } from 'drizzle-orm/logger';
4
+ import { PgTransaction } from 'drizzle-orm/pg-core';
5
+ import type { SelectedFieldsOrdered } from 'drizzle-orm/pg-core/query-builders/select.types';
6
+ import type {
7
+ PgTransactionConfig,
8
+ PreparedQueryConfig,
9
+ PgQueryResultHKT,
10
+ } from 'drizzle-orm/pg-core/session';
11
+ import { PgPreparedQuery, PgSession } from 'drizzle-orm/pg-core/session';
12
+ import type {
13
+ RelationalSchemaConfig,
14
+ TablesRelationalConfig,
15
+ } from 'drizzle-orm/relations';
16
+ import { fillPlaceholders, type Query, SQL, sql } from 'drizzle-orm/sql/sql';
17
+ import type { Assume } from 'drizzle-orm/utils';
18
+ import { adaptArrayOperators } from './sql/query-rewriters.ts';
19
+ import { mapResultRow } from './sql/result-mapper.ts';
20
+ import type { DuckDBDialect } from './dialect.ts';
21
+ import { TransactionRollbackError } from 'drizzle-orm/errors';
22
+ import type { DuckDBClientLike, RowData } from './client.ts';
23
+ import { executeOnClient, prepareParams } from './client.ts';
24
+
25
+ export type { DuckDBClientLike, RowData } from './client.ts';
26
+
27
+ export class DuckDBPreparedQuery<
28
+ T extends PreparedQueryConfig
29
+ > extends PgPreparedQuery<T> {
30
+ static readonly [entityKind]: string = 'DuckDBPreparedQuery';
31
+
32
+ constructor(
33
+ private client: DuckDBClientLike,
34
+ private dialect: DuckDBDialect,
35
+ private queryString: string,
36
+ private params: unknown[],
37
+ private logger: Logger,
38
+ private fields: SelectedFieldsOrdered | undefined,
39
+ private _isResponseInArrayMode: boolean,
40
+ private customResultMapper: ((rows: unknown[][]) => T['execute']) | undefined,
41
+ private rewriteArrays: boolean,
42
+ private rejectStringArrayLiterals: boolean,
43
+ private warnOnStringArrayLiteral?: (sql: string) => void
44
+ ) {
45
+ super({ sql: queryString, params });
46
+ }
47
+
48
+ async execute(
49
+ placeholderValues: Record<string, unknown> | undefined = {}
50
+ ): Promise<T['execute']> {
51
+ this.dialect.assertNoPgJsonColumns();
52
+ const params = prepareParams(
53
+ fillPlaceholders(this.params, placeholderValues),
54
+ {
55
+ rejectStringArrayLiterals: this.rejectStringArrayLiterals,
56
+ warnOnStringArrayLiteral: this.warnOnStringArrayLiteral
57
+ ? () => this.warnOnStringArrayLiteral?.(this.queryString)
58
+ : undefined,
59
+ }
60
+ );
61
+ const rewrittenQuery = this.rewriteArrays
62
+ ? adaptArrayOperators(this.queryString)
63
+ : this.queryString;
64
+
65
+ if (this.rewriteArrays && rewrittenQuery !== this.queryString) {
66
+ this.logger.logQuery(
67
+ `[duckdb] original query before array rewrite: ${this.queryString}`,
68
+ params
69
+ );
70
+ }
71
+
72
+ this.logger.logQuery(rewrittenQuery, params);
73
+
74
+ const {
75
+ fields,
76
+ joinsNotNullableMap,
77
+ customResultMapper,
78
+ } = this as typeof this & { joinsNotNullableMap?: Record<string, boolean> };
79
+
80
+ const rows = await executeOnClient(
81
+ this.client,
82
+ rewrittenQuery,
83
+ params
84
+ );
85
+
86
+ if (rows.length === 0 || !fields) {
87
+ return rows as T['execute'];
88
+ }
89
+
90
+ const rowValues = rows.map((row) => Object.values(row));
91
+
92
+ return customResultMapper
93
+ ? customResultMapper(rowValues)
94
+ : rowValues.map((row) =>
95
+ mapResultRow<T['execute']>(fields, row, joinsNotNullableMap)
96
+ );
97
+ }
98
+
99
+ all(
100
+ placeholderValues: Record<string, unknown> | undefined = {}
101
+ ): Promise<T['all']> {
102
+ return this.execute(placeholderValues);
103
+ }
104
+
105
+ isResponseInArrayMode(): boolean {
106
+ return this._isResponseInArrayMode;
107
+ }
108
+ }
109
+
110
+ export interface DuckDBSessionOptions {
111
+ logger?: Logger;
112
+ rewriteArrays?: boolean;
113
+ rejectStringArrayLiterals?: boolean;
114
+ }
115
+
116
+ export class DuckDBSession<
117
+ TFullSchema extends Record<string, unknown> = Record<string, never>,
118
+ TSchema extends TablesRelationalConfig = Record<string, never>
119
+ > extends PgSession<DuckDBQueryResultHKT, TFullSchema, TSchema> {
120
+ static readonly [entityKind]: string = 'DuckDBSession';
121
+
122
+ protected override dialect: DuckDBDialect;
123
+ private logger: Logger;
124
+ private rewriteArrays: boolean;
125
+ private rejectStringArrayLiterals: boolean;
126
+ private hasWarnedArrayLiteral = false;
127
+
128
+ constructor(
129
+ private client: DuckDBClientLike,
130
+ dialect: DuckDBDialect,
131
+ private schema: RelationalSchemaConfig<TSchema> | undefined,
132
+ private options: DuckDBSessionOptions = {}
133
+ ) {
134
+ super(dialect);
135
+ this.dialect = dialect;
136
+ this.logger = options.logger ?? new NoopLogger();
137
+ this.rewriteArrays = options.rewriteArrays ?? true;
138
+ this.rejectStringArrayLiterals = options.rejectStringArrayLiterals ?? false;
139
+ }
140
+
141
+ prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
142
+ query: Query,
143
+ fields: SelectedFieldsOrdered | undefined,
144
+ name: string | undefined,
145
+ isResponseInArrayMode: boolean,
146
+ customResultMapper?: (rows: unknown[][]) => T['execute']
147
+ ): PgPreparedQuery<T> {
148
+ void name; // DuckDB doesn't support prepared statement names but the signature must match.
149
+ return new DuckDBPreparedQuery(
150
+ this.client,
151
+ this.dialect,
152
+ query.sql,
153
+ query.params,
154
+ this.logger,
155
+ fields,
156
+ isResponseInArrayMode,
157
+ customResultMapper,
158
+ this.rewriteArrays,
159
+ this.rejectStringArrayLiterals,
160
+ this.rejectStringArrayLiterals ? undefined : this.warnOnStringArrayLiteral
161
+ );
162
+ }
163
+
164
+ override async transaction<T>(
165
+ transaction: (tx: DuckDBTransaction<TFullSchema, TSchema>) => Promise<T>
166
+ ): Promise<T> {
167
+ const session = new DuckDBSession(
168
+ this.client,
169
+ this.dialect,
170
+ this.schema,
171
+ this.options
172
+ );
173
+
174
+ const tx = new DuckDBTransaction<TFullSchema, TSchema>(
175
+ this.dialect,
176
+ session,
177
+ this.schema
178
+ );
179
+
180
+ await tx.execute(sql`BEGIN TRANSACTION;`);
181
+
182
+ try {
183
+ const result = await transaction(tx);
184
+ await tx.execute(sql`commit`);
185
+ return result;
186
+ } catch (error) {
187
+ await tx.execute(sql`rollback`);
188
+ throw error;
189
+ }
190
+ }
191
+
192
+ private warnOnStringArrayLiteral = (query: string) => {
193
+ if (this.hasWarnedArrayLiteral) {
194
+ return;
195
+ }
196
+ this.hasWarnedArrayLiteral = true;
197
+ this.logger.logQuery(
198
+ `[duckdb] ${arrayLiteralWarning}\nquery: ${query}`,
199
+ []
200
+ );
201
+ };
202
+ }
203
+
204
+ type PgTransactionInternals<
205
+ TFullSchema extends Record<string, unknown> = Record<string, never>,
206
+ TSchema extends TablesRelationalConfig = Record<string, never>
207
+ > = {
208
+ dialect: DuckDBDialect;
209
+ session: DuckDBSession<TFullSchema, TSchema>;
210
+ };
211
+
212
+ type DuckDBTransactionWithInternals<
213
+ TFullSchema extends Record<string, unknown> = Record<string, never>,
214
+ TSchema extends TablesRelationalConfig = Record<string, never>
215
+ > = PgTransactionInternals<TFullSchema, TSchema> &
216
+ DuckDBTransaction<TFullSchema, TSchema>;
217
+
218
+ export class DuckDBTransaction<
219
+ TFullSchema extends Record<string, unknown>,
220
+ TSchema extends TablesRelationalConfig
221
+ > extends PgTransaction<DuckDBQueryResultHKT, TFullSchema, TSchema> {
222
+ static readonly [entityKind]: string = 'DuckDBTransaction';
223
+
224
+ rollback(): never {
225
+ throw new TransactionRollbackError();
226
+ }
227
+
228
+ getTransactionConfigSQL(config: PgTransactionConfig): SQL {
229
+ const chunks: string[] = [];
230
+ if (config.isolationLevel) {
231
+ chunks.push(`isolation level ${config.isolationLevel}`);
232
+ }
233
+ if (config.accessMode) {
234
+ chunks.push(config.accessMode);
235
+ }
236
+ if (typeof config.deferrable === 'boolean') {
237
+ chunks.push(config.deferrable ? 'deferrable' : 'not deferrable');
238
+ }
239
+ return sql.raw(chunks.join(' '));
240
+ }
241
+
242
+ setTransaction(config: PgTransactionConfig): Promise<void> {
243
+ type Tx = DuckDBTransactionWithInternals<TFullSchema, TSchema>;
244
+ return (this as unknown as Tx).session.execute(
245
+ sql`set transaction ${this.getTransactionConfigSQL(config)}`
246
+ );
247
+ }
248
+
249
+ override async transaction<T>(
250
+ transaction: (tx: DuckDBTransaction<TFullSchema, TSchema>) => Promise<T>
251
+ ): Promise<T> {
252
+ type Tx = DuckDBTransactionWithInternals<TFullSchema, TSchema>;
253
+ const nestedTx = new DuckDBTransaction<TFullSchema, TSchema>(
254
+ (this as unknown as Tx).dialect,
255
+ (this as unknown as Tx).session,
256
+ this.schema,
257
+ this.nestedIndex + 1
258
+ );
259
+
260
+ return transaction(nestedTx);
261
+ }
262
+ }
263
+
264
+ export type GenericRowData<T extends RowData = RowData> = T;
265
+
266
+ export type GenericTableData<T = RowData> = T[];
267
+
268
+ const arrayLiteralWarning =
269
+ 'Received a stringified Postgres-style array literal. Use duckDbList()/duckDbArray() or pass native arrays instead. You can also set rejectStringArrayLiterals=true to throw.';
270
+
271
+
272
+ export interface DuckDBQueryResultHKT extends PgQueryResultHKT {
273
+ type: GenericTableData<Assume<this['row'], RowData>>;
274
+ }
@@ -0,0 +1,147 @@
1
+ const selectionRegex = /select\s+(.+)\s+from/i;
2
+ const tableIdPropSelectionRegex = new RegExp(
3
+ [
4
+ `("(.+)"\\."(.+)")`, // table identifier + property
5
+ `(\\s+as\\s+'?(.+?)'?\\.'?(.+?)'?)?`, // optional AS clause
6
+ ].join(''),
7
+ 'i'
8
+ );
9
+ const noTableIdPropSelectionRegex = /"(.+)"(\s+as\s+'?\1'?)?/i;
10
+
11
+ export function adaptArrayOperators(query: string): string {
12
+ type ArrayOperator = {
13
+ token: '@>' | '<@' | '&&';
14
+ fn: 'array_has_all' | 'array_has_any';
15
+ swap?: boolean;
16
+ };
17
+
18
+ const operators: ArrayOperator[] = [
19
+ { token: '@>', fn: 'array_has_all' },
20
+ { token: '<@', fn: 'array_has_all', swap: true },
21
+ { token: '&&', fn: 'array_has_any' },
22
+ ];
23
+
24
+ const isWhitespace = (char: string | undefined) =>
25
+ char !== undefined && /\s/.test(char);
26
+
27
+ const walkLeft = (source: string, start: number): [number, string] => {
28
+ let idx = start;
29
+ while (idx >= 0 && isWhitespace(source[idx])) {
30
+ idx--;
31
+ }
32
+
33
+ let depth = 0;
34
+ let inString = false;
35
+ for (; idx >= 0; idx--) {
36
+ const ch = source[idx];
37
+ if (ch === "'" && source[idx - 1] !== '\\') {
38
+ inString = !inString;
39
+ }
40
+ if (inString) continue;
41
+ if (ch === ')' || ch === ']') {
42
+ depth++;
43
+ } else if (ch === '(' || ch === '[') {
44
+ depth--;
45
+ if (depth < 0) {
46
+ return [idx + 1, source.slice(idx + 1, start + 1)];
47
+ }
48
+ } else if (depth === 0 && isWhitespace(ch)) {
49
+ return [idx + 1, source.slice(idx + 1, start + 1)];
50
+ }
51
+ }
52
+ return [0, source.slice(0, start + 1)];
53
+ };
54
+
55
+ const walkRight = (source: string, start: number): [number, string] => {
56
+ let idx = start;
57
+ while (idx < source.length && isWhitespace(source[idx])) {
58
+ idx++;
59
+ }
60
+
61
+ let depth = 0;
62
+ let inString = false;
63
+ for (; idx < source.length; idx++) {
64
+ const ch = source[idx];
65
+ if (ch === "'" && source[idx - 1] !== '\\') {
66
+ inString = !inString;
67
+ }
68
+ if (inString) continue;
69
+ if (ch === '(' || ch === '[') {
70
+ depth++;
71
+ } else if (ch === ')' || ch === ']') {
72
+ depth--;
73
+ if (depth < 0) {
74
+ return [idx, source.slice(start, idx)];
75
+ }
76
+ } else if (depth === 0 && isWhitespace(ch)) {
77
+ return [idx, source.slice(start, idx)];
78
+ }
79
+ }
80
+ return [source.length, source.slice(start)];
81
+ };
82
+
83
+ let rewritten = query;
84
+ for (const { token, fn, swap } of operators) {
85
+ let idx = rewritten.indexOf(token);
86
+ while (idx !== -1) {
87
+ const [leftStart, leftExpr] = walkLeft(rewritten, idx - 1);
88
+ const [rightEnd, rightExpr] = walkRight(
89
+ rewritten,
90
+ idx + token.length
91
+ );
92
+
93
+ const left = leftExpr.trim();
94
+ const right = rightExpr.trim();
95
+
96
+ const replacement = `${fn}(${swap ? right : left}, ${
97
+ swap ? left : right
98
+ })`;
99
+
100
+ rewritten =
101
+ rewritten.slice(0, leftStart) +
102
+ replacement +
103
+ rewritten.slice(rightEnd);
104
+
105
+ idx = rewritten.indexOf(token, leftStart + replacement.length);
106
+ }
107
+ }
108
+
109
+ return rewritten;
110
+ }
111
+
112
+ export function queryAdapter(query: string): string {
113
+ const selection = selectionRegex.exec(query);
114
+
115
+ if (selection?.length !== 2) {
116
+ return query;
117
+ }
118
+
119
+ const fields = selection[1]
120
+ .split(',')
121
+ .map((field) => {
122
+ const trimmedField = field.trim();
123
+ const tableProp = tableIdPropSelectionRegex.exec(trimmedField);
124
+ if (tableProp) {
125
+ const [, identifier, table, column, , aliasTable, aliasColumn] =
126
+ tableProp;
127
+
128
+ const asAlias = `'${aliasTable ?? table}.${aliasColumn ?? column}'`;
129
+ if (tableProp[4]) {
130
+ return trimmedField.replace(tableProp[4], ` as ${asAlias}`);
131
+ }
132
+ return `${identifier} as ${asAlias}`;
133
+ }
134
+
135
+ const noTableProp = noTableIdPropSelectionRegex.exec(trimmedField);
136
+ if (noTableProp) {
137
+ const [, column, alias] = noTableProp;
138
+ const asAlias = ` as '${column}'`;
139
+ return alias ? trimmedField.replace(alias, asAlias) : `${trimmedField}${asAlias}`;
140
+ }
141
+
142
+ return trimmedField;
143
+ })
144
+ .filter(Boolean) as string[];
145
+
146
+ return query.replace(selection[1], fields.join(', '));
147
+ }