@duckcodeailabs/dql-cli 0.8.4 → 0.8.6
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 +1690 -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 +8 -7
- package/dist/assets/dql-notebook/assets/index-DeyBtNqN.js +0 -533
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,358 @@ 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 }));
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
530
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
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
|
+
}));
|
|
286
612
|
}
|
|
287
613
|
catch (error) {
|
|
288
614
|
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -290,6 +616,47 @@ export async function startLocalServer(opts) {
|
|
|
290
616
|
}
|
|
291
617
|
return;
|
|
292
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;
|
|
@@ -305,8 +672,60 @@ export async function startLocalServer(opts) {
|
|
|
305
672
|
res.end(serializeJSON({ default: defaultKey, connections }));
|
|
306
673
|
return;
|
|
307
674
|
}
|
|
675
|
+
// Save/update connections
|
|
676
|
+
if (req.method === 'PUT' && path === '/api/connections') {
|
|
677
|
+
try {
|
|
678
|
+
const body = await readJSON(req);
|
|
679
|
+
const configPath = join(projectRoot, 'dql.config.json');
|
|
680
|
+
let raw = {};
|
|
681
|
+
if (existsSync(configPath)) {
|
|
682
|
+
raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
683
|
+
}
|
|
684
|
+
if (body.connections && typeof body.connections === 'object') {
|
|
685
|
+
raw.connections = body.connections;
|
|
686
|
+
}
|
|
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
|
+
}
|
|
717
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
718
|
+
res.end(serializeJSON({ ok: true }));
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
722
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
308
726
|
// ── Semantic layer discovery API ─────────────────────────────────────────
|
|
309
727
|
if (req.method === 'GET' && path === '/api/semantic-layer') {
|
|
728
|
+
const userPrefs = readUserPrefs(userPrefsPath);
|
|
310
729
|
if (!semanticLayer) {
|
|
311
730
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
312
731
|
res.end(serializeJSON({
|
|
@@ -316,6 +735,11 @@ export async function startLocalServer(opts) {
|
|
|
316
735
|
metrics: [],
|
|
317
736
|
dimensions: [],
|
|
318
737
|
hierarchies: [],
|
|
738
|
+
domains: [],
|
|
739
|
+
tags: [],
|
|
740
|
+
favorites: userPrefs.favorites,
|
|
741
|
+
recentlyUsed: userPrefs.recentlyUsed,
|
|
742
|
+
lastSyncTime: semanticLastSyncTime,
|
|
319
743
|
}));
|
|
320
744
|
return;
|
|
321
745
|
}
|
|
@@ -324,6 +748,7 @@ export async function startLocalServer(opts) {
|
|
|
324
748
|
label: m.label,
|
|
325
749
|
description: m.description,
|
|
326
750
|
domain: m.domain,
|
|
751
|
+
sql: m.sql,
|
|
327
752
|
type: m.type,
|
|
328
753
|
table: m.table,
|
|
329
754
|
tags: m.tags ?? [],
|
|
@@ -333,9 +758,12 @@ export async function startLocalServer(opts) {
|
|
|
333
758
|
name: d.name,
|
|
334
759
|
label: d.label,
|
|
335
760
|
description: d.description,
|
|
761
|
+
domain: d.domain,
|
|
762
|
+
sql: d.sql,
|
|
336
763
|
type: d.type,
|
|
337
764
|
table: d.table,
|
|
338
765
|
tags: d.tags ?? [],
|
|
766
|
+
owner: d.owner ?? null,
|
|
339
767
|
}));
|
|
340
768
|
const hierarchies = semanticLayer.listHierarchies().map((h) => ({
|
|
341
769
|
name: h.name,
|
|
@@ -352,7 +780,343 @@ export async function startLocalServer(opts) {
|
|
|
352
780
|
metrics,
|
|
353
781
|
dimensions,
|
|
354
782
|
hierarchies,
|
|
783
|
+
domains: semanticLayer.listDomains(),
|
|
784
|
+
tags: semanticLayer.listTags(),
|
|
785
|
+
favorites: userPrefs.favorites,
|
|
786
|
+
recentlyUsed: userPrefs.recentlyUsed,
|
|
787
|
+
lastSyncTime: semanticLastSyncTime,
|
|
788
|
+
}));
|
|
789
|
+
return;
|
|
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,
|
|
355
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
|
+
}
|
|
356
1120
|
return;
|
|
357
1121
|
}
|
|
358
1122
|
// ── Semantic completions for SQL cells ─────────────────────────────────────
|
|
@@ -360,10 +1124,26 @@ export async function startLocalServer(opts) {
|
|
|
360
1124
|
const completions = [];
|
|
361
1125
|
if (semanticLayer) {
|
|
362
1126
|
for (const m of semanticLayer.listMetrics()) {
|
|
363
|
-
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
|
+
});
|
|
364
1136
|
}
|
|
365
1137
|
for (const d of semanticLayer.listDimensions()) {
|
|
366
|
-
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
|
+
});
|
|
367
1147
|
}
|
|
368
1148
|
}
|
|
369
1149
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -371,6 +1151,56 @@ export async function startLocalServer(opts) {
|
|
|
371
1151
|
return;
|
|
372
1152
|
}
|
|
373
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
|
+
}
|
|
374
1204
|
if (req.method === 'POST' && path === '/api/query') {
|
|
375
1205
|
try {
|
|
376
1206
|
const body = await readJSON(req);
|
|
@@ -465,6 +1295,119 @@ export async function startLocalServer(opts) {
|
|
|
465
1295
|
}
|
|
466
1296
|
return;
|
|
467
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
|
+
}
|
|
468
1411
|
if (req.method === 'POST' && path === '/api/test-connection') {
|
|
469
1412
|
try {
|
|
470
1413
|
const body = await readJSON(req);
|
|
@@ -968,23 +1911,32 @@ function scanNotebookFiles(projectRoot) {
|
|
|
968
1911
|
const dir = join(projectRoot, folder);
|
|
969
1912
|
if (!existsSync(dir))
|
|
970
1913
|
continue;
|
|
1914
|
+
collect(dir, folder, type);
|
|
1915
|
+
}
|
|
1916
|
+
return result;
|
|
1917
|
+
function collect(currentDir, relativeDir, type) {
|
|
971
1918
|
try {
|
|
972
|
-
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
|
+
}
|
|
973
1926
|
if (!entry.isFile())
|
|
974
1927
|
continue;
|
|
975
1928
|
if (!entry.name.endsWith('.dql') && !entry.name.endsWith('.dqlnb'))
|
|
976
1929
|
continue;
|
|
977
1930
|
result.push({
|
|
978
1931
|
name: entry.name.replace(/\.(dql|dqlnb)$/, ''),
|
|
979
|
-
path:
|
|
1932
|
+
path: relativePath,
|
|
980
1933
|
type,
|
|
981
|
-
folder,
|
|
1934
|
+
folder: relativeDir.split('/')[0] ?? relativeDir,
|
|
982
1935
|
});
|
|
983
1936
|
}
|
|
984
1937
|
}
|
|
985
1938
|
catch { /* skip unreadable dirs */ }
|
|
986
1939
|
}
|
|
987
|
-
return result;
|
|
988
1940
|
}
|
|
989
1941
|
function scanDataFiles(projectRoot) {
|
|
990
1942
|
const dataDir = join(projectRoot, 'data');
|
|
@@ -999,6 +1951,699 @@ function scanDataFiles(projectRoot) {
|
|
|
999
1951
|
return [];
|
|
1000
1952
|
}
|
|
1001
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
|
+
}
|
|
1002
2647
|
function buildNotebookTemplate(title, template) {
|
|
1003
2648
|
const id = () => Math.random().toString(36).slice(2, 10);
|
|
1004
2649
|
let cells;
|