dbt-js 0.1.1 → 0.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/db.js CHANGED
@@ -1,182 +1,194 @@
1
- // All database access lives here. connect() dispatches on connection.type and
2
- // returns a uniform client: { query(sql, params) -> { rows, rowCount }, end() }.
3
- // Drivers are imported lazily so each backend only loads its own.
4
-
5
- export async function connect(connection, { projectDir, readOnly = false, schema } = {}) {
6
- const { type = 'postgres', ...rest } = connection;
7
- return type === 'duckdb'
8
- ? connectDuckdb(rest.path, projectDir, readOnly)
9
- : type === 'mysql'
10
- ? connectMysql(rest, readOnly)
11
- : type === 'sqlite'
12
- ? connectSqlite(rest, schema, readOnly)
13
- : connectPg(rest, readOnly);
14
- }
15
-
16
- async function connectPg(connection, readOnly = false) {
17
- const { default: pg } = await import('pg');
18
- const client = new pg.Client(connection);
19
- await client.connect();
20
- // Session-level read-only also applies inside data-modifying CTEs,
21
- // which a statement-keyword check can't catch.
22
- if (readOnly) await client.query('SET default_transaction_read_only = on');
23
- return {
24
- dialect: 'postgres',
25
- async query(sql, params) {
26
- const res = await client.query(sql, params);
27
- return { rows: res.rows, rowCount: res.rowCount ?? undefined };
28
- },
29
- end: () => client.end(),
30
- };
31
- }
32
-
33
- async function connectMysql(connection, readOnly = false) {
34
- const { default: mysql } = await import('mysql2/promise');
35
- const conn = await mysql.createConnection({
36
- dateStrings: true, // JSON-safe rows, matching the duckdb adapter
37
- ...connection,
38
- multipleStatements: false,
39
- });
40
- // render.js emits "schema"."name" with no dialect knowledge; ANSI_QUOTES
41
- // makes double-quoted identifiers valid for the whole session.
42
- await conn.query(`SET SESSION sql_mode = CONCAT_WS(',', NULLIF(@@sql_mode, ''), 'ANSI_QUOTES')`);
43
- if (readOnly) await conn.query('SET SESSION transaction_read_only = 1');
44
- return {
45
- dialect: 'mysql',
46
- async query(sql, params) {
47
- const q = toQmarks(sql, params);
48
- const [res] = await conn.query(q.sql, q.params); // rows[] or ResultSetHeader
49
- return Array.isArray(res)
50
- ? { rows: res, rowCount: res.length }
51
- : { rows: [], rowCount: res.affectedRows ?? undefined }; // DML/DDL; CTAS reports inserted rows
52
- },
53
- end: () => conn.end(),
54
- };
55
- }
56
-
57
- async function connectSqlite(connection, schema, readOnly = false) {
58
- // Synchronous driver; long statements block the event loop (fine for CLI use,
59
- // worth knowing when embedding).
60
- const { default: Database } = await import('better-sqlite3');
61
- const db = new Database(connection.path, readOnly ? { readonly: true } : {});
62
- // cfg.schema 'main'/'temp' are SQLite's built-in schemas (single-file mode).
63
- // Anything else lives in '<schema>.db' beside the main file, ATTACHed for the
64
- // whole session so "schema"."name" from render.js resolves. ATTACH inherits
65
- // the connection's readonly flag and can't create files read-only, so a
66
- // missing file is skipped there (queries then fail with "no such table").
67
- if (schema && schema !== 'main' && schema !== 'temp') {
68
- const { join, dirname } = await import('node:path');
69
- const { existsSync } = await import('node:fs');
70
- const path =
71
- connection.path === ':memory:' ? ':memory:' : join(dirname(connection.path), `${schema}.db`);
72
- if (!readOnly || path === ':memory:' || existsSync(path)) {
73
- db.prepare(`ATTACH DATABASE ? AS ${quoteIdent(schema)}`).run(path);
74
- }
75
- }
76
- return {
77
- dialect: 'sqlite',
78
- async query(sql, params) {
79
- const q = toQmarks(sql, params);
80
- const stmt = db.prepare(q.sql); // rejects multi-statement strings, like mysql's multipleStatements:false
81
- if (stmt.reader) {
82
- const rows = stmt.all(...(q.params ?? []));
83
- return { rows, rowCount: rows.length };
84
- }
85
- const info = stmt.run(...(q.params ?? []));
86
- // sqlite3_changes is only updated by INSERT/UPDATE/DELETE; after DDL/CTAS
87
- // it still holds the previous DML's count, so report undefined instead
88
- const dml = /^\s*(insert|update|delete)\b/i.test(q.sql);
89
- return { rows: [], rowCount: dml ? info.changes : undefined };
90
- },
91
- end: async () => db.close(),
92
- };
93
- }
94
-
95
- // Internal queries (seeds, tests, relationKind) use Postgres-style $N
96
- // placeholders; model SQL never carries params, so user SQL is never rewritten.
97
- function toQmarks(sql, params) {
98
- if (!params?.length) return { sql, params: undefined };
99
- const ordered = [];
100
- const out = sql.replace(/\$(\d+)/g, (_, n) => {
101
- ordered.push(params[n - 1]);
102
- return '?';
103
- });
104
- return { sql: out, params: ordered };
105
- }
106
-
107
- async function connectDuckdb(path, projectDir, readOnly = false) {
108
- const { DuckDBInstance, ResultReturnType } = await import('@duckdb/node-api');
109
- const instance = await DuckDBInstance.create(path, readOnly ? { access_mode: 'READ_ONLY' } : undefined);
110
- const conn = await instance.connect();
111
- if (projectDir) {
112
- // resolve read_csv('data/...') etc. against the project dir, not the app's cwd
113
- await conn.run(`SET file_search_path = '${projectDir.replace(/'/g, "''")}'`);
114
- }
115
- return {
116
- dialect: 'duckdb',
117
- async query(sql, params) {
118
- const result = await conn.run(sql, params?.length ? params : undefined);
119
- // Json variant returns BIGINT as string and dates as ISO strings —
120
- // safe for JSON.stringify and Number() alike.
121
- const rows = await result.getRowObjectsJson();
122
- const rowCount =
123
- result.returnType === ResultReturnType.CHANGED_ROWS ? result.rowsChanged
124
- : result.returnType === ResultReturnType.QUERY_RESULT ? rows.length
125
- : undefined; // DDL/CTAS: DuckDB doesn't report counts
126
- return { rows, rowCount };
127
- },
128
- async end() {
129
- conn.disconnectSync();
130
- instance.closeSync(); // checkpoints WAL, releases the file lock
131
- },
132
- };
133
- }
134
-
135
- export const quoteIdent = (s) => `"${String(s).replace(/"/g, '""')}"`;
136
- export const rel = (schema, name) => `${quoteIdent(schema)}.${quoteIdent(name)}`;
137
-
138
- export async function ensureSchema(client, schema) {
139
- // SQLite: CREATE SCHEMA doesn't exist; the schema was ATTACHed at connect
140
- // time (which creates the file when writable), so it's already ensured.
141
- if (client.dialect === 'sqlite') return;
142
- await client.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)}`);
143
- }
144
-
145
- // 'r' = table, 'v' = view, null = absent. information_schema works on all
146
- // backends; the catalog predicate keeps DuckDB's temp catalog (schema "main")
147
- // from colliding when the target schema is also "main". MySQL has no catalog
148
- // (table_catalog is always 'def'; schema == database) so it skips the predicate.
149
- // The alias forces a lowercase key — MySQL returns information_schema columns
150
- // uppercase (TABLE_TYPE).
151
- export async function relationKind(client, schema, name) {
152
- if (client.dialect === 'sqlite') {
153
- // no information_schema; pragma_table_list covers main + attached schemas
154
- const { rows } = await client.query(
155
- 'SELECT type FROM pragma_table_list WHERE schema = $1 AND name = $2',
156
- [schema, name]
157
- );
158
- const t = rows[0]?.type; // lowercase 'table'/'view' (also 'shadow'/'virtual')
159
- return t === 'table' ? 'r' : t === 'view' ? 'v' : null;
160
- }
161
- const catalogPredicate =
162
- client.dialect === 'mysql' ? '' : 'table_catalog = current_database() AND ';
163
- const { rows } = await client.query(
164
- `SELECT table_type AS table_type FROM information_schema.tables
165
- WHERE ${catalogPredicate}table_schema = $1 AND table_name = $2`,
166
- [schema, name]
167
- );
168
- const t = rows[0]?.table_type;
169
- return t === 'BASE TABLE' ? 'r' : t === 'VIEW' ? 'v' : null;
170
- }
171
-
172
- export async function withTransaction(client, fn) {
173
- await client.query('BEGIN');
174
- try {
175
- const result = await fn();
176
- await client.query('COMMIT');
177
- return result;
178
- } catch (e) {
179
- await client.query('ROLLBACK');
180
- throw e;
181
- }
182
- }
1
+ // All database access lives here. connect() dispatches on connection.type and
2
+ // returns a uniform client: { query(sql, params) -> { rows, rowCount }, end() }.
3
+ // Drivers are imported lazily so each backend only loads its own.
4
+
5
+ export async function connect(connection, { projectDir, readOnly = false, schema } = {}) {
6
+ const { type = 'postgres', ...rest } = connection;
7
+ return type === 'duckdb'
8
+ ? connectDuckdb(rest.path, projectDir, readOnly, rest.attach)
9
+ : type === 'mysql'
10
+ ? connectMysql(rest, readOnly)
11
+ : type === 'sqlite'
12
+ ? connectSqlite(rest, schema, readOnly)
13
+ : connectPg(rest, readOnly);
14
+ }
15
+
16
+ async function connectPg(connection, readOnly = false) {
17
+ const { default: pg } = await import('pg');
18
+ const client = new pg.Client(connection);
19
+ await client.connect();
20
+ // Session-level read-only also applies inside data-modifying CTEs,
21
+ // which a statement-keyword check can't catch.
22
+ if (readOnly) await client.query('SET default_transaction_read_only = on');
23
+ return {
24
+ dialect: 'postgres',
25
+ async query(sql, params) {
26
+ const res = await client.query(sql, params);
27
+ return { rows: res.rows, rowCount: res.rowCount ?? undefined };
28
+ },
29
+ end: () => client.end(),
30
+ };
31
+ }
32
+
33
+ async function connectMysql(connection, readOnly = false) {
34
+ const { default: mysql } = await import('mysql2/promise');
35
+ const conn = await mysql.createConnection({
36
+ dateStrings: true, // JSON-safe rows, matching the duckdb adapter
37
+ ...connection,
38
+ multipleStatements: false,
39
+ });
40
+ // render.js emits "schema"."name" with no dialect knowledge; ANSI_QUOTES
41
+ // makes double-quoted identifiers valid for the whole session.
42
+ await conn.query(`SET SESSION sql_mode = CONCAT_WS(',', NULLIF(@@sql_mode, ''), 'ANSI_QUOTES')`);
43
+ if (readOnly) await conn.query('SET SESSION transaction_read_only = 1');
44
+ return {
45
+ dialect: 'mysql',
46
+ async query(sql, params) {
47
+ const q = toQmarks(sql, params);
48
+ const [res] = await conn.query(q.sql, q.params); // rows[] or ResultSetHeader
49
+ return Array.isArray(res)
50
+ ? { rows: res, rowCount: res.length }
51
+ : { rows: [], rowCount: res.affectedRows ?? undefined }; // DML/DDL; CTAS reports inserted rows
52
+ },
53
+ end: () => conn.end(),
54
+ };
55
+ }
56
+
57
+ async function connectSqlite(connection, schema, readOnly = false) {
58
+ // Synchronous driver; long statements block the event loop (fine for CLI use,
59
+ // worth knowing when embedding).
60
+ const { default: Database } = await import('better-sqlite3');
61
+ const db = new Database(connection.path, readOnly ? { readonly: true } : {});
62
+ // cfg.schema 'main'/'temp' are SQLite's built-in schemas (single-file mode).
63
+ // Anything else lives in '<schema>.db' beside the main file, ATTACHed for the
64
+ // whole session so "schema"."name" from render.js resolves. ATTACH inherits
65
+ // the connection's readonly flag and can't create files read-only, so a
66
+ // missing file is skipped there (queries then fail with "no such table").
67
+ if (schema && schema !== 'main' && schema !== 'temp') {
68
+ const { join, dirname } = await import('node:path');
69
+ const { existsSync } = await import('node:fs');
70
+ const path =
71
+ connection.path === ':memory:' ? ':memory:' : join(dirname(connection.path), `${schema}.db`);
72
+ if (!readOnly || path === ':memory:' || existsSync(path)) {
73
+ db.prepare(`ATTACH DATABASE ? AS ${quoteIdent(schema)}`).run(path);
74
+ }
75
+ }
76
+ return {
77
+ dialect: 'sqlite',
78
+ async query(sql, params) {
79
+ const q = toQmarks(sql, params);
80
+ const stmt = db.prepare(q.sql); // rejects multi-statement strings, like mysql's multipleStatements:false
81
+ if (stmt.reader) {
82
+ const rows = stmt.all(...(q.params ?? []));
83
+ return { rows, rowCount: rows.length };
84
+ }
85
+ const info = stmt.run(...(q.params ?? []));
86
+ // sqlite3_changes is only updated by INSERT/UPDATE/DELETE; after DDL/CTAS
87
+ // it still holds the previous DML's count, so report undefined instead
88
+ const dml = /^\s*(insert|update|delete)\b/i.test(q.sql);
89
+ return { rows: [], rowCount: dml ? info.changes : undefined };
90
+ },
91
+ end: async () => db.close(),
92
+ };
93
+ }
94
+
95
+ // Internal queries (seeds, tests, relationKind) use Postgres-style $N
96
+ // placeholders; model SQL never carries params, so user SQL is never rewritten.
97
+ function toQmarks(sql, params) {
98
+ if (!params?.length) return { sql, params: undefined };
99
+ const ordered = [];
100
+ const out = sql.replace(/\$(\d+)/g, (_, n) => {
101
+ ordered.push(params[n - 1]);
102
+ return '?';
103
+ });
104
+ return { sql: out, params: ordered };
105
+ }
106
+
107
+ async function connectDuckdb(path, projectDir, readOnly = false, attach = []) {
108
+ const { DuckDBInstance, ResultReturnType } = await import('@duckdb/node-api');
109
+ const instance = await DuckDBInstance.create(path, readOnly ? { access_mode: 'READ_ONLY' } : undefined);
110
+ const conn = await instance.connect();
111
+ // Mount external databases as catalogs (referenced as "alias"."schema"."table").
112
+ // Attachments are read-only by default; a read-only connection (the query API)
113
+ // forces every attachment read-only too. DuckDB autoloads the sqlite/postgres/
114
+ // mysql scanner extensions on demand, so no explicit INSTALL/LOAD is needed.
115
+ for (const entry of attach ?? []) {
116
+ const readOnlyAttach = readOnly || entry.read_only !== false;
117
+ const opts = [];
118
+ if (entry.type && entry.type !== 'duckdb') opts.push(`TYPE ${entry.type}`);
119
+ if (readOnlyAttach) opts.push('READ_ONLY');
120
+ const tail = opts.length ? ` (${opts.join(', ')})` : '';
121
+ await conn.run(`ATTACH '${entry.path.replace(/'/g, "''")}' AS ${quoteIdent(entry.alias)}${tail}`);
122
+ }
123
+ if (projectDir) {
124
+ // resolve read_csv('data/...') etc. against the project dir, not the app's cwd
125
+ await conn.run(`SET file_search_path = '${projectDir.replace(/'/g, "''")}'`);
126
+ }
127
+ return {
128
+ dialect: 'duckdb',
129
+ async query(sql, params) {
130
+ const result = await conn.run(sql, params?.length ? params : undefined);
131
+ // Json variant returns BIGINT as string and dates as ISO strings —
132
+ // safe for JSON.stringify and Number() alike.
133
+ const rows = await result.getRowObjectsJson();
134
+ const rowCount =
135
+ result.returnType === ResultReturnType.CHANGED_ROWS ? result.rowsChanged
136
+ : result.returnType === ResultReturnType.QUERY_RESULT ? rows.length
137
+ : undefined; // DDL/CTAS: DuckDB doesn't report counts
138
+ return { rows, rowCount };
139
+ },
140
+ async end() {
141
+ conn.disconnectSync();
142
+ instance.closeSync(); // checkpoints WAL, releases the file lock
143
+ },
144
+ };
145
+ }
146
+
147
+ export const quoteIdent = (s) => `"${String(s).replace(/"/g, '""')}"`;
148
+ export const rel = (schema, name) => `${quoteIdent(schema)}.${quoteIdent(name)}`;
149
+
150
+ export async function ensureSchema(client, schema) {
151
+ // SQLite: CREATE SCHEMA doesn't exist; the schema was ATTACHed at connect
152
+ // time (which creates the file when writable), so it's already ensured.
153
+ if (client.dialect === 'sqlite') return;
154
+ await client.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)}`);
155
+ }
156
+
157
+ // 'r' = table, 'v' = view, null = absent. information_schema works on all
158
+ // backends; the catalog predicate keeps DuckDB's temp catalog (schema "main")
159
+ // from colliding when the target schema is also "main". MySQL has no catalog
160
+ // (table_catalog is always 'def'; schema == database) so it skips the predicate.
161
+ // The alias forces a lowercase key — MySQL returns information_schema columns
162
+ // uppercase (TABLE_TYPE).
163
+ export async function relationKind(client, schema, name) {
164
+ if (client.dialect === 'sqlite') {
165
+ // no information_schema; pragma_table_list covers main + attached schemas
166
+ const { rows } = await client.query(
167
+ 'SELECT type FROM pragma_table_list WHERE schema = $1 AND name = $2',
168
+ [schema, name]
169
+ );
170
+ const t = rows[0]?.type; // lowercase 'table'/'view' (also 'shadow'/'virtual')
171
+ return t === 'table' ? 'r' : t === 'view' ? 'v' : null;
172
+ }
173
+ const catalogPredicate =
174
+ client.dialect === 'mysql' ? '' : 'table_catalog = current_database() AND ';
175
+ const { rows } = await client.query(
176
+ `SELECT table_type AS table_type FROM information_schema.tables
177
+ WHERE ${catalogPredicate}table_schema = $1 AND table_name = $2`,
178
+ [schema, name]
179
+ );
180
+ const t = rows[0]?.table_type;
181
+ return t === 'BASE TABLE' ? 'r' : t === 'VIEW' ? 'v' : null;
182
+ }
183
+
184
+ export async function withTransaction(client, fn) {
185
+ await client.query('BEGIN');
186
+ try {
187
+ const result = await fn();
188
+ await client.query('COMMIT');
189
+ return result;
190
+ } catch (e) {
191
+ await client.query('ROLLBACK');
192
+ throw e;
193
+ }
194
+ }