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/LICENSE +21 -21
- package/README.md +365 -325
- package/bin/dbt-js.js +4 -4
- package/package.json +53 -53
- package/src/api.js +271 -257
- package/src/batches.js +129 -120
- package/src/cli.js +178 -175
- package/src/config.js +139 -68
- package/src/dag.js +67 -67
- package/src/db.js +194 -182
- package/src/materialize.js +197 -197
- package/src/project.js +139 -107
- package/src/render.js +65 -62
- package/src/seed.js +68 -68
- package/src/tests.js +49 -49
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
}
|