dbt-js 0.1.1

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,197 @@
1
+ import { quoteIdent, rel, relationKind, withTransaction } from './db.js';
2
+ import { render } from './render.js';
3
+ import { computeBatches } from './batches.js';
4
+
5
+ export async function runModel(client, node, projectCfg, opts = {}) {
6
+ const { fullRefresh = false, vars } = opts;
7
+ const { name, config, rawSql } = node;
8
+ const schema = projectCfg.schema;
9
+ const target = rel(schema, name);
10
+ const kind = await relationKind(client, schema, name);
11
+ const isIncremental = config.materialized === 'incremental' && !fullRefresh && kind === 'r';
12
+
13
+ const ctx = {
14
+ name,
15
+ schema,
16
+ vars: vars ?? projectCfg.vars,
17
+ isIncremental,
18
+ sources: projectCfg.sources,
19
+ timezone: config.timezone,
20
+ };
21
+
22
+ // Hooks run outside the materialization transaction, one statement each, so
23
+ // they can use statements Postgres forbids inside a txn (VACUUM, CREATE
24
+ // INDEX CONCURRENTLY). Microbatch runs them once per model, not per batch.
25
+ await runHooks(client, config.pre_hook, 'pre_hook', ctx);
26
+
27
+ if (config.materialized === 'incremental' && config.strategy === 'microbatch') {
28
+ return runMicrobatch(client, node, projectCfg, { ...opts, kind, hookCtx: ctx });
29
+ }
30
+
31
+ const { sql } = render(rawSql, ctx);
32
+ const result = await materialize(client, { name, config, sql, target, kind, isIncremental });
33
+ await runHooks(client, config.post_hook, 'post_hook', ctx);
34
+ return result;
35
+ }
36
+
37
+ async function materialize(client, { name, config, sql, target, kind, isIncremental }) {
38
+ const sqlite = client.dialect === 'sqlite';
39
+ const cascade = sqlite ? '' : ' CASCADE'; // CASCADE is a SQLite syntax error
40
+
41
+ if (config.materialized === 'view') {
42
+ if (sqlite) {
43
+ // no CREATE OR REPLACE VIEW; SQLite DDL is transactional, so the wrap
44
+ // closes the window where the view would be absent
45
+ return withTransaction(client, async () => {
46
+ if (kind && kind !== 'v') await client.query(`DROP TABLE IF EXISTS ${target}`);
47
+ await client.query(`DROP VIEW IF EXISTS ${target}`);
48
+ await client.query(`CREATE VIEW ${target} AS\n${sql}`);
49
+ return { action: 'view' };
50
+ });
51
+ }
52
+ if (kind && kind !== 'v') await client.query(`DROP TABLE IF EXISTS ${target} CASCADE`);
53
+ await client.query(`CREATE OR REPLACE VIEW ${target} AS\n${sql}`);
54
+ return { action: 'view' };
55
+ }
56
+
57
+ if (!isIncremental) {
58
+ // table, or incremental first run / --full-refresh: transactional rebuild
59
+ return withTransaction(client, async () => {
60
+ if (kind === 'v') await client.query(`DROP VIEW IF EXISTS ${target}${cascade}`);
61
+ else await client.query(`DROP TABLE IF EXISTS ${target}${cascade}`);
62
+ const res = await client.query(`CREATE TABLE ${target} AS\n${sql}`);
63
+ const action = config.materialized === 'table' ? 'table' : 'incremental (full build)';
64
+ return { action, rowCount: res.rowCount };
65
+ });
66
+ }
67
+
68
+ if (config.strategy === 'append') {
69
+ const res = await client.query(`INSERT INTO ${target}\n${sql}`);
70
+ return { action: 'incremental append', rowCount: res.rowCount };
71
+ }
72
+
73
+ // delete+insert: compute the SELECT once into a temp table, swap within one txn
74
+ const keys = Array.isArray(config.unique_key) ? config.unique_key : [config.unique_key];
75
+ const temp = quoteIdent(`${name}__dbtjs_incr`);
76
+ const mysql = client.dialect === 'mysql';
77
+ return withTransaction(client, async () => {
78
+ // explicit DROP rather than ON COMMIT DROP — DuckDB silently ignores the latter
79
+ await client.query(`CREATE TEMPORARY TABLE ${temp} AS\n${sql}`);
80
+ const match = keys.map((k) => `t.${quoteIdent(k)} = i.${quoteIdent(k)}`).join(' AND ');
81
+ // MySQL has no Postgres-style DELETE ... USING ... WHERE; its multi-table
82
+ // form references the temp table once per statement, satisfying MySQL's
83
+ // single-reference rule for TEMPORARY tables. SQLite has neither form —
84
+ // correlated EXISTS against the aliased target instead.
85
+ await client.query(
86
+ sqlite
87
+ ? `DELETE FROM ${target} AS t WHERE EXISTS (SELECT 1 FROM ${temp} i WHERE ${match})`
88
+ : mysql
89
+ ? `DELETE t FROM ${target} t JOIN ${temp} i ON ${match}`
90
+ : `DELETE FROM ${target} t USING ${temp} i WHERE ${match}`
91
+ );
92
+ const res = await client.query(`INSERT INTO ${target} SELECT * FROM ${temp}`);
93
+ // TEMPORARY keyword on MySQL: plain DROP TABLE implicitly commits,
94
+ // which would break this transaction's atomicity
95
+ await client.query(`DROP ${mysql ? 'TEMPORARY ' : ''}TABLE ${temp}`);
96
+ return { action: 'incremental delete+insert', rowCount: res.rowCount };
97
+ });
98
+ }
99
+
100
+ async function runHooks(client, hooks, which, ctx) {
101
+ for (const [i, hook] of hooks.entries()) {
102
+ const { sql } = render(hook, ctx);
103
+ try {
104
+ await client.query(sql);
105
+ } catch (e) {
106
+ throw new Error(`${which}[${i}]: ${e.message}`);
107
+ }
108
+ }
109
+ }
110
+
111
+ // Microbatch: split the event-time range into aligned windows; each batch is its
112
+ // own transaction that replaces the target rows inside its window. A failed
113
+ // batch is recorded and the rest keep running (retry via --event-time-start/-end).
114
+ async function runMicrobatch(client, node, projectCfg, opts) {
115
+ const { fullRefresh = false, vars, eventTimeStart, eventTimeEnd, onBatch, kind, hookCtx } = opts;
116
+ const { name, config, rawSql } = node;
117
+ const schema = projectCfg.schema;
118
+ const target = rel(schema, name);
119
+ const firstBuild = fullRefresh || kind !== 'r';
120
+
121
+ const batches = computeBatches({
122
+ begin: config.begin,
123
+ batchSize: config.batch_size,
124
+ lookback: config.lookback,
125
+ start: eventTimeStart,
126
+ end: eventTimeEnd,
127
+ firstBuild,
128
+ timezone: config.timezone,
129
+ });
130
+
131
+ const et = quoteIdent(config.event_time);
132
+ const sqlite = client.dialect === 'sqlite';
133
+ const cascade = sqlite ? '' : ' CASCADE';
134
+ const failed = [];
135
+ let total = 0;
136
+ let countUnknown = false;
137
+ let created = !firstBuild;
138
+
139
+ for (const b of batches) {
140
+ const { sql } = render(rawSql, {
141
+ name,
142
+ schema,
143
+ vars: vars ?? projectCfg.vars,
144
+ isIncremental: !firstBuild,
145
+ sources: projectCfg.sources,
146
+ batchStart: b.start,
147
+ batchEnd: b.end,
148
+ timezone: config.timezone,
149
+ });
150
+ try {
151
+ let rowCount;
152
+ if (!created) {
153
+ rowCount = await withTransaction(client, async () => {
154
+ if (kind === 'v') await client.query(`DROP VIEW IF EXISTS ${target}${cascade}`);
155
+ else await client.query(`DROP TABLE IF EXISTS ${target}${cascade}`);
156
+ const res = await client.query(`CREATE TABLE ${target} AS\n${sql}`);
157
+ return res.rowCount;
158
+ });
159
+ created = true;
160
+ } else {
161
+ rowCount = await withTransaction(client, async () => {
162
+ // SQLite compares timestamps as text, and a day-granularity event_time
163
+ // ('YYYY-MM-DD') sorts BELOW the batch boundary ('YYYY-MM-DD HH:MM:SS'
164
+ // from computeBatches) — datetime() normalizes both shapes
165
+ await client.query(
166
+ sqlite
167
+ ? `DELETE FROM ${target} WHERE datetime(${et}) >= datetime('${b.start}') AND datetime(${et}) < datetime('${b.end}')`
168
+ : `DELETE FROM ${target} WHERE ${et} >= '${b.start}' AND ${et} < '${b.end}'`
169
+ );
170
+ const res = await client.query(`INSERT INTO ${target}\n${sql}`);
171
+ return res.rowCount;
172
+ });
173
+ }
174
+ if (rowCount == null) countUnknown = true;
175
+ else total += rowCount;
176
+ onBatch?.({ ...b, ok: true, rowCount });
177
+ } catch (e) {
178
+ onBatch?.({ ...b, ok: false, message: e.message });
179
+ if (!created) {
180
+ // the target doesn't exist yet, so no later batch can insert into it
181
+ throw new Error(`first batch (${b.start}) failed: ${e.message}`);
182
+ }
183
+ failed.push({ ...b, message: e.message });
184
+ }
185
+ }
186
+
187
+ // skipped on partial failure: the model is already 'fail', don't stamp a
188
+ // success hook (grant, index, audit row) onto an incomplete build
189
+ if (!failed.length) await runHooks(client, config.post_hook, 'post_hook', hookCtx);
190
+
191
+ return {
192
+ action: 'incremental microbatch',
193
+ rowCount: countUnknown ? undefined : total,
194
+ batchCount: batches.length,
195
+ failedBatches: failed,
196
+ };
197
+ }
package/src/project.js ADDED
@@ -0,0 +1,107 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+
4
+ const CONFIG_RE = /\/\*\s*config:\s*([\s\S]*?)\*\//;
5
+ const MATERIALIZATIONS = new Set(['view', 'table', 'incremental']);
6
+ const STRATEGIES = new Set(['append', 'delete+insert', 'microbatch']);
7
+ const BATCH_SIZES = new Set(['hour', 'day', 'month', 'year']);
8
+
9
+ // inlineModels: optional { name: rawSql } map (same format as a model file,
10
+ // config comment included) — when given, models/ is not scanned.
11
+ export function loadProject(cwd = process.cwd(), { models: inlineModels } = {}) {
12
+ const models = [];
13
+ if (inlineModels) {
14
+ for (const [name, rawSql] of Object.entries(inlineModels)) {
15
+ models.push({ name, rawSql, config: parseModelConfig(name, rawSql) });
16
+ }
17
+ } else {
18
+ const modelsDir = join(cwd, 'models');
19
+ if (existsSync(modelsDir)) {
20
+ for (const file of readdirSync(modelsDir).filter((f) => f.endsWith('.sql')).sort()) {
21
+ const path = join(modelsDir, file);
22
+ const rawSql = readFileSync(path, 'utf8');
23
+ const name = basename(file, '.sql');
24
+ models.push({ name, path, rawSql, config: parseModelConfig(name, rawSql) });
25
+ }
26
+ }
27
+ }
28
+
29
+ const seeds = [];
30
+ const seedsDir = join(cwd, 'seeds');
31
+ if (existsSync(seedsDir)) {
32
+ for (const file of readdirSync(seedsDir).filter((f) => f.endsWith('.csv')).sort()) {
33
+ seeds.push({ name: basename(file, '.csv'), path: join(seedsDir, file) });
34
+ }
35
+ }
36
+
37
+ const seen = new Set();
38
+ for (const { name } of [...models, ...seeds]) {
39
+ if (seen.has(name)) throw new Error(`Duplicate node name '${name}' across models/ and seeds/`);
40
+ seen.add(name);
41
+ }
42
+ if (!models.length && !seeds.length) {
43
+ throw new Error(`No models/*.sql or seeds/*.csv found in ${cwd} (and no inline models given)`);
44
+ }
45
+ return { models, seeds };
46
+ }
47
+
48
+ function parseModelConfig(name, rawSql) {
49
+ const match = rawSql.match(CONFIG_RE);
50
+ let config = {};
51
+ if (match) {
52
+ try {
53
+ config = JSON.parse(match[1]);
54
+ } catch (e) {
55
+ throw new Error(`Invalid JSON in config comment of model '${name}': ${e.message}`);
56
+ }
57
+ }
58
+ config.materialized ??= 'view';
59
+ if (!MATERIALIZATIONS.has(config.materialized)) {
60
+ throw new Error(`Model '${name}': unknown materialized '${config.materialized}' (use view|table|incremental)`);
61
+ }
62
+ config.timezone ??= 'UTC';
63
+ if (typeof config.timezone !== 'string') {
64
+ throw new Error(`Model '${name}': "timezone" must be a string (e.g. "UTC", "America/New_York")`);
65
+ }
66
+ try {
67
+ // RangeError on an unknown IANA zone; 'UTC' is always valid
68
+ new Intl.DateTimeFormat('en-US', { timeZone: config.timezone });
69
+ } catch {
70
+ throw new Error(`Model '${name}': unknown timezone '${config.timezone}' (use an IANA name like "America/New_York" or "UTC")`);
71
+ }
72
+ for (const key of ['pre_hook', 'post_hook']) {
73
+ if (typeof config[key] === 'string') config[key] = [config[key]];
74
+ config[key] ??= [];
75
+ if (!Array.isArray(config[key]) || config[key].some((h) => typeof h !== 'string' || !h.trim())) {
76
+ throw new Error(`Model '${name}': "${key}" must be a SQL string or array of SQL strings`);
77
+ }
78
+ }
79
+ if (config.materialized === 'incremental') {
80
+ config.strategy ??= 'append';
81
+ if (!STRATEGIES.has(config.strategy)) {
82
+ throw new Error(`Model '${name}': unknown strategy '${config.strategy}' (use append|delete+insert|microbatch)`);
83
+ }
84
+ if (config.strategy === 'delete+insert' && !config.unique_key) {
85
+ throw new Error(`Model '${name}': strategy delete+insert requires "unique_key"`);
86
+ }
87
+ if (config.strategy === 'microbatch') {
88
+ if (typeof config.event_time !== 'string' || !config.event_time) {
89
+ throw new Error(`Model '${name}': microbatch requires "event_time" (a column of this model)`);
90
+ }
91
+ if (!config.begin || Number.isNaN(Date.parse(String(config.begin).replace(' ', 'T')))) {
92
+ throw new Error(`Model '${name}': microbatch requires "begin" (start of history, e.g. "2026-01-01")`);
93
+ }
94
+ if (!BATCH_SIZES.has(config.batch_size)) {
95
+ throw new Error(`Model '${name}': microbatch requires "batch_size" (hour|day|month|year)`);
96
+ }
97
+ config.lookback ??= 1;
98
+ if (!Number.isInteger(config.lookback) || config.lookback < 0) {
99
+ throw new Error(`Model '${name}': "lookback" must be a non-negative integer`);
100
+ }
101
+ if (config.unique_key) {
102
+ throw new Error(`Model '${name}': "unique_key" is not used by microbatch (batches replace by event_time window)`);
103
+ }
104
+ }
105
+ }
106
+ return config;
107
+ }
package/src/render.js ADDED
@@ -0,0 +1,62 @@
1
+ // Minimal template renderer. Supported constructs:
2
+ // {{ ref('model') }} {{ this }} {{ source('src', 'table') }}
3
+ // {{ var('name') }} {{ var('name', default) }}
4
+ // {{ batch_start }} {{ batch_end }} (microbatch models only)
5
+ // {{ timezone }} (the model's config timezone)
6
+ // {% if is_incremental() %} ... {% endif %} (no nesting)
7
+ const CONFIG_RE = /\/\*\s*config:\s*[\s\S]*?\*\//;
8
+ const IF_INCREMENTAL_RE = /\{%\s*if\s+is_incremental\(\)\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/g;
9
+ const REF_RE = /\{\{\s*ref\(\s*['"](\w+)['"]\s*\)\s*\}\}/g;
10
+ const THIS_RE = /\{\{\s*this\s*\}\}/g;
11
+ const SOURCE_RE = /\{\{\s*source\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)\s*\}\}/g;
12
+ const VAR_RE = /\{\{\s*var\(\s*['"](\w+)['"]\s*(?:,\s*('[^']*'|"[^"]*"|[^)\s]+))?\s*\)\s*\}\}/g;
13
+ const BATCH_RE = /\{\{\s*(batch_start|batch_end)\s*\}\}/g;
14
+ const TIMEZONE_RE = /\{\{\s*timezone\s*\}\}/g;
15
+ const LEFTOVER_RE = /\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\}|\{\{|\{%/;
16
+
17
+ const quoteIdent = (s) => `"${s.replace(/"/g, '""')}"`;
18
+ const stripQuotes = (s) => (/^(['"]).*\1$/s.test(s) ? s.slice(1, -1) : s);
19
+
20
+ // Cheap dependency extraction for DAG building — scans ref() calls without
21
+ // rendering, so missing vars or incremental branches can't hide a dependency.
22
+ export function extractRefs(rawSql) {
23
+ return [...rawSql.matchAll(REF_RE)].map((m) => m[1]);
24
+ }
25
+
26
+ // ctx: { name, schema, vars, isIncremental, sources, batchStart?, batchEnd?, timezone? }
27
+ export function render(rawSql, ctx) {
28
+ const refs = [];
29
+ let sql = rawSql.replace(CONFIG_RE, '');
30
+ sql = sql.replace(IF_INCREMENTAL_RE, (_, body) => (ctx.isIncremental ? body : ''));
31
+ sql = sql.replace(REF_RE, (_, name) => {
32
+ refs.push(name);
33
+ return `${quoteIdent(ctx.schema)}.${quoteIdent(name)}`;
34
+ });
35
+ sql = sql.replace(THIS_RE, () => `${quoteIdent(ctx.schema)}.${quoteIdent(ctx.name)}`);
36
+ sql = sql.replace(SOURCE_RE, (_, src, table) => {
37
+ const decl = ctx.sources?.[src];
38
+ if (!decl?.schema) {
39
+ throw new Error(
40
+ `'${ctx.name}' uses undeclared source '${src}' — add it under "sources" in dbtjs.config.json`
41
+ );
42
+ }
43
+ return `${quoteIdent(decl.schema)}.${quoteIdent(table)}`;
44
+ });
45
+ if (ctx.batchStart != null) {
46
+ // only microbatch runs supply these; elsewhere the token falls through to the leftover guard
47
+ sql = sql.replace(BATCH_RE, (_, which) => (which === 'batch_start' ? ctx.batchStart : ctx.batchEnd));
48
+ }
49
+ // raw substitution (like batch_start) — author quotes it in SQL if needed
50
+ sql = sql.replace(TIMEZONE_RE, ctx.timezone ?? 'UTC');
51
+ sql = sql.replace(VAR_RE, (_, name, def) => {
52
+ const value = ctx.vars?.[name];
53
+ if (value !== undefined && value !== null) return String(value);
54
+ if (def !== undefined) return stripQuotes(def);
55
+ throw new Error(`Missing var '${name}' in '${ctx.name}' (no default given) — pass --vars '{"${name}": ...}'`);
56
+ });
57
+ const leftover = sql.match(LEFTOVER_RE);
58
+ if (leftover) {
59
+ throw new Error(`Unrecognized template expression in '${ctx.name}': ${leftover[0].slice(0, 80)}`);
60
+ }
61
+ return { sql: sql.trim(), refs };
62
+ }
package/src/seed.js ADDED
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parse } from 'csv-parse/sync';
3
+ import { quoteIdent, rel, withTransaction } from './db.js';
4
+
5
+ const BATCH_SIZE = 500;
6
+
7
+ export async function loadSeed(client, seed, projectCfg) {
8
+ const rows = parse(readFileSync(seed.path, 'utf8'), {
9
+ columns: true,
10
+ skip_empty_lines: true,
11
+ trim: true,
12
+ });
13
+ if (!rows.length) throw new Error(`Seed '${seed.name}' has no data rows`);
14
+
15
+ const columns = Object.keys(rows[0]);
16
+ const overrides = projectCfg.seeds?.columnTypes?.[seed.name] ?? {};
17
+ const mysql = client.dialect === 'mysql';
18
+ const sqlite = client.dialect === 'sqlite';
19
+ const types = columns.map((c) => {
20
+ const t = overrides[c] ?? inferType(rows.map((r) => r[c]));
21
+ // bare NUMERIC is DECIMAL(10,0) on MySQL — would silently round decimals
22
+ return mysql && t === 'numeric' ? 'decimal(38,10)' : t;
23
+ });
24
+ const target = rel(projectCfg.schema, seed.name);
25
+ // stay under SQLite's 32766-bind-variable cap (and Postgres's 65535) for wide CSVs
26
+ const batchSize = Math.max(1, Math.min(BATCH_SIZE, Math.floor(32000 / columns.length)));
27
+
28
+ await withTransaction(client, async () => {
29
+ await client.query(`DROP TABLE IF EXISTS ${target}${sqlite ? '' : ' CASCADE'}`);
30
+ const defs = columns.map((c, i) => `${quoteIdent(c)} ${types[i]}`).join(', ');
31
+ await client.query(`CREATE TABLE ${target} (${defs})`);
32
+ for (let i = 0; i < rows.length; i += batchSize) {
33
+ const batch = rows.slice(i, i + batchSize);
34
+ const params = [];
35
+ const tuples = batch.map(
36
+ (row) =>
37
+ `(${columns
38
+ .map((c, j) => {
39
+ let v = row[c] === '' ? null : row[c];
40
+ // MySQL booleans are TINYINT(1); the string 'true' errors under
41
+ // strict mode. SQLite would store the TEXT 'true', which is falsy
42
+ // in CASE WHEN (and better-sqlite3 can't bind true/false anyway).
43
+ if ((mysql || sqlite) && v !== null && types[j] === 'boolean')
44
+ v = /^(true|t)$/i.test(v) ? 1 : 0;
45
+ params.push(v);
46
+ return `$${params.length}`;
47
+ })
48
+ .join(', ')})`
49
+ );
50
+ await client.query(`INSERT INTO ${target} VALUES ${tuples.join(', ')}`, params);
51
+ }
52
+ });
53
+ return { rowCount: rows.length };
54
+ }
55
+
56
+ // Minimal inference: integer/bigint, numeric, boolean, else text.
57
+ // Empty strings load as NULL and are excluded from inference.
58
+ // Anything fancier (dates, etc.) → seeds.columnTypes override in dbtjs.config.json.
59
+ export function inferType(values) {
60
+ const present = values.filter((v) => v !== '');
61
+ if (!present.length) return 'text';
62
+ if (present.every((v) => /^-?\d+$/.test(v))) {
63
+ return present.some((v) => Math.abs(Number(v)) > 2147483647) ? 'bigint' : 'integer';
64
+ }
65
+ if (present.every((v) => /^-?\d*\.?\d+$/.test(v))) return 'numeric';
66
+ if (present.every((v) => /^(true|false|t|f)$/i.test(v))) return 'boolean';
67
+ return 'text';
68
+ }
package/src/tests.js ADDED
@@ -0,0 +1,49 @@
1
+ import { quoteIdent, rel } from './db.js';
2
+
3
+ // Each test compiles to a SELECT returning violating rows; any row = FAIL.
4
+ // NULLs only violate not_null (dbt semantics).
5
+ export function buildTests(models, schema) {
6
+ const tests = [];
7
+ for (const model of models) {
8
+ for (const [column, specs] of Object.entries(model.config.tests ?? {})) {
9
+ const target = rel(schema, model.name);
10
+ const col = quoteIdent(column);
11
+ for (const spec of specs) {
12
+ if (spec === 'not_null') {
13
+ tests.push({
14
+ id: `${model.name}.${column}.not_null`,
15
+ model: model.name,
16
+ sql: `SELECT * FROM ${target} WHERE ${col} IS NULL`,
17
+ params: [],
18
+ });
19
+ } else if (spec === 'unique') {
20
+ tests.push({
21
+ id: `${model.name}.${column}.unique`,
22
+ model: model.name,
23
+ sql: `SELECT ${col}, count(*) AS n FROM ${target} WHERE ${col} IS NOT NULL GROUP BY ${col} HAVING count(*) > 1`,
24
+ params: [],
25
+ });
26
+ } else if (spec?.accepted_values?.length) {
27
+ const placeholders = spec.accepted_values.map((_, i) => `$${i + 1}`).join(', ');
28
+ tests.push({
29
+ id: `${model.name}.${column}.accepted_values`,
30
+ model: model.name,
31
+ sql: `SELECT ${col}, count(*) AS n FROM ${target} WHERE ${col} IS NOT NULL AND ${col} NOT IN (${placeholders}) GROUP BY ${col}`,
32
+ params: spec.accepted_values,
33
+ });
34
+ } else {
35
+ throw new Error(`Unknown test ${JSON.stringify(spec)} on ${model.name}.${column}`);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ return tests;
41
+ }
42
+
43
+ export async function runTest(client, test) {
44
+ const count = await client.query(`SELECT count(*) AS n FROM (${test.sql}) q`, test.params);
45
+ const violations = Number(count.rows[0].n);
46
+ if (violations === 0) return { pass: true };
47
+ const sample = await client.query(`${test.sql} LIMIT 10`, test.params);
48
+ return { pass: false, violations, sample: sample.rows };
49
+ }