@lightdash/cli 1.147.0 → 1.148.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,13 @@
1
+ import { type CompileHandlerOptions } from './compile';
2
+ type PreAggregateCheckExternalOptions = CompileHandlerOptions & {
3
+ model: string | undefined;
4
+ name: string | undefined;
5
+ all: boolean;
6
+ json: boolean;
7
+ sql: boolean;
8
+ failOnMismatch: boolean;
9
+ clearCache: boolean;
10
+ };
11
+ export declare const preAggregateCheckExternalHandler: (options: PreAggregateCheckExternalOptions) => Promise<void>;
12
+ export {};
13
+ //# sourceMappingURL=preAggregateCheckExternal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preAggregateCheckExternal.d.ts","sourceRoot":"","sources":["../../src/handlers/preAggregateCheckExternal.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAW,KAAK,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAIhE,KAAK,gCAAgC,GAAG,qBAAqB,GAAG;IAC5D,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,GAAG,EAAE,OAAO,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,OAAO,CAAC;IACb,cAAc,EAAE,OAAO,CAAC;IACxB,UAAU,EAAE,OAAO,CAAC;CACvB,CAAC;AAsZF,eAAO,MAAM,gCAAgC,YAChC,gCAAgC,kBAiJ5C,CAAC"}
@@ -0,0 +1,372 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.preAggregateCheckExternalHandler = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@lightdash/common");
6
+ const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
7
+ const path = tslib_1.__importStar(require("path"));
8
+ const context_1 = require("../dbt/context");
9
+ const globalState_1 = tslib_1.__importDefault(require("../globalState"));
10
+ const styles = tslib_1.__importStar(require("../styles"));
11
+ const compile_1 = require("./compile");
12
+ const getDbtVersion_1 = require("./dbt/getDbtVersion");
13
+ const getWarehouseClient_1 = tslib_1.__importDefault(require("./dbt/getWarehouseClient"));
14
+ const CLI_QUERY_TAGS = { query_context: common_1.QueryExecutionContext.CLI };
15
+ const probeShape = async (warehouseClient, sql, clearCache) => {
16
+ let fields = {};
17
+ let hasRows = false;
18
+ // Nonce defeats result caches keyed on query text (BigQuery/Snowflake
19
+ // serve the stale schema for a repeated probe after a table is replaced)
20
+ const probeSql = clearCache ? `${sql} -- ${Date.now().toString(36)}` : sql;
21
+ await warehouseClient.streamQuery(probeSql, ({ fields: streamedFields, rows }) => {
22
+ if (Object.keys(streamedFields).length > 0) {
23
+ fields = streamedFields;
24
+ }
25
+ if (rows.length > 0) {
26
+ hasRows = true;
27
+ }
28
+ }, { tags: CLI_QUERY_TAGS });
29
+ return { fields, hasRows };
30
+ };
31
+ const confirmWarehouseValidation = async () => {
32
+ const interactive = !globalState_1.default.isNonInteractive() &&
33
+ process.stdin.isTTY === true &&
34
+ process.stdout.isTTY === true;
35
+ if (!interactive)
36
+ return true;
37
+ const { confirmed } = await inquirer_1.default.prompt([
38
+ {
39
+ type: 'confirm',
40
+ name: 'confirmed',
41
+ message: 'Run warehouse validation queries? This executes the generated materialization SQL and external table checks with LIMIT 1.',
42
+ default: false,
43
+ },
44
+ ]);
45
+ return confirmed;
46
+ };
47
+ const editDistance = (a, b) => {
48
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => {
49
+ const row = Array.from({ length: b.length + 1 }, () => 0);
50
+ row[0] = i;
51
+ return row;
52
+ });
53
+ for (let j = 0; j <= b.length; j += 1)
54
+ dp[0][j] = j;
55
+ for (let i = 1; i <= a.length; i += 1) {
56
+ for (let j = 1; j <= b.length; j += 1) {
57
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
58
+ }
59
+ }
60
+ return dp[a.length][b.length];
61
+ };
62
+ const describeRole = (column) => {
63
+ switch (column.role) {
64
+ case common_1.preAggregateMaterialization.MaterializationColumnRole
65
+ .TIME_DIMENSION:
66
+ return `time dimension (${column.granularity?.toLowerCase()})`;
67
+ case common_1.preAggregateMaterialization.MaterializationColumnRole.METRIC:
68
+ return `metric (${column.aggregation})`;
69
+ case common_1.preAggregateMaterialization.MaterializationColumnRole
70
+ .METRIC_COMPONENT:
71
+ return 'metric component';
72
+ case common_1.preAggregateMaterialization.MaterializationColumnRole.DIMENSION:
73
+ return 'dimension';
74
+ default:
75
+ return (0, common_1.assertUnreachable)(column.role, `Unknown column role: ${column.role}`);
76
+ }
77
+ };
78
+ const checkPreAggregate = async ({ explore, preAggregateDef, warehouseClient, clearCache, executeValidation, }) => {
79
+ const base = {
80
+ model: explore.name,
81
+ name: preAggregateDef.name,
82
+ table: preAggregateDef.table ?? null,
83
+ sql: null,
84
+ columns: [],
85
+ extraColumns: [],
86
+ tableError: null,
87
+ error: null,
88
+ validationSkippedReason: null,
89
+ };
90
+ let rendered;
91
+ try {
92
+ rendered = common_1.preAggregateMaterialization.renderMaterializationSql({
93
+ sourceExplore: explore,
94
+ preAggregateDef,
95
+ warehouseSqlBuilder: warehouseClient,
96
+ });
97
+ }
98
+ catch (e) {
99
+ return { ...base, error: (0, common_1.getErrorMessage)(e) };
100
+ }
101
+ base.sql = rendered.sql;
102
+ base.columns = rendered.columns.map((column) => ({
103
+ name: column.name,
104
+ role: describeRole(column),
105
+ expectedType: null,
106
+ actualType: null,
107
+ status: null,
108
+ }));
109
+ if (!executeValidation) {
110
+ base.validationSkippedReason = 'permission_declined';
111
+ return base;
112
+ }
113
+ // Match SQL Runner column discovery: execute one row and read its fields.
114
+ let expectedFields;
115
+ try {
116
+ const probe = await probeShape(warehouseClient, `SELECT * FROM (\n${rendered.sql}\n) AS ld_shape_probe LIMIT 1`, clearCache);
117
+ if (!probe.hasRows) {
118
+ base.validationSkippedReason = 'generated_query_no_rows';
119
+ return base;
120
+ }
121
+ expectedFields = probe.fields;
122
+ }
123
+ catch (e) {
124
+ return {
125
+ ...base,
126
+ error: `Generated materialization SQL failed against the warehouse: ${(0, common_1.getErrorMessage)(e)}`,
127
+ };
128
+ }
129
+ let actualFields = null;
130
+ if (preAggregateDef.table) {
131
+ try {
132
+ const probe = await probeShape(warehouseClient, `SELECT * FROM ${preAggregateDef.table} AS ld_shape_probe LIMIT 1`, clearCache);
133
+ if (!probe.hasRows) {
134
+ base.validationSkippedReason = 'external_table_no_rows';
135
+ }
136
+ else {
137
+ actualFields = probe.fields;
138
+ }
139
+ }
140
+ catch (e) {
141
+ base.tableError = (0, common_1.getErrorMessage)(warehouseClient.parseError(e instanceof Error ? e : new Error((0, common_1.getErrorMessage)(e))));
142
+ }
143
+ }
144
+ base.columns = rendered.columns.map((column) => {
145
+ const expectedType = expectedFields[column.name]?.type ?? null;
146
+ const actualType = actualFields?.[column.name]?.type ?? null;
147
+ let status = null;
148
+ if (actualFields) {
149
+ if (actualType === null) {
150
+ status = 'missing';
151
+ }
152
+ else if (expectedType !== null && actualType !== expectedType) {
153
+ status = 'type_mismatch';
154
+ }
155
+ else {
156
+ status = 'match';
157
+ }
158
+ }
159
+ return {
160
+ name: column.name,
161
+ role: describeRole(column),
162
+ expectedType,
163
+ actualType,
164
+ status,
165
+ };
166
+ });
167
+ if (actualFields) {
168
+ const expectedNames = new Set(base.columns.map((c) => c.name));
169
+ const missingNames = base.columns
170
+ .filter((c) => c.status === 'missing')
171
+ .map((c) => c.name);
172
+ base.extraColumns = Object.entries(actualFields)
173
+ .filter(([name]) => !expectedNames.has(name))
174
+ .map(([name, { type }]) => {
175
+ const closest = missingNames
176
+ .map((candidate) => ({
177
+ candidate,
178
+ distance: editDistance(name, candidate),
179
+ }))
180
+ .sort((a, b) => a.distance - b.distance)[0];
181
+ return {
182
+ name,
183
+ type,
184
+ didYouMean: closest &&
185
+ closest.distance <=
186
+ Math.max(3, Math.floor(closest.candidate.length / 4))
187
+ ? closest.candidate
188
+ : null,
189
+ };
190
+ });
191
+ }
192
+ return base;
193
+ };
194
+ const hasMismatch = (result) => result.error !== null ||
195
+ result.tableError !== null ||
196
+ result.columns.some((column) => column.status === 'missing');
197
+ const renderHuman = (result, log) => {
198
+ const tableLabel = result.table
199
+ ? ` → ${result.table}`
200
+ : ` ${styles.secondary('(no table: declared — expected shape only)')}`;
201
+ log('');
202
+ log(`${styles.bold(`${result.model} / ${result.name}`)}${tableLabel}`);
203
+ if (result.error) {
204
+ log(` ${styles.error('✗')} ${result.error}`);
205
+ return;
206
+ }
207
+ if (result.tableError) {
208
+ log(` ${styles.warning('⚠')} Could not introspect declared table: ${result.tableError}`);
209
+ }
210
+ const nameWidth = Math.max(...result.columns.map((c) => c.name.length), 'EXPECTED'.length);
211
+ const typeWidth = Math.max(...result.columns.map((c) => (c.expectedType ?? '').length), 'TYPE'.length);
212
+ const roleWidth = Math.max(...result.columns.map((c) => c.role.length), 'ROLE'.length);
213
+ log('');
214
+ log(` ${'EXPECTED'.padEnd(nameWidth)} ${'TYPE'.padEnd(typeWidth)} ${'ROLE'.padEnd(roleWidth)}${result.columns.some((c) => c.status !== null) ? ' ACTUAL' : ''}`);
215
+ result.columns.forEach((column) => {
216
+ let actual = '';
217
+ switch (column.status) {
218
+ case 'match':
219
+ actual = ` ${styles.success('✓')} ${column.actualType}`;
220
+ break;
221
+ case 'type_mismatch':
222
+ actual = ` ${styles.warning('⚠')} ${column.actualType} ${styles.secondary(`(expected ${column.expectedType})`)}`;
223
+ break;
224
+ case 'missing':
225
+ actual = ` ${styles.error('✗')} missing`;
226
+ break;
227
+ case null:
228
+ break;
229
+ default:
230
+ (0, common_1.assertUnreachable)(column.status, `Unknown column status: ${column.status}`);
231
+ }
232
+ log(` ${column.name.padEnd(nameWidth)} ${(column.expectedType ?? '?').padEnd(typeWidth)} ${column.role.padEnd(roleWidth)}${actual}`);
233
+ });
234
+ result.extraColumns.forEach((extra) => {
235
+ const hint = extra.didYouMean
236
+ ? styles.secondary(` — did you mean "${extra.didYouMean}"?`)
237
+ : '';
238
+ log(` ${extra.name} ${styles.secondary(`(${extra.type})`)} ${styles.warning('unmatched actual column')}${hint}`);
239
+ });
240
+ log('');
241
+ if (result.validationSkippedReason) {
242
+ switch (result.validationSkippedReason) {
243
+ case 'permission_declined':
244
+ log(` ${styles.secondary('Warehouse validation skipped — showing generated SQL only.')}`);
245
+ break;
246
+ case 'generated_query_no_rows':
247
+ log(` ${styles.warning('Generated query returned no rows — unable to validate.')}`);
248
+ break;
249
+ case 'external_table_no_rows':
250
+ log(` ${styles.warning(`${result.table} returned no rows — unable to validate.`)}`);
251
+ break;
252
+ default:
253
+ (0, common_1.assertUnreachable)(result.validationSkippedReason, `Unknown validation skipped reason: ${result.validationSkippedReason}`);
254
+ }
255
+ }
256
+ else if (result.columns.some((c) => c.status !== null)) {
257
+ const missingCount = result.columns.filter((c) => c.status === 'missing').length;
258
+ const matchCount = result.columns.length - missingCount;
259
+ if (missingCount > 0) {
260
+ log(` ${styles.error('✗')} ${missingCount} missing column${missingCount === 1 ? '' : 's'}. Rebuild ${result.table} with the SQL below, then re-run.`);
261
+ }
262
+ else {
263
+ log(` ${styles.success('✓')} Table matches — ${matchCount}/${result.columns.length} columns.`);
264
+ }
265
+ }
266
+ else if (!result.tableError) {
267
+ log(` ${styles.secondary('No live table checked — showing expected shape and SQL only.')}`);
268
+ }
269
+ };
270
+ const preAggregateCheckExternalHandler = async (options) => {
271
+ globalState_1.default.setVerbose(options.verbose);
272
+ if (!options.all && (!options.model || !options.name)) {
273
+ console.error(styles.error('Specify --model <model> and --name <pre-aggregate-name>, or --all for every external pre-aggregate.'));
274
+ process.exit(1);
275
+ }
276
+ const dbtVersion = await (0, getDbtVersion_1.getDbtVersion)();
277
+ if (dbtVersion.isDbtCloudCLI) {
278
+ console.error(styles.error('pre-aggregate-check-external needs direct warehouse access and does not support the dbt Cloud CLI.'));
279
+ process.exit(1);
280
+ }
281
+ const executeValidation = await confirmWarehouseValidation();
282
+ const explores = await (0, compile_1.compile)(executeValidation
283
+ ? options
284
+ : { ...options, skipWarehouseCatalog: true });
285
+ const validExplores = explores.filter((explore) => !(0, common_1.isExploreError)(explore));
286
+ const targets = [];
287
+ if (options.all) {
288
+ validExplores.forEach((explore) => {
289
+ (explore.preAggregates ?? []).forEach((preAggregateDef) => {
290
+ if (preAggregateDef.table) {
291
+ targets.push({ explore, preAggregateDef });
292
+ }
293
+ });
294
+ });
295
+ if (targets.length === 0) {
296
+ console.error(styles.warning('No external pre-aggregates (with a `table:` key) found in the project.'));
297
+ return;
298
+ }
299
+ }
300
+ else {
301
+ const explore = validExplores.find((candidate) => candidate.name === options.model);
302
+ if (!explore) {
303
+ console.error(styles.error(`Model "${options.model}" not found in project.`));
304
+ process.exit(1);
305
+ }
306
+ const preAggregateDef = (explore.preAggregates ?? []).find((candidate) => candidate.name === options.name);
307
+ if (!preAggregateDef) {
308
+ console.error(styles.error(`Pre-aggregate "${options.name}" not found on model "${options.model}". Available: ${(explore.preAggregates ?? [])
309
+ .map((candidate) => candidate.name)
310
+ .join(', ')}`));
311
+ process.exit(1);
312
+ }
313
+ targets.push({ explore, preAggregateDef });
314
+ }
315
+ const dbtContext = await (0, context_1.getDbtContext)({
316
+ projectDir: path.resolve(options.projectDir),
317
+ targetPath: options.targetPath,
318
+ });
319
+ const { warehouseClient } = await (0, getWarehouseClient_1.default)({
320
+ isDbtCloudCLI: false,
321
+ profilesDir: options.profilesDir,
322
+ profile: options.profile || dbtContext.profileName,
323
+ target: options.target,
324
+ startOfWeek: options.startOfWeek,
325
+ });
326
+ const results = [];
327
+ // Sequential on purpose: one warehouse connection, readable progress
328
+ for await (const target of targets) {
329
+ results.push(await checkPreAggregate({
330
+ explore: target.explore,
331
+ preAggregateDef: target.preAggregateDef,
332
+ warehouseClient,
333
+ clearCache: options.clearCache,
334
+ executeValidation,
335
+ }));
336
+ }
337
+ // --sql keeps stdout machine-readable; humans read stderr
338
+ const log = options.sql
339
+ ? (line) => console.error(line)
340
+ : (line) => console.log(line);
341
+ if (options.json) {
342
+ console.log(JSON.stringify(options.all ? results : results[0], null, 2));
343
+ }
344
+ else {
345
+ results.forEach((result) => {
346
+ renderHuman(result, log);
347
+ if (result.sql && !options.sql) {
348
+ log('');
349
+ log(` Materialization SQL (${warehouseClient.getAdapterType()}):`);
350
+ log('');
351
+ log(result.sql
352
+ .split('\n')
353
+ .map((line) => ` ${line}`)
354
+ .join('\n'));
355
+ }
356
+ });
357
+ if (options.sql) {
358
+ results.forEach((result) => {
359
+ if (result.sql) {
360
+ if (results.length > 1) {
361
+ console.log(`-- ${result.model} / ${result.name}`);
362
+ }
363
+ console.log(result.sql);
364
+ }
365
+ });
366
+ }
367
+ }
368
+ if (options.failOnMismatch && results.some(hasMismatch)) {
369
+ process.exit(1);
370
+ }
371
+ };
372
+ exports.preAggregateCheckExternalHandler = preAggregateCheckExternalHandler;
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ const lint_1 = require("./handlers/lint");
27
27
  const listProjects_1 = require("./handlers/listProjects");
28
28
  const login_1 = require("./handlers/login");
29
29
  const preAggregateAudit_1 = require("./handlers/preAggregateAudit");
30
+ const preAggregateCheckExternal_1 = require("./handlers/preAggregateCheckExternal");
30
31
  const preview_2 = require("./handlers/preview");
31
32
  const renameHandler_1 = require("./handlers/renameHandler");
32
33
  const renameProject_1 = require("./handlers/renameProject");
@@ -652,6 +653,26 @@ commander_1.program
652
653
  .option('--fail-on-miss', 'Exit 1 if any eligible tile is a pre-aggregate miss (CI-friendly)', false)
653
654
  .option('--verbose', 'Expand the ineligible section in human output', false)
654
655
  .action(preAggregateAudit_1.preAggregateAuditHandler);
656
+ commander_1.program
657
+ .command('pre-aggregate-check-external')
658
+ .description('Generate the expected table shape and materialization SQL for an external pre-aggregate, and check the declared table against it. Runs fully locally against the dbt project and warehouse credentials from profiles.yml.')
659
+ .option('--model <model>', 'Model the pre-aggregate is defined on')
660
+ .option('--name <name>', 'Pre-aggregate name')
661
+ .option('--all', 'Check every external pre-aggregate (with a `table:` key) in the project', false)
662
+ .option('--project-dir <path>', 'The directory of the dbt project', env_1.DEFAULT_DBT_PROJECT_DIR)
663
+ .option('--profiles-dir <path>', 'The directory of the dbt profiles', env_1.DEFAULT_DBT_PROFILES_DIR)
664
+ .option('--profile <name>', 'The name of the profile to use (defaults to profile name in dbt_project.yml)', undefined)
665
+ .option('--target <name>', 'target to use in profiles.yml file', undefined)
666
+ .option('--target-path <path>', 'The target directory for dbt (overrides DBT_TARGET_PATH and dbt_project.yml)', undefined)
667
+ .option('--vars <vars>')
668
+ .option('--skip-dbt-compile', 'Skip `dbt compile` and deploy from the existing ./target/manifest.json', false)
669
+ .option('--skip-warehouse-catalog', 'Skip fetch warehouse catalog and use types defined in the YAML.', false)
670
+ .option('--json', 'Emit machine-readable JSON instead of human output', false)
671
+ .option('--sql', 'Print bare materialization SQL on stdout (human output goes to stderr)', false)
672
+ .option('--fail-on-mismatch', 'Exit 1 if any checked pre-aggregate has missing columns, an unreachable table, or an SQL error (CI-friendly)', false)
673
+ .option('--clear-cache', 'Bypass warehouse result caches when probing table shapes (use after rebuilding a table, or the warehouse may report its old schema)', false)
674
+ .option('--verbose', undefined, false)
675
+ .action(preAggregateCheckExternal_1.preAggregateCheckExternalHandler);
655
676
  commander_1.program
656
677
  .command('sql')
657
678
  .description('Run raw SQL query against the warehouse using project credentials')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightdash/cli",
3
- "version": "1.147.0",
3
+ "version": "1.148.0",
4
4
  "description": "Lightdash CLI tool",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,8 +42,8 @@
42
42
  "unique-names-generator": "4.7.1",
43
43
  "uuid": "11.1.1",
44
44
  "yaml": "2.8.3",
45
- "@lightdash/warehouses": "1.147.0",
46
- "@lightdash/common": "1.147.0"
45
+ "@lightdash/common": "1.148.0",
46
+ "@lightdash/warehouses": "1.148.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/inquirer": "8.2.4",