@duckcodeailabs/dql-cli 0.8.5 → 0.8.7
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/README.md +13 -4
- package/dist/args.d.ts +1 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +4 -0
- package/dist/args.js.map +1 -1
- package/dist/assets/dql-notebook/assets/index-CTmiMNUc.js +558 -0
- package/dist/assets/dql-notebook/index.html +1 -1
- package/dist/block-templates.d.ts +8 -0
- package/dist/block-templates.d.ts.map +1 -0
- package/dist/block-templates.js +60 -0
- package/dist/block-templates.js.map +1 -0
- package/dist/commands/build.test.js +1 -1
- package/dist/commands/build.test.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +17 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +1 -1
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +73 -5
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +82 -2
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/new.test.js +7 -7
- package/dist/commands/new.test.js.map +1 -1
- package/dist/commands/notebook.d.ts.map +1 -1
- package/dist/commands/notebook.js +2 -2
- package/dist/commands/notebook.js.map +1 -1
- package/dist/commands/semantic.d.ts +2 -0
- package/dist/commands/semantic.d.ts.map +1 -1
- package/dist/commands/semantic.js +115 -1
- package/dist/commands/semantic.js.map +1 -1
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/dist/local-runtime.d.ts +35 -0
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +1668 -45
- package/dist/local-runtime.js.map +1 -1
- package/dist/local-runtime.test.js +69 -1
- package/dist/local-runtime.test.js.map +1 -1
- package/dist/semantic-import.d.ts +127 -0
- package/dist/semantic-import.d.ts.map +1 -0
- package/dist/semantic-import.js +713 -0
- package/dist/semantic-import.js.map +1 -0
- package/dist/semantic-import.test.d.ts +2 -0
- package/dist/semantic-import.test.d.ts.map +1 -0
- package/dist/semantic-import.test.js +278 -0
- package/dist/semantic-import.test.js.map +1 -0
- package/package.json +9 -8
- package/dist/assets/dql-notebook/assets/index-Rushqlh8.js +0 -524
package/dist/local-runtime.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs';
|
|
3
|
-
import { dirname, extname, join, normalize, resolve } from 'node:path';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, extname, join, normalize, relative, resolve } from 'node:path';
|
|
4
4
|
import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, } from '@duckcodeailabs/dql-notebook';
|
|
5
5
|
import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, Parser, buildLineageGraph, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, } from '@duckcodeailabs/dql-core';
|
|
6
|
+
import { listBlockTemplates } from './block-templates.js';
|
|
7
|
+
import { buildSemanticObjectDetail, buildSemanticTree, computeSyncDiff, loadSemanticImportManifest, performSemanticImport, previewSemanticImport, syncSemanticImport, } from './semantic-import.js';
|
|
6
8
|
export async function startLocalServer(opts) {
|
|
7
|
-
const { rootDir, executor, connection, preferredPort, projectRoot = process.cwd() } = opts;
|
|
8
|
-
|
|
9
|
+
const { rootDir, executor, connection: rawConnection, preferredPort, projectRoot = process.cwd() } = opts;
|
|
10
|
+
let connection = normalizeProjectConnection(rawConnection, projectRoot);
|
|
11
|
+
let projectConfig = loadProjectConfig(projectRoot);
|
|
9
12
|
// Load semantic layer via provider system (dql native, dbt, cubejs, etc.)
|
|
10
13
|
let semanticLayer;
|
|
11
14
|
let semanticLayerErrors = [];
|
|
12
15
|
let semanticDetectedProvider;
|
|
13
16
|
const semanticLayerDir = join(projectRoot, 'semantic-layer');
|
|
17
|
+
let semanticImportManifest = loadSemanticImportManifest(projectRoot);
|
|
18
|
+
const userPrefsPath = join(projectRoot, '.dql-user-prefs.json');
|
|
14
19
|
const semanticConfig = projectConfig.semanticLayer;
|
|
20
|
+
let semanticLastSyncTime = null;
|
|
15
21
|
{
|
|
16
22
|
const executeQuery = semanticConfig?.provider === 'snowflake'
|
|
17
23
|
? async (sql) => { const r = await executor.executeQuery(sql, [], {}, connection); return { rows: r.rows }; }
|
|
@@ -20,10 +26,13 @@ export async function startLocalServer(opts) {
|
|
|
20
26
|
semanticLayer = result.layer;
|
|
21
27
|
semanticLayerErrors = result.errors;
|
|
22
28
|
semanticDetectedProvider = result.detectedProvider;
|
|
29
|
+
semanticLastSyncTime = result.layer ? new Date().toISOString() : null;
|
|
30
|
+
semanticImportManifest = loadSemanticImportManifest(projectRoot);
|
|
23
31
|
// Legacy fallback if provider system returned nothing and no errors
|
|
24
32
|
if (!semanticLayer && semanticLayerErrors.length === 0 && existsSync(semanticLayerDir)) {
|
|
25
33
|
try {
|
|
26
34
|
semanticLayer = loadSemanticLayerFromDir(semanticLayerDir);
|
|
35
|
+
semanticLastSyncTime = new Date().toISOString();
|
|
27
36
|
}
|
|
28
37
|
catch { /* continue without */ }
|
|
29
38
|
}
|
|
@@ -83,6 +92,8 @@ export async function startLocalServer(opts) {
|
|
|
83
92
|
if (refreshed.layer) {
|
|
84
93
|
semanticLayer = refreshed.layer;
|
|
85
94
|
semanticLayerErrors = refreshed.errors;
|
|
95
|
+
semanticLastSyncTime = new Date().toISOString();
|
|
96
|
+
semanticImportManifest = loadSemanticImportManifest(projectRoot);
|
|
86
97
|
}
|
|
87
98
|
else if (refreshed.errors.length > 0) {
|
|
88
99
|
semanticLayerErrors = refreshed.errors;
|
|
@@ -221,32 +232,14 @@ export async function startLocalServer(opts) {
|
|
|
221
232
|
if (req.method === 'GET' && path === '/api/schema') {
|
|
222
233
|
try {
|
|
223
234
|
const dataFiles = scanDataFiles(projectRoot);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return { name: qualifiedName, path: qualifiedName, columns: [], source: 'database' };
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
else {
|
|
236
|
-
// Fallback: query information_schema directly
|
|
237
|
-
const result = await executor.executeQuery(`SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`, [], {}, connection);
|
|
238
|
-
dbTables = result.rows.map((row) => {
|
|
239
|
-
const schema = String(row['table_schema'] ?? '');
|
|
240
|
-
const name = String(row['table_name'] ?? '');
|
|
241
|
-
const qualifiedName = schema ? `${schema}.${name}` : name;
|
|
242
|
-
return { name: qualifiedName, path: qualifiedName, columns: [], source: 'database' };
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
// Non-fatal: schema discovery from DB may fail if not connected
|
|
248
|
-
}
|
|
249
|
-
// Merge: data files first, then db tables (dedup by name)
|
|
235
|
+
const { tables, columnsByPath } = await introspectSchema(executor, connection);
|
|
236
|
+
const dbTables = tables.map((t) => ({
|
|
237
|
+
name: t.path,
|
|
238
|
+
path: t.path,
|
|
239
|
+
columns: columnsByPath.get(t.path) ?? [],
|
|
240
|
+
source: 'database',
|
|
241
|
+
objectType: t.type,
|
|
242
|
+
}));
|
|
250
243
|
const seen = new Set(dataFiles.map((f) => f.name));
|
|
251
244
|
const merged = [
|
|
252
245
|
...dataFiles.map((f) => ({ ...f, source: 'file' })),
|
|
@@ -264,25 +257,273 @@ export async function startLocalServer(opts) {
|
|
|
264
257
|
if (req.method === 'POST' && path === '/api/blocks') {
|
|
265
258
|
try {
|
|
266
259
|
const body = await readJSON(req);
|
|
267
|
-
const { name } = body;
|
|
260
|
+
const { name, domain, content, description, tags, metricRefs, template, } = body;
|
|
268
261
|
if (!name || typeof name !== 'string') {
|
|
269
262
|
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
270
263
|
res.end(serializeJSON({ error: 'Missing block name' }));
|
|
271
264
|
return;
|
|
272
265
|
}
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
266
|
+
const created = createBlockArtifacts(projectRoot, {
|
|
267
|
+
name,
|
|
268
|
+
domain,
|
|
269
|
+
content,
|
|
270
|
+
description,
|
|
271
|
+
tags,
|
|
272
|
+
metricRefs,
|
|
273
|
+
template,
|
|
274
|
+
});
|
|
275
|
+
res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
276
|
+
res.end(serializeJSON(created));
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (error instanceof Error && error.message === 'BLOCK_EXISTS') {
|
|
278
280
|
res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
279
281
|
res.end(serializeJSON({ error: 'Block already exists' }));
|
|
280
282
|
return;
|
|
281
283
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
285
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (req.method === 'POST' && path === '/api/blocks/save-from-cell') {
|
|
290
|
+
try {
|
|
291
|
+
const body = await readJSON(req);
|
|
292
|
+
const { name, domain, content, description, tags, metricRefs, template, } = body;
|
|
293
|
+
if (!name || typeof name !== 'string' || !content || typeof content !== 'string') {
|
|
294
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
295
|
+
res.end(serializeJSON({ error: 'name and content are required' }));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const created = createBlockArtifacts(projectRoot, {
|
|
299
|
+
name,
|
|
300
|
+
domain,
|
|
301
|
+
content,
|
|
302
|
+
description,
|
|
303
|
+
tags,
|
|
304
|
+
metricRefs,
|
|
305
|
+
template,
|
|
306
|
+
});
|
|
284
307
|
res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
285
|
-
res.end(serializeJSON(
|
|
308
|
+
res.end(serializeJSON(created));
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (error instanceof Error && error.message === 'BLOCK_EXISTS') {
|
|
312
|
+
res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
313
|
+
res.end(serializeJSON({ error: 'Block already exists' }));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
317
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (req.method === 'GET' && path === '/api/blocks/templates') {
|
|
322
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
323
|
+
res.end(serializeJSON({ templates: listBlockTemplates() }));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// ── Block library (list all blocks with metadata) ────────────────────
|
|
327
|
+
if (req.method === 'GET' && path === '/api/blocks/library') {
|
|
328
|
+
try {
|
|
329
|
+
const blocksDir = join(projectRoot, 'blocks');
|
|
330
|
+
const blocks = [];
|
|
331
|
+
if (existsSync(blocksDir)) {
|
|
332
|
+
const scanDir = (dir) => {
|
|
333
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
334
|
+
if (entry.isDirectory()) {
|
|
335
|
+
scanDir(join(dir, entry.name));
|
|
336
|
+
}
|
|
337
|
+
else if (entry.name.endsWith('.dql')) {
|
|
338
|
+
const filePath = join(dir, entry.name);
|
|
339
|
+
const relPath = relative(projectRoot, filePath);
|
|
340
|
+
try {
|
|
341
|
+
const source = readFileSync(filePath, 'utf-8');
|
|
342
|
+
const stat = statSync(filePath);
|
|
343
|
+
// Quick regex parse for key block fields
|
|
344
|
+
const nameMatch = /block\s+"([^"]+)"/.exec(source);
|
|
345
|
+
const domainMatch = /domain\s*=\s*"([^"]+)"/.exec(source);
|
|
346
|
+
const statusMatch = /status\s*=\s*"([^"]+)"/.exec(source);
|
|
347
|
+
const ownerMatch = /owner\s*=\s*"([^"]+)"/.exec(source);
|
|
348
|
+
const descMatch = /description\s*=\s*"([^"]+)"/.exec(source);
|
|
349
|
+
const tagsMatch = /tags\s*=\s*\[([^\]]*)\]/.exec(source);
|
|
350
|
+
const parsedTags = tagsMatch
|
|
351
|
+
? tagsMatch[1].split(',').map((tag) => tag.trim().replace(/^"|"$/g, '')).filter(Boolean)
|
|
352
|
+
: [];
|
|
353
|
+
blocks.push({
|
|
354
|
+
name: nameMatch?.[1] ?? entry.name.replace('.dql', ''),
|
|
355
|
+
domain: domainMatch?.[1] ?? 'uncategorized',
|
|
356
|
+
status: statusMatch?.[1] ?? 'draft',
|
|
357
|
+
owner: ownerMatch?.[1] ?? null,
|
|
358
|
+
tags: parsedTags,
|
|
359
|
+
path: relPath,
|
|
360
|
+
lastModified: stat.mtime.toISOString(),
|
|
361
|
+
description: descMatch?.[1] ?? '',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch { /* skip unreadable files */ }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
scanDir(blocksDir);
|
|
369
|
+
}
|
|
370
|
+
blocks.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
371
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
372
|
+
res.end(serializeJSON({ blocks }));
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
376
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// ── Block status update ──────────────────────────────────────────────
|
|
381
|
+
if (req.method === 'POST' && path === '/api/blocks/status') {
|
|
382
|
+
try {
|
|
383
|
+
const body = await readJSON(req);
|
|
384
|
+
const blockPath = body.path;
|
|
385
|
+
const newStatus = body.newStatus;
|
|
386
|
+
const validStatuses = ['draft', 'review', 'certified', 'deprecated'];
|
|
387
|
+
if (!validStatuses.includes(newStatus)) {
|
|
388
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
389
|
+
res.end(serializeJSON({ error: `Status must be one of: ${validStatuses.join(', ')}` }));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const absPath = resolve(projectRoot, blockPath);
|
|
393
|
+
if (!existsSync(absPath)) {
|
|
394
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
395
|
+
res.end(serializeJSON({ error: 'Block file not found' }));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
let source = readFileSync(absPath, 'utf-8');
|
|
399
|
+
// Update or insert status field
|
|
400
|
+
if (/status\s*=\s*"[^"]*"/.test(source)) {
|
|
401
|
+
source = source.replace(/status\s*=\s*"[^"]*"/, `status = "${newStatus}"`);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
// Insert after first { in block declaration
|
|
405
|
+
source = source.replace(/block\s+"[^"]*"\s*\{/, (match) => `${match}\n status = "${newStatus}"`);
|
|
406
|
+
}
|
|
407
|
+
writeFileSync(absPath, source, 'utf-8');
|
|
408
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
409
|
+
res.end(serializeJSON({ ok: true, status: newStatus }));
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
413
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// ── Block version history (git log) ──────────────────────────────────
|
|
418
|
+
if (req.method === 'GET' && path === '/api/blocks/history') {
|
|
419
|
+
try {
|
|
420
|
+
const blockPath = url.searchParams.get('path');
|
|
421
|
+
if (!blockPath) {
|
|
422
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
423
|
+
res.end(serializeJSON({ error: 'path parameter is required' }));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const { execSync } = await import('node:child_process');
|
|
427
|
+
const gitLog = execSync(`git log --format="%H|||%ai|||%an|||%s" -20 -- "${blockPath}"`, { cwd: projectRoot, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
428
|
+
const entries = gitLog
|
|
429
|
+
? gitLog.split('\n').map((line) => {
|
|
430
|
+
const [hash, date, author, message] = line.split('|||');
|
|
431
|
+
return { hash, date, author, message };
|
|
432
|
+
})
|
|
433
|
+
: [];
|
|
434
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
435
|
+
res.end(serializeJSON({ entries }));
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
439
|
+
res.end(serializeJSON({ entries: [] }));
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// ── Run block tests ────────────────────────────────────────────────
|
|
444
|
+
if (req.method === 'POST' && path === '/api/blocks/run-tests') {
|
|
445
|
+
try {
|
|
446
|
+
const body = await readJSON(req);
|
|
447
|
+
const source = body.source;
|
|
448
|
+
if (!source) {
|
|
449
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
450
|
+
res.end(serializeJSON({ error: 'source is required' }));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// Parse the block to extract tests and query SQL
|
|
454
|
+
const parser = new Parser(source, '<run-tests>');
|
|
455
|
+
const ast = parser.parse();
|
|
456
|
+
const blockNode = ast.statements.find((n) => n.kind === 'BlockDecl');
|
|
457
|
+
if (!blockNode) {
|
|
458
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
459
|
+
res.end(serializeJSON({ error: 'No block declaration found in source' }));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const testNodes = blockNode.tests ?? [];
|
|
463
|
+
if (testNodes.length === 0) {
|
|
464
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
465
|
+
res.end(serializeJSON({ assertions: [], passed: 0, failed: 0, duration: 0 }));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
// Get the block's base SQL
|
|
469
|
+
const baseSql = blockNode.query?.rawSQL?.trim() ?? '';
|
|
470
|
+
if (!baseSql) {
|
|
471
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
472
|
+
res.end(serializeJSON({ error: 'Block has no query SQL to test against' }));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const resolvedSql = resolveProjectRelativeSqlPaths(baseSql, projectRoot, projectConfig.dataDir);
|
|
476
|
+
// Build and run assertions
|
|
477
|
+
const start = Date.now();
|
|
478
|
+
const results = [];
|
|
479
|
+
for (const test of testNodes) {
|
|
480
|
+
const field = test.field;
|
|
481
|
+
const op = test.operator;
|
|
482
|
+
const expected = test.expected;
|
|
483
|
+
// Extract the expected value from the AST node
|
|
484
|
+
const expectedValue = typeof expected === 'object' && expected !== null
|
|
485
|
+
? (expected.value ?? String(expected))
|
|
486
|
+
: expected;
|
|
487
|
+
// Build a SQL query that computes the aggregate for this assertion
|
|
488
|
+
const testSql = `SELECT ${field} AS test_value FROM (${resolvedSql}) AS __test_block`;
|
|
489
|
+
try {
|
|
490
|
+
const result = await executor.executeQuery(testSql, [], {}, connection);
|
|
491
|
+
const actualRaw = result.rows?.[0];
|
|
492
|
+
const actual = actualRaw ? Object.values(actualRaw)[0] : undefined;
|
|
493
|
+
const actualNum = Number(actual);
|
|
494
|
+
const expectedNum = Number(expectedValue);
|
|
495
|
+
let passed = false;
|
|
496
|
+
switch (op) {
|
|
497
|
+
case '>':
|
|
498
|
+
passed = actualNum > expectedNum;
|
|
499
|
+
break;
|
|
500
|
+
case '<':
|
|
501
|
+
passed = actualNum < expectedNum;
|
|
502
|
+
break;
|
|
503
|
+
case '>=':
|
|
504
|
+
passed = actualNum >= expectedNum;
|
|
505
|
+
break;
|
|
506
|
+
case '<=':
|
|
507
|
+
passed = actualNum <= expectedNum;
|
|
508
|
+
break;
|
|
509
|
+
case '==':
|
|
510
|
+
passed = String(actual) === String(expectedValue);
|
|
511
|
+
break;
|
|
512
|
+
case '!=':
|
|
513
|
+
passed = String(actual) !== String(expectedValue);
|
|
514
|
+
break;
|
|
515
|
+
default: passed = false;
|
|
516
|
+
}
|
|
517
|
+
results.push({ field, operator: op, expected: String(expectedValue), passed, actual: String(actual ?? '') });
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
results.push({ field, operator: op, expected: String(expectedValue), passed: false, actual: `Error: ${err instanceof Error ? err.message : String(err)}` });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const duration = Date.now() - start;
|
|
524
|
+
const passed = results.filter((r) => r.passed).length;
|
|
525
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
526
|
+
res.end(serializeJSON({ assertions: results, passed, failed: results.length - passed, duration }));
|
|
286
527
|
}
|
|
287
528
|
catch (error) {
|
|
288
529
|
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -290,6 +531,132 @@ export async function startLocalServer(opts) {
|
|
|
290
531
|
}
|
|
291
532
|
return;
|
|
292
533
|
}
|
|
534
|
+
if (req.method === 'GET' && path === '/api/block-studio/catalog') {
|
|
535
|
+
try {
|
|
536
|
+
const cfg = loadProjectConfig(projectRoot);
|
|
537
|
+
const connections = cfg.connections ?? {};
|
|
538
|
+
if (Object.keys(connections).length === 0 && cfg.defaultConnection) {
|
|
539
|
+
connections.default = cfg.defaultConnection;
|
|
540
|
+
}
|
|
541
|
+
const defaultKey = cfg.defaultConnection ? 'default' : Object.keys(connections)[0] ?? 'default';
|
|
542
|
+
const userPrefs = readUserPrefs(userPrefsPath);
|
|
543
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
544
|
+
res.end(serializeJSON({
|
|
545
|
+
semanticTree: semanticLayer ? buildSemanticTree(semanticLayer, semanticImportManifest) : null,
|
|
546
|
+
databaseTree: await buildDatabaseSchemaTree(projectRoot, executor, connection),
|
|
547
|
+
connection: {
|
|
548
|
+
default: defaultKey,
|
|
549
|
+
current: defaultKey,
|
|
550
|
+
connections,
|
|
551
|
+
},
|
|
552
|
+
favorites: userPrefs.favorites,
|
|
553
|
+
recentlyUsed: userPrefs.recentlyUsed,
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
558
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (req.method === 'GET' && path === '/api/block-studio/open') {
|
|
563
|
+
try {
|
|
564
|
+
const relativePath = url.searchParams.get('path');
|
|
565
|
+
if (!relativePath) {
|
|
566
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
567
|
+
res.end(serializeJSON({ error: 'Missing block path.' }));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const payload = openBlockStudioDocument(projectRoot, relativePath, semanticLayer);
|
|
571
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
572
|
+
res.end(serializeJSON(payload));
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
576
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
577
|
+
}
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (req.method === 'POST' && path === '/api/block-studio/validate') {
|
|
581
|
+
try {
|
|
582
|
+
const body = await readJSON(req);
|
|
583
|
+
const source = typeof body.source === 'string' ? body.source : '';
|
|
584
|
+
const validation = validateBlockStudioSource(source, semanticLayer);
|
|
585
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
586
|
+
res.end(serializeJSON(validation));
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
590
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (req.method === 'POST' && path === '/api/block-studio/run') {
|
|
595
|
+
try {
|
|
596
|
+
const body = await readJSON(req);
|
|
597
|
+
const source = typeof body.source === 'string' ? body.source : '';
|
|
598
|
+
const validation = validateBlockStudioSource(source, semanticLayer);
|
|
599
|
+
if (!validation.executableSql) {
|
|
600
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
601
|
+
res.end(serializeJSON({ error: 'No executable SQL found in block source.' }));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const sql = resolveProjectRelativeSqlPaths(validation.executableSql, projectRoot, projectConfig.dataDir);
|
|
605
|
+
const result = await executor.executeQuery(sql, [], {}, connection);
|
|
606
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
607
|
+
res.end(serializeJSON({
|
|
608
|
+
sql: validation.executableSql,
|
|
609
|
+
result: normalizeQueryResult(result),
|
|
610
|
+
chartConfig: validation.chartConfig ?? null,
|
|
611
|
+
}));
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
615
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (req.method === 'POST' && path === '/api/block-studio/save') {
|
|
620
|
+
try {
|
|
621
|
+
const body = await readJSON(req);
|
|
622
|
+
const source = typeof body.source === 'string' ? body.source : '';
|
|
623
|
+
const metadata = body.metadata && typeof body.metadata === 'object'
|
|
624
|
+
? body.metadata
|
|
625
|
+
: {};
|
|
626
|
+
if (!source.trim()) {
|
|
627
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
628
|
+
res.end(serializeJSON({ error: 'Block source is required.' }));
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (!metadata.name || typeof metadata.name !== 'string') {
|
|
632
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
633
|
+
res.end(serializeJSON({ error: 'Block name is required.' }));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const savedPath = saveBlockStudioArtifacts(projectRoot, {
|
|
637
|
+
currentPath: typeof body.path === 'string' ? body.path : undefined,
|
|
638
|
+
source,
|
|
639
|
+
name: metadata.name,
|
|
640
|
+
domain: metadata.domain,
|
|
641
|
+
description: metadata.description,
|
|
642
|
+
owner: metadata.owner,
|
|
643
|
+
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String) : [],
|
|
644
|
+
});
|
|
645
|
+
const payload = openBlockStudioDocument(projectRoot, savedPath, semanticLayer);
|
|
646
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
647
|
+
res.end(serializeJSON(payload));
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
if (error instanceof Error && error.message === 'BLOCK_EXISTS') {
|
|
651
|
+
res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
652
|
+
res.end(serializeJSON({ error: 'Block already exists' }));
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
656
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
293
660
|
if (req.method === 'GET' && path === '/api/connections') {
|
|
294
661
|
const cfg = loadProjectConfig(projectRoot);
|
|
295
662
|
const raw = cfg;
|
|
@@ -318,6 +685,35 @@ export async function startLocalServer(opts) {
|
|
|
318
685
|
raw.connections = body.connections;
|
|
319
686
|
}
|
|
320
687
|
writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n', 'utf-8');
|
|
688
|
+
// Hot-swap: re-read the config and re-initialize the active connection
|
|
689
|
+
projectConfig = loadProjectConfig(projectRoot);
|
|
690
|
+
const newDefault = projectConfig.defaultConnection;
|
|
691
|
+
if (newDefault) {
|
|
692
|
+
connection = normalizeProjectConnection(newDefault, projectRoot);
|
|
693
|
+
// Auto-register data files if DuckDB/file driver
|
|
694
|
+
if (connection.driver === 'file' || connection.driver === 'duckdb') {
|
|
695
|
+
const dataDir = projectConfig.dataDir
|
|
696
|
+
? resolve(projectRoot, projectConfig.dataDir)
|
|
697
|
+
: join(projectRoot, 'data');
|
|
698
|
+
if (existsSync(dataDir)) {
|
|
699
|
+
try {
|
|
700
|
+
const files = readdirSync(dataDir, { withFileTypes: true })
|
|
701
|
+
.filter((e) => e.isFile() && /\.(csv|parquet)$/i.test(e.name));
|
|
702
|
+
for (const file of files) {
|
|
703
|
+
const tableName = file.name.replace(/\.(csv|parquet)$/i, '');
|
|
704
|
+
const absPath = join(dataDir, file.name).replaceAll('\\', '/');
|
|
705
|
+
const reader = file.name.endsWith('.parquet') ? 'read_parquet' : 'read_csv_auto';
|
|
706
|
+
const ddl = `CREATE OR REPLACE VIEW "${tableName}" AS SELECT * FROM ${reader}('${absPath}')`;
|
|
707
|
+
try {
|
|
708
|
+
await executor.executeQuery(ddl, [], {}, connection);
|
|
709
|
+
}
|
|
710
|
+
catch { /* non-fatal */ }
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
catch { /* non-fatal */ }
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
321
717
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
322
718
|
res.end(serializeJSON({ ok: true }));
|
|
323
719
|
}
|
|
@@ -329,6 +725,7 @@ export async function startLocalServer(opts) {
|
|
|
329
725
|
}
|
|
330
726
|
// ── Semantic layer discovery API ─────────────────────────────────────────
|
|
331
727
|
if (req.method === 'GET' && path === '/api/semantic-layer') {
|
|
728
|
+
const userPrefs = readUserPrefs(userPrefsPath);
|
|
332
729
|
if (!semanticLayer) {
|
|
333
730
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
334
731
|
res.end(serializeJSON({
|
|
@@ -338,6 +735,11 @@ export async function startLocalServer(opts) {
|
|
|
338
735
|
metrics: [],
|
|
339
736
|
dimensions: [],
|
|
340
737
|
hierarchies: [],
|
|
738
|
+
domains: [],
|
|
739
|
+
tags: [],
|
|
740
|
+
favorites: userPrefs.favorites,
|
|
741
|
+
recentlyUsed: userPrefs.recentlyUsed,
|
|
742
|
+
lastSyncTime: semanticLastSyncTime,
|
|
341
743
|
}));
|
|
342
744
|
return;
|
|
343
745
|
}
|
|
@@ -346,6 +748,7 @@ export async function startLocalServer(opts) {
|
|
|
346
748
|
label: m.label,
|
|
347
749
|
description: m.description,
|
|
348
750
|
domain: m.domain,
|
|
751
|
+
sql: m.sql,
|
|
349
752
|
type: m.type,
|
|
350
753
|
table: m.table,
|
|
351
754
|
tags: m.tags ?? [],
|
|
@@ -355,9 +758,12 @@ export async function startLocalServer(opts) {
|
|
|
355
758
|
name: d.name,
|
|
356
759
|
label: d.label,
|
|
357
760
|
description: d.description,
|
|
761
|
+
domain: d.domain,
|
|
762
|
+
sql: d.sql,
|
|
358
763
|
type: d.type,
|
|
359
764
|
table: d.table,
|
|
360
765
|
tags: d.tags ?? [],
|
|
766
|
+
owner: d.owner ?? null,
|
|
361
767
|
}));
|
|
362
768
|
const hierarchies = semanticLayer.listHierarchies().map((h) => ({
|
|
363
769
|
name: h.name,
|
|
@@ -374,18 +780,370 @@ export async function startLocalServer(opts) {
|
|
|
374
780
|
metrics,
|
|
375
781
|
dimensions,
|
|
376
782
|
hierarchies,
|
|
783
|
+
domains: semanticLayer.listDomains(),
|
|
784
|
+
tags: semanticLayer.listTags(),
|
|
785
|
+
favorites: userPrefs.favorites,
|
|
786
|
+
recentlyUsed: userPrefs.recentlyUsed,
|
|
787
|
+
lastSyncTime: semanticLastSyncTime,
|
|
377
788
|
}));
|
|
378
789
|
return;
|
|
379
790
|
}
|
|
791
|
+
if (req.method === 'GET' && path === '/api/semantic-layer/tree') {
|
|
792
|
+
if (!semanticLayer) {
|
|
793
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
794
|
+
res.end(serializeJSON({
|
|
795
|
+
tree: {
|
|
796
|
+
id: 'provider:dql',
|
|
797
|
+
label: 'semantic layer',
|
|
798
|
+
kind: 'provider',
|
|
799
|
+
count: 0,
|
|
800
|
+
children: [],
|
|
801
|
+
},
|
|
802
|
+
}));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
806
|
+
res.end(serializeJSON({
|
|
807
|
+
tree: buildSemanticTree(semanticLayer, semanticImportManifest),
|
|
808
|
+
}));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (req.method === 'GET' && path.startsWith('/api/semantic-layer/object/')) {
|
|
812
|
+
if (!semanticLayer) {
|
|
813
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
814
|
+
res.end(serializeJSON({ error: 'No semantic layer configured.' }));
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const id = decodeURIComponent(path.slice('/api/semantic-layer/object/'.length));
|
|
818
|
+
const detail = buildSemanticObjectDetail(semanticLayer, semanticImportManifest, id);
|
|
819
|
+
if (!detail) {
|
|
820
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
821
|
+
res.end(serializeJSON({ error: `Unknown semantic object: ${id}` }));
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
825
|
+
res.end(serializeJSON(detail));
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (req.method === 'POST' && path === '/api/semantic-layer/import') {
|
|
829
|
+
try {
|
|
830
|
+
const body = await readJSON(req);
|
|
831
|
+
const provider = body.provider;
|
|
832
|
+
if (provider !== 'dbt' && provider !== 'cubejs' && provider !== 'snowflake') {
|
|
833
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
834
|
+
res.end(serializeJSON({ error: 'provider must be one of dbt, cubejs, snowflake' }));
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const sourceConfig = provider === 'snowflake'
|
|
838
|
+
? {
|
|
839
|
+
provider,
|
|
840
|
+
projectPath: body.projectPath ?? projectConfig.semanticLayer?.projectPath,
|
|
841
|
+
connection: body.connection ?? projectConfig.semanticLayer?.connection,
|
|
842
|
+
}
|
|
843
|
+
: {
|
|
844
|
+
provider,
|
|
845
|
+
projectPath: typeof body.projectPath === 'string' ? body.projectPath : projectConfig.semanticLayer?.projectPath,
|
|
846
|
+
repoUrl: typeof body.repoUrl === 'string' ? body.repoUrl : projectConfig.semanticLayer?.repoUrl,
|
|
847
|
+
branch: typeof body.branch === 'string' ? body.branch : projectConfig.semanticLayer?.branch,
|
|
848
|
+
subPath: typeof body.subPath === 'string' ? body.subPath : projectConfig.semanticLayer?.subPath,
|
|
849
|
+
source: body.repoUrl || projectConfig.semanticLayer?.repoUrl
|
|
850
|
+
? (body.source ?? projectConfig.semanticLayer?.source ?? 'github')
|
|
851
|
+
: 'local',
|
|
852
|
+
};
|
|
853
|
+
const executeQuery = provider === 'snowflake'
|
|
854
|
+
? async (sql) => {
|
|
855
|
+
const result = await executor.executeQuery(sql, [], {}, connection);
|
|
856
|
+
return { rows: result.rows };
|
|
857
|
+
}
|
|
858
|
+
: undefined;
|
|
859
|
+
const importResult = await performSemanticImport({
|
|
860
|
+
targetProjectRoot: projectRoot,
|
|
861
|
+
provider,
|
|
862
|
+
sourceConfig,
|
|
863
|
+
executeQuery,
|
|
864
|
+
});
|
|
865
|
+
// Re-resolve using project's actual semantic config (not hardcoded 'dql')
|
|
866
|
+
const projSemConfig = loadProjectConfig(projectRoot)?.semanticLayer ?? { provider: 'dql', path: './semantic-layer' };
|
|
867
|
+
const refreshed = await resolveSemanticLayerAsync(projSemConfig, projectRoot);
|
|
868
|
+
semanticLayer = refreshed.layer;
|
|
869
|
+
semanticLayerErrors = refreshed.errors;
|
|
870
|
+
semanticDetectedProvider = refreshed.detectedProvider ?? 'dql';
|
|
871
|
+
semanticLastSyncTime = importResult.manifest.importedAt;
|
|
872
|
+
semanticImportManifest = importResult.manifest;
|
|
873
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
874
|
+
res.end(serializeJSON(importResult));
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
878
|
+
const hint = message.includes('conflict')
|
|
879
|
+
? 'A file conflict was detected. Remove or rename the conflicting file and retry.'
|
|
880
|
+
: message.includes('dbt_project.yml')
|
|
881
|
+
? 'Ensure your dbt project path contains a valid dbt_project.yml file.'
|
|
882
|
+
: message.includes('query executor')
|
|
883
|
+
? 'A Snowflake connection is required. Configure one in the Connection panel first.'
|
|
884
|
+
: 'Check the provider path and ensure the source files are accessible.';
|
|
885
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
886
|
+
res.end(serializeJSON({ error: message, hint }));
|
|
887
|
+
}
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (req.method === 'POST' && path === '/api/semantic-layer/sync') {
|
|
891
|
+
try {
|
|
892
|
+
const executeQuery = semanticImportManifest?.provider === 'snowflake'
|
|
893
|
+
? async (sql) => {
|
|
894
|
+
const result = await executor.executeQuery(sql, [], {}, connection);
|
|
895
|
+
return { rows: result.rows };
|
|
896
|
+
}
|
|
897
|
+
: undefined;
|
|
898
|
+
const importResult = await syncSemanticImport({
|
|
899
|
+
targetProjectRoot: projectRoot,
|
|
900
|
+
executeQuery,
|
|
901
|
+
});
|
|
902
|
+
// Re-resolve using project's actual semantic config (not hardcoded 'dql')
|
|
903
|
+
const projSemConfig = loadProjectConfig(projectRoot)?.semanticLayer ?? { provider: 'dql', path: './semantic-layer' };
|
|
904
|
+
const refreshed = await resolveSemanticLayerAsync(projSemConfig, projectRoot);
|
|
905
|
+
semanticLayer = refreshed.layer;
|
|
906
|
+
semanticLayerErrors = refreshed.errors;
|
|
907
|
+
semanticDetectedProvider = refreshed.detectedProvider ?? 'dql';
|
|
908
|
+
semanticLastSyncTime = importResult.manifest.importedAt;
|
|
909
|
+
semanticImportManifest = importResult.manifest;
|
|
910
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
911
|
+
res.end(serializeJSON(importResult));
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
915
|
+
const hint = message.includes('No semantic import manifest')
|
|
916
|
+
? 'No previous import found. Use the Setup Wizard to import a semantic layer first.'
|
|
917
|
+
: 'Check the source configuration and retry.';
|
|
918
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
919
|
+
res.end(serializeJSON({ error: message, hint }));
|
|
920
|
+
}
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
// ── Semantic layer import preview (dry-run) ──────────────────────────
|
|
924
|
+
if (req.method === 'POST' && path === '/api/semantic-layer/import-preview') {
|
|
925
|
+
try {
|
|
926
|
+
const body = await readJSON(req);
|
|
927
|
+
const provider = body.provider;
|
|
928
|
+
if (provider !== 'dbt' && provider !== 'cubejs' && provider !== 'snowflake') {
|
|
929
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
930
|
+
res.end(serializeJSON({ error: 'provider must be one of dbt, cubejs, snowflake' }));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const sourceConfig = provider === 'snowflake'
|
|
934
|
+
? {
|
|
935
|
+
provider,
|
|
936
|
+
projectPath: body.projectPath ?? projectConfig.semanticLayer?.projectPath,
|
|
937
|
+
connection: body.connection ?? projectConfig.semanticLayer?.connection,
|
|
938
|
+
}
|
|
939
|
+
: {
|
|
940
|
+
provider,
|
|
941
|
+
projectPath: typeof body.projectPath === 'string' ? body.projectPath : projectConfig.semanticLayer?.projectPath,
|
|
942
|
+
repoUrl: typeof body.repoUrl === 'string' ? body.repoUrl : projectConfig.semanticLayer?.repoUrl,
|
|
943
|
+
branch: typeof body.branch === 'string' ? body.branch : projectConfig.semanticLayer?.branch,
|
|
944
|
+
subPath: typeof body.subPath === 'string' ? body.subPath : projectConfig.semanticLayer?.subPath,
|
|
945
|
+
source: body.repoUrl || projectConfig.semanticLayer?.repoUrl
|
|
946
|
+
? (body.source ?? projectConfig.semanticLayer?.source ?? 'github')
|
|
947
|
+
: 'local',
|
|
948
|
+
};
|
|
949
|
+
const executeQuery = provider === 'snowflake'
|
|
950
|
+
? async (sql) => {
|
|
951
|
+
const result = await executor.executeQuery(sql, [], {}, connection);
|
|
952
|
+
return { rows: result.rows };
|
|
953
|
+
}
|
|
954
|
+
: undefined;
|
|
955
|
+
const preview = await previewSemanticImport({
|
|
956
|
+
targetProjectRoot: projectRoot,
|
|
957
|
+
provider,
|
|
958
|
+
sourceConfig,
|
|
959
|
+
executeQuery,
|
|
960
|
+
});
|
|
961
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
962
|
+
res.end(serializeJSON(preview));
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
966
|
+
const hint = message.includes('dbt_project.yml')
|
|
967
|
+
? 'Ensure your dbt project path contains a valid dbt_project.yml file.'
|
|
968
|
+
: message.includes('model/') || message.includes('schema/')
|
|
969
|
+
? 'Ensure your Cube.js project has a model/ or schema/ directory.'
|
|
970
|
+
: message.includes('query executor')
|
|
971
|
+
? 'A Snowflake connection is required. Configure one in the Connection panel first.'
|
|
972
|
+
: 'Check the provider path and ensure the source files are accessible.';
|
|
973
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
974
|
+
res.end(serializeJSON({ error: message, hint }));
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
// ── Semantic layer sync diff preview ────────────────────────────────
|
|
979
|
+
if (req.method === 'POST' && path === '/api/semantic-layer/sync-preview') {
|
|
980
|
+
try {
|
|
981
|
+
const executeQuery = semanticImportManifest?.provider === 'snowflake'
|
|
982
|
+
? async (sql) => {
|
|
983
|
+
const result = await executor.executeQuery(sql, [], {}, connection);
|
|
984
|
+
return { rows: result.rows };
|
|
985
|
+
}
|
|
986
|
+
: undefined;
|
|
987
|
+
const diff = await computeSyncDiff({
|
|
988
|
+
targetProjectRoot: projectRoot,
|
|
989
|
+
executeQuery,
|
|
990
|
+
});
|
|
991
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
992
|
+
res.end(serializeJSON(diff));
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
996
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
997
|
+
}
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (req.method === 'GET' && path === '/api/semantic-layer/search') {
|
|
1001
|
+
if (!semanticLayer) {
|
|
1002
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1003
|
+
res.end(serializeJSON({ metrics: [], dimensions: [], hierarchies: [] }));
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const q = url.searchParams.get('q') ?? '';
|
|
1007
|
+
const domain = url.searchParams.get('domain') ?? '';
|
|
1008
|
+
const tag = url.searchParams.get('tag') ?? '';
|
|
1009
|
+
const type = url.searchParams.get('type') ?? '';
|
|
1010
|
+
const results = semanticLayer.searchAdvanced(q, {
|
|
1011
|
+
domains: domain ? [domain] : undefined,
|
|
1012
|
+
tags: tag ? [tag] : undefined,
|
|
1013
|
+
types: type === 'metric' || type === 'dimension' || type === 'hierarchy' ? [type] : undefined,
|
|
1014
|
+
});
|
|
1015
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1016
|
+
res.end(serializeJSON({
|
|
1017
|
+
metrics: results.metrics.map((m) => ({
|
|
1018
|
+
name: m.name,
|
|
1019
|
+
label: m.label,
|
|
1020
|
+
description: m.description,
|
|
1021
|
+
domain: m.domain,
|
|
1022
|
+
sql: m.sql,
|
|
1023
|
+
type: m.type,
|
|
1024
|
+
table: m.table,
|
|
1025
|
+
tags: m.tags ?? [],
|
|
1026
|
+
owner: m.owner ?? null,
|
|
1027
|
+
})),
|
|
1028
|
+
dimensions: results.dimensions.map((d) => ({
|
|
1029
|
+
name: d.name,
|
|
1030
|
+
label: d.label,
|
|
1031
|
+
description: d.description,
|
|
1032
|
+
domain: d.domain,
|
|
1033
|
+
sql: d.sql,
|
|
1034
|
+
type: d.type,
|
|
1035
|
+
table: d.table,
|
|
1036
|
+
tags: d.tags ?? [],
|
|
1037
|
+
owner: d.owner ?? null,
|
|
1038
|
+
})),
|
|
1039
|
+
hierarchies: results.hierarchies.map((h) => ({
|
|
1040
|
+
name: h.name,
|
|
1041
|
+
label: h.label,
|
|
1042
|
+
description: h.description,
|
|
1043
|
+
domain: h.domain,
|
|
1044
|
+
levels: h.levels.map((l) => ({ name: l.name, label: l.label })),
|
|
1045
|
+
})),
|
|
1046
|
+
}));
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (req.method === 'GET' && path === '/api/semantic-layer/compatible-dims') {
|
|
1050
|
+
if (!semanticLayer) {
|
|
1051
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1052
|
+
res.end(serializeJSON({ dimensions: [] }));
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const metrics = (url.searchParams.get('metrics') ?? '')
|
|
1056
|
+
.split(',')
|
|
1057
|
+
.map((value) => value.trim())
|
|
1058
|
+
.filter(Boolean);
|
|
1059
|
+
const dimensions = semanticLayer.listCompatibleDimensions(metrics).map((d) => ({
|
|
1060
|
+
name: d.name,
|
|
1061
|
+
label: d.label,
|
|
1062
|
+
description: d.description,
|
|
1063
|
+
domain: d.domain,
|
|
1064
|
+
sql: d.sql,
|
|
1065
|
+
type: d.type,
|
|
1066
|
+
table: d.table,
|
|
1067
|
+
tags: d.tags ?? [],
|
|
1068
|
+
owner: d.owner ?? null,
|
|
1069
|
+
}));
|
|
1070
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1071
|
+
res.end(serializeJSON({ dimensions }));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (req.method === 'GET' && path === '/api/user-prefs/favorites') {
|
|
1075
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1076
|
+
res.end(serializeJSON({ favorites: readUserPrefs(userPrefsPath).favorites }));
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (req.method === 'POST' && path === '/api/user-prefs/favorites') {
|
|
1080
|
+
try {
|
|
1081
|
+
const body = await readJSON(req);
|
|
1082
|
+
const prefs = readUserPrefs(userPrefsPath);
|
|
1083
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
1084
|
+
if (name) {
|
|
1085
|
+
prefs.favorites = prefs.favorites.includes(name)
|
|
1086
|
+
? prefs.favorites.filter((item) => item !== name)
|
|
1087
|
+
: [...prefs.favorites, name].sort((a, b) => a.localeCompare(b));
|
|
1088
|
+
writeUserPrefs(userPrefsPath, prefs);
|
|
1089
|
+
}
|
|
1090
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1091
|
+
res.end(serializeJSON({ favorites: prefs.favorites }));
|
|
1092
|
+
}
|
|
1093
|
+
catch (error) {
|
|
1094
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1095
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1096
|
+
}
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (req.method === 'GET' && path === '/api/user-prefs/recent') {
|
|
1100
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1101
|
+
res.end(serializeJSON({ recentlyUsed: readUserPrefs(userPrefsPath).recentlyUsed }));
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (req.method === 'POST' && path === '/api/user-prefs/recent') {
|
|
1105
|
+
try {
|
|
1106
|
+
const body = await readJSON(req);
|
|
1107
|
+
const prefs = readUserPrefs(userPrefsPath);
|
|
1108
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
1109
|
+
if (name) {
|
|
1110
|
+
prefs.recentlyUsed = [name, ...prefs.recentlyUsed.filter((item) => item !== name)].slice(0, 12);
|
|
1111
|
+
writeUserPrefs(userPrefsPath, prefs);
|
|
1112
|
+
}
|
|
1113
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1114
|
+
res.end(serializeJSON({ recentlyUsed: prefs.recentlyUsed }));
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1118
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1119
|
+
}
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
380
1122
|
// ── Semantic completions for SQL cells ─────────────────────────────────────
|
|
381
1123
|
if (req.method === 'GET' && path === '/api/semantic-completions') {
|
|
382
1124
|
const completions = [];
|
|
383
1125
|
if (semanticLayer) {
|
|
384
1126
|
for (const m of semanticLayer.listMetrics()) {
|
|
385
|
-
completions.push({
|
|
1127
|
+
completions.push({
|
|
1128
|
+
type: 'metric',
|
|
1129
|
+
name: m.name,
|
|
1130
|
+
label: m.label,
|
|
1131
|
+
description: m.description ?? '',
|
|
1132
|
+
sql: m.sql,
|
|
1133
|
+
domain: m.domain,
|
|
1134
|
+
tags: m.tags ?? [],
|
|
1135
|
+
});
|
|
386
1136
|
}
|
|
387
1137
|
for (const d of semanticLayer.listDimensions()) {
|
|
388
|
-
completions.push({
|
|
1138
|
+
completions.push({
|
|
1139
|
+
type: 'dimension',
|
|
1140
|
+
name: d.name,
|
|
1141
|
+
label: d.label,
|
|
1142
|
+
description: d.description ?? '',
|
|
1143
|
+
sql: d.sql,
|
|
1144
|
+
domain: d.domain,
|
|
1145
|
+
tags: d.tags ?? [],
|
|
1146
|
+
});
|
|
389
1147
|
}
|
|
390
1148
|
}
|
|
391
1149
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -393,6 +1151,56 @@ export async function startLocalServer(opts) {
|
|
|
393
1151
|
return;
|
|
394
1152
|
}
|
|
395
1153
|
// ── end dql-notebook API ──────────────────────────────────────────────────
|
|
1154
|
+
// GET /api/describe-table?table=schema.table — returns columns for a specific table
|
|
1155
|
+
if (req.method === 'GET' && path === '/api/describe-table') {
|
|
1156
|
+
try {
|
|
1157
|
+
const tablePath = url.searchParams.get('table') ?? '';
|
|
1158
|
+
const schemaName = url.searchParams.get('schema') ?? undefined;
|
|
1159
|
+
if (!tablePath) {
|
|
1160
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1161
|
+
res.end(serializeJSON({ error: 'Missing table parameter' }));
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
// Try connector.listColumns() first
|
|
1165
|
+
let columns = [];
|
|
1166
|
+
try {
|
|
1167
|
+
const connector = await executor.getConnector(connection);
|
|
1168
|
+
if (typeof connector.listColumns === 'function') {
|
|
1169
|
+
const rawCols = await connector.listColumns(schemaName, tablePath);
|
|
1170
|
+
columns = rawCols.map((c) => ({ name: c.name, type: c.dataType }));
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
// fallback below
|
|
1175
|
+
}
|
|
1176
|
+
// Fallback: DESCRIBE via SQL (works for DuckDB, PG)
|
|
1177
|
+
if (columns.length === 0) {
|
|
1178
|
+
try {
|
|
1179
|
+
const isFile = /\.(csv|parquet|json)$/i.test(tablePath) || tablePath.startsWith('data/');
|
|
1180
|
+
const safePath = tablePath.replace(/'/g, "''");
|
|
1181
|
+
const qualifiedIdentifier = tablePath.split('.').map((p) => `"${p.replace(/"/g, '""')}"`).join('.');
|
|
1182
|
+
const sql = isFile
|
|
1183
|
+
? `DESCRIBE SELECT * FROM read_csv_auto('${safePath}') LIMIT 0`
|
|
1184
|
+
: `DESCRIBE ${qualifiedIdentifier}`;
|
|
1185
|
+
const result = await executor.executeQuery(sql, [], {}, connection);
|
|
1186
|
+
columns = result.rows.map((row) => ({
|
|
1187
|
+
name: String(row['column_name'] ?? row['Field'] ?? ''),
|
|
1188
|
+
type: String(row['column_type'] ?? row['Type'] ?? ''),
|
|
1189
|
+
}));
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
// empty columns
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1196
|
+
res.end(serializeJSON(columns));
|
|
1197
|
+
}
|
|
1198
|
+
catch (error) {
|
|
1199
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1200
|
+
res.end(serializeJSON({ error: String(error) }));
|
|
1201
|
+
}
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
396
1204
|
if (req.method === 'POST' && path === '/api/query') {
|
|
397
1205
|
try {
|
|
398
1206
|
const body = await readJSON(req);
|
|
@@ -487,6 +1295,119 @@ export async function startLocalServer(opts) {
|
|
|
487
1295
|
}
|
|
488
1296
|
return;
|
|
489
1297
|
}
|
|
1298
|
+
if (req.method === 'POST' && path === '/api/semantic-builder/preview') {
|
|
1299
|
+
try {
|
|
1300
|
+
if (!semanticLayer) {
|
|
1301
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1302
|
+
res.end(serializeJSON({ error: 'No semantic layer configured.' }));
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const body = await readJSON(req);
|
|
1306
|
+
const { metrics = [], dimensions = [], filters = [], limit, timeDimension, orderBy } = body;
|
|
1307
|
+
const targetConnection = isConnectionConfig(body.connection) ? body.connection : connection;
|
|
1308
|
+
const driver = targetConnection.driver;
|
|
1309
|
+
let tableMapping;
|
|
1310
|
+
try {
|
|
1311
|
+
const tablesResult = await executor.executeQuery(`SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'pg_catalog')`, [], {}, targetConnection);
|
|
1312
|
+
const schemaQualified = new Map();
|
|
1313
|
+
for (const row of tablesResult.rows) {
|
|
1314
|
+
const schema = String(row['table_schema'] ?? '');
|
|
1315
|
+
const name = String(row['table_name'] ?? '');
|
|
1316
|
+
schemaQualified.set(name, schema ? `${schema}.${name}` : name);
|
|
1317
|
+
}
|
|
1318
|
+
tableMapping = {};
|
|
1319
|
+
for (const metric of semanticLayer.listMetrics()) {
|
|
1320
|
+
if (schemaQualified.has(metric.table))
|
|
1321
|
+
tableMapping[metric.table] = schemaQualified.get(metric.table);
|
|
1322
|
+
}
|
|
1323
|
+
for (const dimension of semanticLayer.listDimensions()) {
|
|
1324
|
+
if (schemaQualified.has(dimension.table))
|
|
1325
|
+
tableMapping[dimension.table] = schemaQualified.get(dimension.table);
|
|
1326
|
+
}
|
|
1327
|
+
if (Object.keys(tableMapping).length === 0)
|
|
1328
|
+
tableMapping = undefined;
|
|
1329
|
+
}
|
|
1330
|
+
catch {
|
|
1331
|
+
tableMapping = undefined;
|
|
1332
|
+
}
|
|
1333
|
+
const composed = semanticLayer.composeQuery({ metrics, dimensions, filters, limit, timeDimension, orderBy, driver, tableMapping });
|
|
1334
|
+
if (!composed) {
|
|
1335
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1336
|
+
res.end(serializeJSON({ error: 'Could not compose semantic block preview SQL.' }));
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
|
|
1340
|
+
const result = await executor.executeQuery(prepared.sql, [], {}, prepared.connection);
|
|
1341
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1342
|
+
res.end(serializeJSON({
|
|
1343
|
+
sql: composed.sql,
|
|
1344
|
+
joins: composed.joins,
|
|
1345
|
+
tables: composed.tables,
|
|
1346
|
+
result: normalizeQueryResult(result),
|
|
1347
|
+
}));
|
|
1348
|
+
}
|
|
1349
|
+
catch (error) {
|
|
1350
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1351
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1352
|
+
}
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
if (req.method === 'POST' && path === '/api/semantic-builder/save') {
|
|
1356
|
+
try {
|
|
1357
|
+
if (!semanticLayer) {
|
|
1358
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1359
|
+
res.end(serializeJSON({ error: 'No semantic layer configured.' }));
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const body = await readJSON(req);
|
|
1363
|
+
const { name, domain, description, owner, tags, metrics = [], dimensions = [], timeDimension, filters = [], chart = 'table', blockType = 'semantic', } = body;
|
|
1364
|
+
if (!name || metrics.length === 0) {
|
|
1365
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1366
|
+
res.end(serializeJSON({ error: 'name and at least one metric are required.' }));
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const targetConnection = isConnectionConfig(body.connection) ? body.connection : connection;
|
|
1370
|
+
const composed = semanticLayer.composeQuery({
|
|
1371
|
+
metrics,
|
|
1372
|
+
dimensions,
|
|
1373
|
+
filters,
|
|
1374
|
+
timeDimension,
|
|
1375
|
+
driver: targetConnection.driver,
|
|
1376
|
+
});
|
|
1377
|
+
if (!composed) {
|
|
1378
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1379
|
+
res.end(serializeJSON({ error: 'Could not compose semantic block SQL.' }));
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const created = createSemanticBuilderBlock(projectRoot, {
|
|
1383
|
+
name,
|
|
1384
|
+
domain,
|
|
1385
|
+
description,
|
|
1386
|
+
owner,
|
|
1387
|
+
tags,
|
|
1388
|
+
metrics,
|
|
1389
|
+
dimensions,
|
|
1390
|
+
timeDimension,
|
|
1391
|
+
chart,
|
|
1392
|
+
blockType,
|
|
1393
|
+
sql: composed.sql,
|
|
1394
|
+
tables: composed.tables,
|
|
1395
|
+
provider: semanticImportManifest?.provider ?? semanticDetectedProvider ?? 'dql',
|
|
1396
|
+
});
|
|
1397
|
+
res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1398
|
+
res.end(serializeJSON(created));
|
|
1399
|
+
}
|
|
1400
|
+
catch (error) {
|
|
1401
|
+
if (error instanceof Error && error.message === 'BLOCK_EXISTS') {
|
|
1402
|
+
res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1403
|
+
res.end(serializeJSON({ error: 'Block already exists' }));
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1407
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1408
|
+
}
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
490
1411
|
if (req.method === 'POST' && path === '/api/test-connection') {
|
|
491
1412
|
try {
|
|
492
1413
|
const body = await readJSON(req);
|
|
@@ -990,23 +1911,32 @@ function scanNotebookFiles(projectRoot) {
|
|
|
990
1911
|
const dir = join(projectRoot, folder);
|
|
991
1912
|
if (!existsSync(dir))
|
|
992
1913
|
continue;
|
|
1914
|
+
collect(dir, folder, type);
|
|
1915
|
+
}
|
|
1916
|
+
return result;
|
|
1917
|
+
function collect(currentDir, relativeDir, type) {
|
|
993
1918
|
try {
|
|
994
|
-
for (const entry of readdirSync(
|
|
1919
|
+
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
1920
|
+
const fullPath = join(currentDir, entry.name);
|
|
1921
|
+
const relativePath = `${relativeDir}/${entry.name}`;
|
|
1922
|
+
if (entry.isDirectory()) {
|
|
1923
|
+
collect(fullPath, relativePath, type);
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
995
1926
|
if (!entry.isFile())
|
|
996
1927
|
continue;
|
|
997
1928
|
if (!entry.name.endsWith('.dql') && !entry.name.endsWith('.dqlnb'))
|
|
998
1929
|
continue;
|
|
999
1930
|
result.push({
|
|
1000
1931
|
name: entry.name.replace(/\.(dql|dqlnb)$/, ''),
|
|
1001
|
-
path:
|
|
1932
|
+
path: relativePath,
|
|
1002
1933
|
type,
|
|
1003
|
-
folder,
|
|
1934
|
+
folder: relativeDir.split('/')[0] ?? relativeDir,
|
|
1004
1935
|
});
|
|
1005
1936
|
}
|
|
1006
1937
|
}
|
|
1007
1938
|
catch { /* skip unreadable dirs */ }
|
|
1008
1939
|
}
|
|
1009
|
-
return result;
|
|
1010
1940
|
}
|
|
1011
1941
|
function scanDataFiles(projectRoot) {
|
|
1012
1942
|
const dataDir = join(projectRoot, 'data');
|
|
@@ -1021,6 +1951,699 @@ function scanDataFiles(projectRoot) {
|
|
|
1021
1951
|
return [];
|
|
1022
1952
|
}
|
|
1023
1953
|
}
|
|
1954
|
+
function readUserPrefs(userPrefsPath) {
|
|
1955
|
+
try {
|
|
1956
|
+
if (!existsSync(userPrefsPath)) {
|
|
1957
|
+
return { favorites: [], recentlyUsed: [] };
|
|
1958
|
+
}
|
|
1959
|
+
const raw = JSON.parse(readFileSync(userPrefsPath, 'utf-8'));
|
|
1960
|
+
return {
|
|
1961
|
+
favorites: Array.isArray(raw.favorites) ? raw.favorites.map(String) : [],
|
|
1962
|
+
recentlyUsed: Array.isArray(raw.recentlyUsed) ? raw.recentlyUsed.map(String) : [],
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
catch {
|
|
1966
|
+
return { favorites: [], recentlyUsed: [] };
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
function writeUserPrefs(userPrefsPath, prefs) {
|
|
1970
|
+
writeFileSync(userPrefsPath, JSON.stringify(prefs, null, 2) + '\n', 'utf-8');
|
|
1971
|
+
}
|
|
1972
|
+
async function introspectSchema(executor, connection) {
|
|
1973
|
+
let tables = [];
|
|
1974
|
+
let columnsByPath = new Map();
|
|
1975
|
+
// Tier 1: information_schema (PG, MySQL, Snowflake, MSSQL, DuckDB, Redshift, Fabric, Databricks)
|
|
1976
|
+
try {
|
|
1977
|
+
const catalogRows = await executor.executeQuery(`SELECT table_schema, table_name, table_type
|
|
1978
|
+
FROM information_schema.tables
|
|
1979
|
+
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
1980
|
+
ORDER BY table_schema, table_name`, [], {}, connection);
|
|
1981
|
+
tables = catalogRows.rows.map((row) => {
|
|
1982
|
+
const schema = String(row['table_schema'] ?? row['TABLE_SCHEMA'] ?? 'default');
|
|
1983
|
+
const name = String(row['table_name'] ?? row['TABLE_NAME'] ?? '');
|
|
1984
|
+
const type = String(row['table_type'] ?? row['TABLE_TYPE'] ?? 'TABLE');
|
|
1985
|
+
const path = schema ? `${schema}.${name}` : name;
|
|
1986
|
+
return { schema, name, path, type };
|
|
1987
|
+
});
|
|
1988
|
+
const columnRows = await executor.executeQuery(`SELECT table_schema, table_name, column_name, data_type
|
|
1989
|
+
FROM information_schema.columns
|
|
1990
|
+
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
1991
|
+
ORDER BY table_schema, table_name, ordinal_position`, [], {}, connection);
|
|
1992
|
+
columnsByPath = columnRows.rows.reduce((map, row) => {
|
|
1993
|
+
const schema = String(row['table_schema'] ?? row['TABLE_SCHEMA'] ?? 'default');
|
|
1994
|
+
const tableName = String(row['table_name'] ?? row['TABLE_NAME'] ?? '');
|
|
1995
|
+
const path = schema ? `${schema}.${tableName}` : tableName;
|
|
1996
|
+
const next = map.get(path) ?? [];
|
|
1997
|
+
next.push({
|
|
1998
|
+
name: String(row['column_name'] ?? row['COLUMN_NAME'] ?? ''),
|
|
1999
|
+
type: String(row['data_type'] ?? row['DATA_TYPE'] ?? ''),
|
|
2000
|
+
});
|
|
2001
|
+
map.set(path, next);
|
|
2002
|
+
return map;
|
|
2003
|
+
}, new Map());
|
|
2004
|
+
return { tables, columnsByPath };
|
|
2005
|
+
}
|
|
2006
|
+
catch {
|
|
2007
|
+
// Tier 1 failed — try connector methods
|
|
2008
|
+
}
|
|
2009
|
+
// Tier 2: connector.listTables() + connector.listColumns() (SQLite, BigQuery, Athena, ClickHouse, Trino)
|
|
2010
|
+
try {
|
|
2011
|
+
const connector = await executor.getConnector(connection);
|
|
2012
|
+
if (typeof connector.listTables === 'function') {
|
|
2013
|
+
const rawTables = await connector.listTables();
|
|
2014
|
+
tables = rawTables.map((t) => {
|
|
2015
|
+
const schema = t.schema || 'default';
|
|
2016
|
+
const path = t.schema ? `${t.schema}.${t.name}` : t.name;
|
|
2017
|
+
return { schema, name: t.name, path, type: t.type };
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
if (typeof connector.listColumns === 'function') {
|
|
2021
|
+
const rawColumns = await connector.listColumns();
|
|
2022
|
+
columnsByPath = rawColumns.reduce((map, col) => {
|
|
2023
|
+
const schema = col.schema || 'default';
|
|
2024
|
+
const path = schema ? `${schema}.${col.table}` : col.table;
|
|
2025
|
+
const next = map.get(path) ?? [];
|
|
2026
|
+
next.push({ name: col.name, type: col.dataType });
|
|
2027
|
+
map.set(path, next);
|
|
2028
|
+
return map;
|
|
2029
|
+
}, new Map());
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
catch {
|
|
2033
|
+
// Tier 3: tables only, no columns — already have what we have
|
|
2034
|
+
}
|
|
2035
|
+
return { tables, columnsByPath };
|
|
2036
|
+
}
|
|
2037
|
+
function buildDatabaseSchemaTree(projectRoot, executor, connection) {
|
|
2038
|
+
return (async () => {
|
|
2039
|
+
const dataFiles = scanDataFiles(projectRoot);
|
|
2040
|
+
const { tables: dbTables, columnsByPath: dbColumnsByPath } = await introspectSchema(executor, connection);
|
|
2041
|
+
const schemaMap = new Map();
|
|
2042
|
+
for (const table of dbTables) {
|
|
2043
|
+
const schemaName = table.schema || 'default';
|
|
2044
|
+
const existing = schemaMap.get(schemaName) ?? [];
|
|
2045
|
+
existing.push({ name: table.name, path: table.path, type: table.type });
|
|
2046
|
+
schemaMap.set(schemaName, existing);
|
|
2047
|
+
}
|
|
2048
|
+
const databaseNodes = Array.from(schemaMap.entries())
|
|
2049
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
2050
|
+
.map(([schemaName, tables]) => ({
|
|
2051
|
+
id: `db-schema:${schemaName}`,
|
|
2052
|
+
label: schemaName,
|
|
2053
|
+
kind: 'schema',
|
|
2054
|
+
children: tables
|
|
2055
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
2056
|
+
.map((table) => ({
|
|
2057
|
+
id: `db-table:${table.path}`,
|
|
2058
|
+
label: table.name,
|
|
2059
|
+
kind: 'table',
|
|
2060
|
+
path: table.path,
|
|
2061
|
+
type: table.type,
|
|
2062
|
+
children: (dbColumnsByPath.get(table.path) ?? []).map((column) => ({
|
|
2063
|
+
id: `db-column:${table.path}:${column.name}`,
|
|
2064
|
+
label: column.name,
|
|
2065
|
+
kind: 'column',
|
|
2066
|
+
path: table.path,
|
|
2067
|
+
type: column.type,
|
|
2068
|
+
})),
|
|
2069
|
+
})),
|
|
2070
|
+
}));
|
|
2071
|
+
// Eagerly resolve file columns via DuckDB DESCRIBE
|
|
2072
|
+
if (dataFiles.length > 0) {
|
|
2073
|
+
const fileChildren = [];
|
|
2074
|
+
for (const file of dataFiles) {
|
|
2075
|
+
let columns = [];
|
|
2076
|
+
try {
|
|
2077
|
+
const ext = file.name.split('.').pop()?.toLowerCase();
|
|
2078
|
+
const readFn = ext === 'parquet' ? 'read_parquet' : ext === 'json' ? 'read_json_auto' : 'read_csv_auto';
|
|
2079
|
+
const descResult = await executor.executeQuery(`DESCRIBE SELECT * FROM ${readFn}('${file.path.replace(/'/g, "''")}') LIMIT 0`, [], {}, connection);
|
|
2080
|
+
columns = descResult.rows.map((row) => ({
|
|
2081
|
+
id: `db-column:${file.path}:${String(row['column_name'] ?? '')}`,
|
|
2082
|
+
label: String(row['column_name'] ?? ''),
|
|
2083
|
+
kind: 'column',
|
|
2084
|
+
path: file.path,
|
|
2085
|
+
type: String(row['column_type'] ?? ''),
|
|
2086
|
+
}));
|
|
2087
|
+
}
|
|
2088
|
+
catch {
|
|
2089
|
+
// file column discovery failed — empty children is fine
|
|
2090
|
+
}
|
|
2091
|
+
fileChildren.push({
|
|
2092
|
+
id: `db-table:${file.path}`,
|
|
2093
|
+
label: file.name,
|
|
2094
|
+
kind: 'table',
|
|
2095
|
+
path: file.path,
|
|
2096
|
+
type: 'FILE',
|
|
2097
|
+
children: columns,
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
databaseNodes.unshift({
|
|
2101
|
+
id: 'db-schema:files',
|
|
2102
|
+
label: 'files',
|
|
2103
|
+
kind: 'schema',
|
|
2104
|
+
children: fileChildren,
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
return databaseNodes;
|
|
2108
|
+
})();
|
|
2109
|
+
}
|
|
2110
|
+
function openBlockStudioDocument(projectRoot, relativePath, semanticLayer) {
|
|
2111
|
+
const normalizedPath = normalize(relativePath).replace(/^\/+/, '');
|
|
2112
|
+
if (!normalizedPath.startsWith('blocks/')) {
|
|
2113
|
+
throw new Error('Invalid block path');
|
|
2114
|
+
}
|
|
2115
|
+
const absPath = join(projectRoot, normalizedPath);
|
|
2116
|
+
if (!existsSync(absPath)) {
|
|
2117
|
+
throw new Error(`File not found: ${normalizedPath}`);
|
|
2118
|
+
}
|
|
2119
|
+
const source = readFileSync(absPath, 'utf-8');
|
|
2120
|
+
const companionPath = blockCompanionRelativePath(normalizedPath);
|
|
2121
|
+
const companion = companionPath ? readBlockCompanionFile(projectRoot, companionPath) : null;
|
|
2122
|
+
const parsedMetadata = parseBlockSourceMetadata(source);
|
|
2123
|
+
const fileName = normalizedPath.split('/').pop()?.replace(/\.dql$/, '') ?? 'block';
|
|
2124
|
+
const metadata = {
|
|
2125
|
+
name: parsedMetadata.name || companion?.name || fileName,
|
|
2126
|
+
path: normalizedPath,
|
|
2127
|
+
domain: parsedMetadata.domain || companion?.domain || normalizedPath.split('/').slice(1, -1).join('/') || 'uncategorized',
|
|
2128
|
+
description: parsedMetadata.description || companion?.description || '',
|
|
2129
|
+
owner: parsedMetadata.owner || companion?.owner || '',
|
|
2130
|
+
tags: parsedMetadata.tags.length > 0 ? parsedMetadata.tags : companion?.tags ?? [],
|
|
2131
|
+
reviewStatus: companion?.reviewStatus,
|
|
2132
|
+
};
|
|
2133
|
+
return {
|
|
2134
|
+
path: normalizedPath,
|
|
2135
|
+
source,
|
|
2136
|
+
metadata,
|
|
2137
|
+
companionPath: companionPath && existsSync(join(projectRoot, companionPath)) ? companionPath : null,
|
|
2138
|
+
validation: validateBlockStudioSource(source, semanticLayer),
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
function validateBlockStudioSource(source, semanticLayer) {
|
|
2142
|
+
const diagnostics = [];
|
|
2143
|
+
try {
|
|
2144
|
+
const parser = new Parser(source, '<block-studio>');
|
|
2145
|
+
parser.parse();
|
|
2146
|
+
}
|
|
2147
|
+
catch (error) {
|
|
2148
|
+
diagnostics.push({
|
|
2149
|
+
severity: 'error',
|
|
2150
|
+
code: 'syntax',
|
|
2151
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
const semanticRefs = extractBlockStudioSemanticReferences(source);
|
|
2155
|
+
if (semanticLayer) {
|
|
2156
|
+
const refValidation = semanticLayer.validateReferences([
|
|
2157
|
+
...semanticRefs.metrics,
|
|
2158
|
+
...semanticRefs.dimensions,
|
|
2159
|
+
...semanticRefs.segments,
|
|
2160
|
+
]);
|
|
2161
|
+
for (const unknown of refValidation.unknown) {
|
|
2162
|
+
diagnostics.push({
|
|
2163
|
+
severity: 'error',
|
|
2164
|
+
code: 'semantic_ref',
|
|
2165
|
+
message: `Unknown semantic reference: ${unknown}`,
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
const chartConfig = extractBlockStudioChartConfig(source);
|
|
2170
|
+
if (!chartConfig) {
|
|
2171
|
+
diagnostics.push({
|
|
2172
|
+
severity: 'warning',
|
|
2173
|
+
code: 'visualization_missing',
|
|
2174
|
+
message: 'Block has no visualization section yet.',
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
const executableSql = extractBlockStudioSql(source);
|
|
2178
|
+
if (!executableSql) {
|
|
2179
|
+
diagnostics.push({
|
|
2180
|
+
severity: 'warning',
|
|
2181
|
+
code: 'sql_missing',
|
|
2182
|
+
message: 'No executable SQL found in the block source.',
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
return {
|
|
2186
|
+
valid: diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
|
|
2187
|
+
diagnostics,
|
|
2188
|
+
semanticRefs,
|
|
2189
|
+
chartConfig: chartConfig ?? undefined,
|
|
2190
|
+
executableSql,
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
function saveBlockStudioArtifacts(projectRoot, options) {
|
|
2194
|
+
const slug = options.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block';
|
|
2195
|
+
const safeDomain = (options.domain ?? '')
|
|
2196
|
+
.trim()
|
|
2197
|
+
.toLowerCase()
|
|
2198
|
+
.replace(/[^a-z0-9/_-]+/g, '-')
|
|
2199
|
+
.replace(/^\/+|\/+$/g, '') || 'uncategorized';
|
|
2200
|
+
const targetRelativePath = `blocks/${safeDomain}/${slug}.dql`;
|
|
2201
|
+
const targetPath = join(projectRoot, targetRelativePath);
|
|
2202
|
+
const previousPath = options.currentPath ? normalize(options.currentPath).replace(/^\/+/, '') : null;
|
|
2203
|
+
if (existsSync(targetPath) && previousPath !== targetRelativePath) {
|
|
2204
|
+
throw new Error('BLOCK_EXISTS');
|
|
2205
|
+
}
|
|
2206
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
2207
|
+
writeFileSync(targetPath, options.source.trimEnd() + '\n', 'utf-8');
|
|
2208
|
+
writeBlockCompanionFile(projectRoot, {
|
|
2209
|
+
slug,
|
|
2210
|
+
name: options.name,
|
|
2211
|
+
domain: safeDomain,
|
|
2212
|
+
description: options.description,
|
|
2213
|
+
owner: options.owner,
|
|
2214
|
+
tags: options.tags,
|
|
2215
|
+
provider: 'dql',
|
|
2216
|
+
content: options.source,
|
|
2217
|
+
});
|
|
2218
|
+
if (previousPath && previousPath !== targetRelativePath) {
|
|
2219
|
+
const previousAbsPath = join(projectRoot, previousPath);
|
|
2220
|
+
if (existsSync(previousAbsPath))
|
|
2221
|
+
rmSync(previousAbsPath, { force: true });
|
|
2222
|
+
const previousCompanion = blockCompanionRelativePath(previousPath);
|
|
2223
|
+
if (previousCompanion) {
|
|
2224
|
+
const previousCompanionPath = join(projectRoot, previousCompanion);
|
|
2225
|
+
if (existsSync(previousCompanionPath))
|
|
2226
|
+
rmSync(previousCompanionPath, { force: true });
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
return targetRelativePath;
|
|
2230
|
+
}
|
|
2231
|
+
function blockCompanionRelativePath(blockPath) {
|
|
2232
|
+
const normalized = normalize(blockPath).replace(/^\/+/, '');
|
|
2233
|
+
if (!normalized.startsWith('blocks/'))
|
|
2234
|
+
return null;
|
|
2235
|
+
const withoutRoot = normalized.slice('blocks/'.length).replace(/\.dql$/, '.yaml');
|
|
2236
|
+
return join('semantic-layer', 'blocks', withoutRoot).replaceAll('\\', '/');
|
|
2237
|
+
}
|
|
2238
|
+
function readBlockCompanionFile(projectRoot, relativePath) {
|
|
2239
|
+
const absPath = join(projectRoot, relativePath);
|
|
2240
|
+
if (!existsSync(absPath))
|
|
2241
|
+
return null;
|
|
2242
|
+
try {
|
|
2243
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
2244
|
+
const lines = content.split(/\r?\n/);
|
|
2245
|
+
const topLevel = {};
|
|
2246
|
+
const arrays = {};
|
|
2247
|
+
let currentArray = null;
|
|
2248
|
+
for (const rawLine of lines) {
|
|
2249
|
+
const line = rawLine.replace(/\t/g, ' ');
|
|
2250
|
+
if (!line.trim() || line.trimStart().startsWith('#'))
|
|
2251
|
+
continue;
|
|
2252
|
+
if (/^\S[^:]*:\s*$/.test(line)) {
|
|
2253
|
+
currentArray = line.trim().slice(0, -1);
|
|
2254
|
+
if (['tags', 'lineage', 'semanticMetrics', 'semanticDimensions'].includes(currentArray)) {
|
|
2255
|
+
arrays[currentArray] = [];
|
|
2256
|
+
}
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
const itemMatch = line.match(/^\s*-\s*(.+)\s*$/);
|
|
2260
|
+
if (itemMatch && currentArray && arrays[currentArray]) {
|
|
2261
|
+
arrays[currentArray].push(parseYamlScalar(itemMatch[1]));
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
const scalarMatch = line.match(/^([A-Za-z0-9_]+):\s*(.+)\s*$/);
|
|
2265
|
+
if (scalarMatch) {
|
|
2266
|
+
currentArray = null;
|
|
2267
|
+
topLevel[scalarMatch[1]] = parseYamlScalar(scalarMatch[2]);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
return {
|
|
2271
|
+
name: topLevel.name ?? '',
|
|
2272
|
+
block: topLevel.block ?? '',
|
|
2273
|
+
domain: topLevel.domain ?? '',
|
|
2274
|
+
description: topLevel.description ?? '',
|
|
2275
|
+
owner: topLevel.owner ?? '',
|
|
2276
|
+
tags: arrays.tags ?? [],
|
|
2277
|
+
reviewStatus: topLevel.reviewStatus ?? '',
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
catch {
|
|
2281
|
+
return null;
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
function parseBlockSourceMetadata(source) {
|
|
2285
|
+
const name = source.match(/^\s*block\s+"([^"]+)"/i)?.[1] ?? '';
|
|
2286
|
+
const extractString = (key) => source.match(new RegExp(`\\b${key}\\s*=\\s*"([^"]*)"`, 'i'))?.[1] ?? '';
|
|
2287
|
+
const tags = source.match(/\btags\s*=\s*\[([^\]]*)\]/i);
|
|
2288
|
+
return {
|
|
2289
|
+
name,
|
|
2290
|
+
domain: extractString('domain'),
|
|
2291
|
+
description: extractString('description'),
|
|
2292
|
+
owner: extractString('owner'),
|
|
2293
|
+
tags: tags ? (tags[1].match(/"([^"]*)"/g) ?? []).map((value) => value.slice(1, -1)) : [],
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
function extractBlockStudioChartConfig(source) {
|
|
2297
|
+
const vizMatch = source.match(/visualization\s*\{([^}]+)\}/is);
|
|
2298
|
+
if (!vizMatch)
|
|
2299
|
+
return null;
|
|
2300
|
+
const body = vizMatch[1];
|
|
2301
|
+
const get = (key) => body.match(new RegExp(`\\b${key}\\s*=\\s*["']?([\\w-]+)["']?`, 'i'))?.[1];
|
|
2302
|
+
const chart = get('chart');
|
|
2303
|
+
if (!chart)
|
|
2304
|
+
return null;
|
|
2305
|
+
const title = body.match(/\btitle\s*=\s*"([^"]+)"/i)?.[1];
|
|
2306
|
+
return {
|
|
2307
|
+
chart,
|
|
2308
|
+
x: get('x'),
|
|
2309
|
+
y: get('y'),
|
|
2310
|
+
color: get('color'),
|
|
2311
|
+
title,
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
function extractBlockStudioSql(source) {
|
|
2315
|
+
const tripleQuoteMatch = source.match(/query\s*=\s*"""([\s\S]*?)"""/i);
|
|
2316
|
+
if (tripleQuoteMatch)
|
|
2317
|
+
return tripleQuoteMatch[1].trim() || null;
|
|
2318
|
+
const bareTripleMatch = source.match(/"""([\s\S]*?)"""/);
|
|
2319
|
+
if (bareTripleMatch)
|
|
2320
|
+
return bareTripleMatch[1].trim() || null;
|
|
2321
|
+
if (/^\s*(dashboard|workbook)\s+"/i.test(source))
|
|
2322
|
+
return null;
|
|
2323
|
+
const sqlKeywordMatch = source.match(/\b(SELECT|WITH|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|SHOW|DESCRIBE|EXPLAIN)\b([\s\S]*)/i);
|
|
2324
|
+
if (!sqlKeywordMatch)
|
|
2325
|
+
return null;
|
|
2326
|
+
let raw = sqlKeywordMatch[0];
|
|
2327
|
+
const dqlSectionStart = raw.search(/\b(visualization|tests|block)\s*\{/i);
|
|
2328
|
+
if (dqlSectionStart > 0)
|
|
2329
|
+
raw = raw.slice(0, dqlSectionStart);
|
|
2330
|
+
return raw.trim() || null;
|
|
2331
|
+
}
|
|
2332
|
+
function extractBlockStudioSemanticReferences(source) {
|
|
2333
|
+
const metrics = new Set();
|
|
2334
|
+
const dimensions = new Set();
|
|
2335
|
+
const segments = new Set();
|
|
2336
|
+
const semanticRegex = /@(metric|dim)\(([^)]+)\)/gi;
|
|
2337
|
+
let match;
|
|
2338
|
+
while ((match = semanticRegex.exec(source))) {
|
|
2339
|
+
const name = match[2].trim();
|
|
2340
|
+
if (!name)
|
|
2341
|
+
continue;
|
|
2342
|
+
if (match[1].toLowerCase() === 'metric')
|
|
2343
|
+
metrics.add(name);
|
|
2344
|
+
else
|
|
2345
|
+
dimensions.add(name);
|
|
2346
|
+
}
|
|
2347
|
+
const segmentRegex = /\/\*\s*segment:([^*]+)\*\//gi;
|
|
2348
|
+
while ((match = segmentRegex.exec(source))) {
|
|
2349
|
+
const name = match[1].trim();
|
|
2350
|
+
if (name)
|
|
2351
|
+
segments.add(name);
|
|
2352
|
+
}
|
|
2353
|
+
return {
|
|
2354
|
+
metrics: Array.from(metrics),
|
|
2355
|
+
dimensions: Array.from(dimensions),
|
|
2356
|
+
segments: Array.from(segments),
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
export function createBlockArtifacts(projectRoot, options) {
|
|
2360
|
+
const slug = options.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block';
|
|
2361
|
+
const safeDomain = (options.domain ?? '')
|
|
2362
|
+
.trim()
|
|
2363
|
+
.toLowerCase()
|
|
2364
|
+
.replace(/[^a-z0-9/_-]+/g, '-')
|
|
2365
|
+
.replace(/^\/+|\/+$/g, '');
|
|
2366
|
+
const blocksDir = safeDomain ? join(projectRoot, 'blocks', safeDomain) : join(projectRoot, 'blocks');
|
|
2367
|
+
mkdirSync(blocksDir, { recursive: true });
|
|
2368
|
+
const blockPath = join(blocksDir, `${slug}.dql`);
|
|
2369
|
+
if (existsSync(blockPath)) {
|
|
2370
|
+
throw new Error('BLOCK_EXISTS');
|
|
2371
|
+
}
|
|
2372
|
+
const templateContent = options.template
|
|
2373
|
+
? listBlockTemplates().find((template) => template.id === options.template)?.content
|
|
2374
|
+
: undefined;
|
|
2375
|
+
const fileContent = normalizeBlockStudioContent({
|
|
2376
|
+
name: options.name,
|
|
2377
|
+
domain: safeDomain || 'uncategorized',
|
|
2378
|
+
description: options.description,
|
|
2379
|
+
tags: options.tags,
|
|
2380
|
+
content: options.content?.trim() || templateContent,
|
|
2381
|
+
});
|
|
2382
|
+
writeFileSync(blockPath, fileContent, 'utf-8');
|
|
2383
|
+
const relativePath = safeDomain ? `blocks/${safeDomain}/${slug}.dql` : `blocks/${slug}.dql`;
|
|
2384
|
+
const companionPath = writeBlockCompanionFile(projectRoot, {
|
|
2385
|
+
slug,
|
|
2386
|
+
name: options.name,
|
|
2387
|
+
domain: safeDomain || 'uncategorized',
|
|
2388
|
+
description: options.description,
|
|
2389
|
+
tags: options.tags,
|
|
2390
|
+
provider: 'dql',
|
|
2391
|
+
content: fileContent,
|
|
2392
|
+
});
|
|
2393
|
+
return {
|
|
2394
|
+
path: relativePath,
|
|
2395
|
+
content: fileContent,
|
|
2396
|
+
companionPath,
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
export function createSemanticBuilderBlock(projectRoot, options) {
|
|
2400
|
+
const slug = options.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block';
|
|
2401
|
+
const safeDomain = (options.domain ?? '')
|
|
2402
|
+
.trim()
|
|
2403
|
+
.toLowerCase()
|
|
2404
|
+
.replace(/[^a-z0-9/_-]+/g, '-')
|
|
2405
|
+
.replace(/^\/+|\/+$/g, '') || 'uncategorized';
|
|
2406
|
+
const blocksDir = join(projectRoot, 'blocks', safeDomain);
|
|
2407
|
+
mkdirSync(blocksDir, { recursive: true });
|
|
2408
|
+
const blockPath = join(blocksDir, `${slug}.dql`);
|
|
2409
|
+
if (existsSync(blockPath)) {
|
|
2410
|
+
throw new Error('BLOCK_EXISTS');
|
|
2411
|
+
}
|
|
2412
|
+
const content = options.blockType === 'custom'
|
|
2413
|
+
? buildCustomSemanticBlockContent(options)
|
|
2414
|
+
: buildSemanticBlockContent(options);
|
|
2415
|
+
writeFileSync(blockPath, content, 'utf-8');
|
|
2416
|
+
const companionPath = writeBlockCompanionFile(projectRoot, {
|
|
2417
|
+
slug,
|
|
2418
|
+
name: options.name,
|
|
2419
|
+
domain: safeDomain,
|
|
2420
|
+
description: options.description,
|
|
2421
|
+
owner: options.owner,
|
|
2422
|
+
tags: options.tags,
|
|
2423
|
+
provider: options.provider,
|
|
2424
|
+
content,
|
|
2425
|
+
lineage: options.tables,
|
|
2426
|
+
semanticMetrics: options.metrics,
|
|
2427
|
+
semanticDimensions: [
|
|
2428
|
+
...options.dimensions,
|
|
2429
|
+
...(options.timeDimension ? [options.timeDimension.name] : []),
|
|
2430
|
+
],
|
|
2431
|
+
});
|
|
2432
|
+
return {
|
|
2433
|
+
path: `blocks/${safeDomain}/${slug}.dql`,
|
|
2434
|
+
content,
|
|
2435
|
+
companionPath,
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
function buildSemanticBlockContent(options) {
|
|
2439
|
+
const lines = [
|
|
2440
|
+
`block "${options.name}" {`,
|
|
2441
|
+
` domain = "${options.domain ?? 'uncategorized'}"`,
|
|
2442
|
+
' type = "semantic"',
|
|
2443
|
+
];
|
|
2444
|
+
if (options.description)
|
|
2445
|
+
lines.push(` description = "${escapeDqlString(options.description)}"`);
|
|
2446
|
+
if (options.owner)
|
|
2447
|
+
lines.push(` owner = "${escapeDqlString(options.owner)}"`);
|
|
2448
|
+
if (options.tags && options.tags.length > 0) {
|
|
2449
|
+
lines.push(` tags = [${options.tags.map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`);
|
|
2450
|
+
}
|
|
2451
|
+
if (options.metrics.length === 1) {
|
|
2452
|
+
lines.push(` metric = "${escapeDqlString(options.metrics[0])}"`);
|
|
2453
|
+
}
|
|
2454
|
+
else {
|
|
2455
|
+
lines.push(` metrics = [${options.metrics.map((metric) => `"${escapeDqlString(metric)}"`).join(', ')}]`);
|
|
2456
|
+
}
|
|
2457
|
+
if (options.dimensions.length > 0) {
|
|
2458
|
+
lines.push(` dimensions = [${options.dimensions.map((dimension) => `"${escapeDqlString(dimension)}"`).join(', ')}]`);
|
|
2459
|
+
}
|
|
2460
|
+
if (options.timeDimension) {
|
|
2461
|
+
lines.push(` time_dimension = "${escapeDqlString(options.timeDimension.name)}"`);
|
|
2462
|
+
lines.push(` granularity = "${escapeDqlString(options.timeDimension.granularity)}"`);
|
|
2463
|
+
}
|
|
2464
|
+
const visualization = buildVisualizationBlock(options.chart ?? 'table', options.dimensions, options.timeDimension, options.metrics);
|
|
2465
|
+
if (visualization) {
|
|
2466
|
+
lines.push('');
|
|
2467
|
+
lines.push(...visualization);
|
|
2468
|
+
}
|
|
2469
|
+
lines.push('}');
|
|
2470
|
+
return lines.join('\n') + '\n';
|
|
2471
|
+
}
|
|
2472
|
+
function buildCustomSemanticBlockContent(options) {
|
|
2473
|
+
const lines = [
|
|
2474
|
+
`block "${options.name}" {`,
|
|
2475
|
+
` domain = "${options.domain ?? 'uncategorized'}"`,
|
|
2476
|
+
' type = "custom"',
|
|
2477
|
+
];
|
|
2478
|
+
if (options.description)
|
|
2479
|
+
lines.push(` description = "${escapeDqlString(options.description)}"`);
|
|
2480
|
+
if (options.owner)
|
|
2481
|
+
lines.push(` owner = "${escapeDqlString(options.owner)}"`);
|
|
2482
|
+
if (options.tags && options.tags.length > 0) {
|
|
2483
|
+
lines.push(` tags = [${options.tags.map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`);
|
|
2484
|
+
}
|
|
2485
|
+
lines.push('');
|
|
2486
|
+
lines.push(' query = """');
|
|
2487
|
+
lines.push(...indentBlock(options.sql.trim(), 8).split('\n'));
|
|
2488
|
+
lines.push(' """');
|
|
2489
|
+
const visualization = buildVisualizationBlock(options.chart ?? 'table', options.dimensions, options.timeDimension, options.metrics);
|
|
2490
|
+
if (visualization) {
|
|
2491
|
+
lines.push('');
|
|
2492
|
+
lines.push(...visualization);
|
|
2493
|
+
}
|
|
2494
|
+
lines.push('}');
|
|
2495
|
+
return lines.join('\n') + '\n';
|
|
2496
|
+
}
|
|
2497
|
+
function buildVisualizationBlock(chart, dimensions, timeDimension, metrics) {
|
|
2498
|
+
const x = timeDimension ? `${timeDimension.name}_${timeDimension.granularity}` : dimensions[0];
|
|
2499
|
+
const y = metrics[0];
|
|
2500
|
+
if (!x && chart !== 'kpi' && chart !== 'table')
|
|
2501
|
+
return null;
|
|
2502
|
+
if (chart === 'table') {
|
|
2503
|
+
return [' visualization {', ' chart = "table"', ' }'];
|
|
2504
|
+
}
|
|
2505
|
+
if (chart === 'kpi') {
|
|
2506
|
+
return [' visualization {', ' chart = "kpi"', ` y = ${y}`, ' }'];
|
|
2507
|
+
}
|
|
2508
|
+
return [
|
|
2509
|
+
' visualization {',
|
|
2510
|
+
` chart = "${chart}"`,
|
|
2511
|
+
` x = ${x}`,
|
|
2512
|
+
` y = ${y}`,
|
|
2513
|
+
' }',
|
|
2514
|
+
];
|
|
2515
|
+
}
|
|
2516
|
+
function writeBlockCompanionFile(projectRoot, options) {
|
|
2517
|
+
const extractedRefs = extractSemanticReferenceNames(options.content);
|
|
2518
|
+
const semanticMetrics = Array.from(new Set([...(options.semanticMetrics ?? []), ...extractedRefs.metrics]));
|
|
2519
|
+
const semanticDimensions = Array.from(new Set([...(options.semanticDimensions ?? []), ...extractedRefs.dimensions]));
|
|
2520
|
+
const companionDir = join(projectRoot, 'semantic-layer', 'blocks', options.domain);
|
|
2521
|
+
mkdirSync(companionDir, { recursive: true });
|
|
2522
|
+
const companionPath = join(companionDir, `${options.slug}.yaml`);
|
|
2523
|
+
const lines = [
|
|
2524
|
+
`name: ${options.slug}`,
|
|
2525
|
+
`block: ${options.slug}`,
|
|
2526
|
+
`domain: ${options.domain}`,
|
|
2527
|
+
`description: ${yamlScalar(options.description?.trim() || options.name)}`,
|
|
2528
|
+
];
|
|
2529
|
+
if (options.owner)
|
|
2530
|
+
lines.push(`owner: ${yamlScalar(options.owner)}`);
|
|
2531
|
+
if (options.tags && options.tags.length > 0) {
|
|
2532
|
+
lines.push('tags:');
|
|
2533
|
+
for (const tag of options.tags)
|
|
2534
|
+
lines.push(` - ${yamlScalar(tag)}`);
|
|
2535
|
+
}
|
|
2536
|
+
if (options.provider) {
|
|
2537
|
+
lines.push('source:');
|
|
2538
|
+
lines.push(` provider: ${yamlScalar(options.provider)}`);
|
|
2539
|
+
lines.push(' objectType: block');
|
|
2540
|
+
lines.push(` objectId: ${yamlScalar(options.slug)}`);
|
|
2541
|
+
}
|
|
2542
|
+
if (semanticMetrics.length > 0) {
|
|
2543
|
+
lines.push('semanticMetrics:');
|
|
2544
|
+
for (const metric of semanticMetrics)
|
|
2545
|
+
lines.push(` - ${yamlScalar(metric)}`);
|
|
2546
|
+
}
|
|
2547
|
+
if (semanticDimensions.length > 0) {
|
|
2548
|
+
lines.push('semanticDimensions:');
|
|
2549
|
+
for (const dimension of semanticDimensions)
|
|
2550
|
+
lines.push(` - ${yamlScalar(dimension)}`);
|
|
2551
|
+
}
|
|
2552
|
+
const mappingEntries = [
|
|
2553
|
+
...semanticMetrics.map((metric) => [metric, metric]),
|
|
2554
|
+
...semanticDimensions.map((dimension) => [dimension, dimension]),
|
|
2555
|
+
];
|
|
2556
|
+
if (mappingEntries.length > 0) {
|
|
2557
|
+
lines.push('semanticMappings:');
|
|
2558
|
+
for (const [key, value] of mappingEntries) {
|
|
2559
|
+
lines.push(` ${key}: ${yamlScalar(value)}`);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
if (options.lineage && options.lineage.length > 0) {
|
|
2563
|
+
lines.push('lineage:');
|
|
2564
|
+
for (const table of options.lineage)
|
|
2565
|
+
lines.push(` - ${yamlScalar(table)}`);
|
|
2566
|
+
}
|
|
2567
|
+
lines.push('reviewStatus: draft');
|
|
2568
|
+
writeFileSync(companionPath, lines.join('\n') + '\n', 'utf-8');
|
|
2569
|
+
return relative(projectRoot, companionPath).replaceAll('\\', '/');
|
|
2570
|
+
}
|
|
2571
|
+
function extractSemanticReferenceNames(content) {
|
|
2572
|
+
const metrics = new Set();
|
|
2573
|
+
const dimensions = new Set();
|
|
2574
|
+
const regex = /@(metric|dim)\(([^)]+)\)/gi;
|
|
2575
|
+
let match;
|
|
2576
|
+
while ((match = regex.exec(content))) {
|
|
2577
|
+
const name = match[2].trim();
|
|
2578
|
+
if (!name)
|
|
2579
|
+
continue;
|
|
2580
|
+
if (match[1].toLowerCase() === 'metric') {
|
|
2581
|
+
metrics.add(name);
|
|
2582
|
+
}
|
|
2583
|
+
else {
|
|
2584
|
+
dimensions.add(name);
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
return {
|
|
2588
|
+
metrics: Array.from(metrics),
|
|
2589
|
+
dimensions: Array.from(dimensions),
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
function escapeDqlString(value) {
|
|
2593
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
2594
|
+
}
|
|
2595
|
+
function indentBlock(value, spaces) {
|
|
2596
|
+
const prefix = ' '.repeat(spaces);
|
|
2597
|
+
return value.split('\n').map((line) => `${prefix}${line}`).join('\n');
|
|
2598
|
+
}
|
|
2599
|
+
function normalizeBlockStudioContent(options) {
|
|
2600
|
+
const content = options.content?.trim();
|
|
2601
|
+
if (content && /^\s*block\s+"/i.test(content)) {
|
|
2602
|
+
return `${content.trimEnd()}\n`;
|
|
2603
|
+
}
|
|
2604
|
+
return buildBlankBlockContent({
|
|
2605
|
+
name: options.name,
|
|
2606
|
+
domain: options.domain,
|
|
2607
|
+
description: options.description,
|
|
2608
|
+
tags: options.tags,
|
|
2609
|
+
sql: content || 'SELECT 1 AS value',
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
function buildBlankBlockContent(options) {
|
|
2613
|
+
const lines = [
|
|
2614
|
+
`block "${escapeDqlString(options.name)}" {`,
|
|
2615
|
+
` domain = "${escapeDqlString(options.domain)}"`,
|
|
2616
|
+
' type = "custom"',
|
|
2617
|
+
` description = "${escapeDqlString(options.description?.trim() || options.name)}"`,
|
|
2618
|
+
' owner = ""',
|
|
2619
|
+
];
|
|
2620
|
+
lines.push(` tags = [${(options.tags ?? []).map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`);
|
|
2621
|
+
lines.push('');
|
|
2622
|
+
lines.push(' query = """');
|
|
2623
|
+
lines.push(...indentBlock(options.sql.trim(), 8).split('\n'));
|
|
2624
|
+
lines.push(' """');
|
|
2625
|
+
lines.push('');
|
|
2626
|
+
lines.push(' visualization {');
|
|
2627
|
+
lines.push(' chart = "table"');
|
|
2628
|
+
lines.push(' }');
|
|
2629
|
+
lines.push('}');
|
|
2630
|
+
return lines.join('\n') + '\n';
|
|
2631
|
+
}
|
|
2632
|
+
function parseYamlScalar(value) {
|
|
2633
|
+
const trimmed = value.trim();
|
|
2634
|
+
if (!trimmed)
|
|
2635
|
+
return '';
|
|
2636
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
2637
|
+
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
2638
|
+
return trimmed.slice(1, -1);
|
|
2639
|
+
}
|
|
2640
|
+
return trimmed;
|
|
2641
|
+
}
|
|
2642
|
+
function yamlScalar(value) {
|
|
2643
|
+
if (/^[a-zA-Z0-9_.:/-]+$/.test(value))
|
|
2644
|
+
return value;
|
|
2645
|
+
return JSON.stringify(value);
|
|
2646
|
+
}
|
|
1024
2647
|
function buildNotebookTemplate(title, template) {
|
|
1025
2648
|
const id = () => Math.random().toString(36).slice(2, 10);
|
|
1026
2649
|
let cells;
|