@duckcodeailabs/dql-cli 0.8.9 → 0.8.10

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.
@@ -2,7 +2,7 @@ import { createServer } from 'node:http';
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
3
3
  import { dirname, extname, join, normalize, relative, resolve } from 'node:path';
4
4
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, } from '@duckcodeailabs/dql-notebook';
5
- import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, Parser, buildLineageGraph, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, searchNodes, } from '@duckcodeailabs/dql-core';
5
+ import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, Parser, buildLineageGraph, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, } from '@duckcodeailabs/dql-core';
6
6
  import { listBlockTemplates } from './block-templates.js';
7
7
  import { buildSemanticObjectDetail, buildSemanticTree, computeSyncDiff, loadSemanticImportManifest, performSemanticImport, previewSemanticImport, syncSemanticImport, } from './semantic-import.js';
8
8
  export async function startLocalServer(opts) {
@@ -595,17 +595,37 @@ export async function startLocalServer(opts) {
595
595
  try {
596
596
  const body = await readJSON(req);
597
597
  const source = typeof body.source === 'string' ? body.source : '';
598
+ const targetConnection = isConnectionConfig(body.connection) ? body.connection : connection;
599
+ let tableMapping;
600
+ if (semanticLayer) {
601
+ try {
602
+ const tablesResult = await executor.executeQuery(`SELECT table_schema, table_name
603
+ FROM information_schema.tables
604
+ WHERE table_schema NOT IN ('information_schema', 'pg_catalog')`, [], {}, targetConnection);
605
+ tableMapping = buildSemanticTableMapping(semanticLayer, tablesResult.rows);
606
+ }
607
+ catch {
608
+ tableMapping = undefined;
609
+ }
610
+ }
611
+ const semanticCompose = semanticLayer
612
+ ? composeSemanticBlockSql(source, semanticLayer, { driver: targetConnection.driver, tableMapping })
613
+ : null;
598
614
  const validation = validateBlockStudioSource(source, semanticLayer);
599
- if (!validation.executableSql) {
615
+ const executableSql = semanticCompose?.sql ?? validation.executableSql;
616
+ if (!executableSql) {
600
617
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
601
- res.end(serializeJSON({ error: 'No executable SQL found in block source.' }));
618
+ const message = semanticCompose?.diagnostics.find((item) => item.severity === 'error')?.message
619
+ ?? validation.diagnostics.find((item) => item.severity === 'error')?.message
620
+ ?? 'No executable SQL found in block source.';
621
+ res.end(serializeJSON({ error: message, diagnostics: validation.diagnostics }));
602
622
  return;
603
623
  }
604
- const sql = resolveProjectRelativeSqlPaths(validation.executableSql, projectRoot, projectConfig.dataDir);
605
- const result = await executor.executeQuery(sql, [], {}, connection);
624
+ const sql = resolveProjectRelativeSqlPaths(executableSql, projectRoot, projectConfig.dataDir);
625
+ const result = await executor.executeQuery(sql, [], {}, targetConnection);
606
626
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
607
627
  res.end(serializeJSON({
608
- sql: validation.executableSql,
628
+ sql: executableSql,
609
629
  result: normalizeQueryResult(result),
610
630
  chartConfig: validation.chartConfig ?? null,
611
631
  }));
@@ -1527,83 +1547,6 @@ export async function startLocalServer(opts) {
1527
1547
  }
1528
1548
  return;
1529
1549
  }
1530
- // ---- Lineage Query API (focused/searchable views) ----
1531
- if (req.method === 'GET' && path === '/api/lineage/query') {
1532
- try {
1533
- const graph = buildProjectLineageGraph(projectRoot, semanticLayer);
1534
- const focus = url.searchParams.get('focus') ?? undefined;
1535
- const search = url.searchParams.get('search') ?? undefined;
1536
- const typesParam = url.searchParams.get('types');
1537
- const domain = url.searchParams.get('domain') ?? undefined;
1538
- const upstreamDepth = url.searchParams.has('upstreamDepth')
1539
- ? Number(url.searchParams.get('upstreamDepth'))
1540
- : undefined;
1541
- const downstreamDepth = url.searchParams.has('downstreamDepth')
1542
- ? Number(url.searchParams.get('downstreamDepth'))
1543
- : undefined;
1544
- const types = typesParam
1545
- ? typesParam.split(',').map((t) => t.trim())
1546
- : undefined;
1547
- const result = queryLineage(graph, {
1548
- focus,
1549
- search,
1550
- types,
1551
- domain,
1552
- upstreamDepth,
1553
- downstreamDepth,
1554
- });
1555
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1556
- res.end(serializeJSON(result));
1557
- }
1558
- catch (error) {
1559
- res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1560
- res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
1561
- }
1562
- return;
1563
- }
1564
- if (req.method === 'GET' && path === '/api/lineage/search') {
1565
- const q = url.searchParams.get('q') ?? '';
1566
- if (!q) {
1567
- res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1568
- res.end(serializeJSON({ error: 'Missing "q" query parameter' }));
1569
- return;
1570
- }
1571
- try {
1572
- const graph = buildProjectLineageGraph(projectRoot, semanticLayer);
1573
- const limit = url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : 20;
1574
- const matches = searchNodes(graph, q, limit);
1575
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1576
- res.end(serializeJSON({ matches }));
1577
- }
1578
- catch (error) {
1579
- res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1580
- res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
1581
- }
1582
- return;
1583
- }
1584
- if (req.method === 'GET' && path.startsWith('/api/lineage/node/')) {
1585
- const nodeId = decodeURIComponent(path.slice('/api/lineage/node/'.length));
1586
- try {
1587
- const graph = buildProjectLineageGraph(projectRoot, semanticLayer);
1588
- const node = graph.getNode(nodeId);
1589
- if (!node) {
1590
- res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
1591
- res.end(serializeJSON({ error: `Node "${nodeId}" not found` }));
1592
- return;
1593
- }
1594
- const ancestors = graph.ancestors(nodeId);
1595
- const descendants = graph.descendants(nodeId);
1596
- const incoming = graph.getIncomingEdges(nodeId);
1597
- const outgoing = graph.getOutgoingEdges(nodeId);
1598
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1599
- res.end(serializeJSON({ node, ancestors, descendants, incoming, outgoing }));
1600
- }
1601
- catch (error) {
1602
- res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1603
- res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
1604
- }
1605
- return;
1606
- }
1607
1550
  if (req.method === 'GET' && path === '/api/notebook/bootstrap') {
1608
1551
  const welcomeNotebook = resolveNotebook(projectRoot, projectConfig.project ?? 'DQL Project');
1609
1552
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -2215,21 +2158,183 @@ function openBlockStudioDocument(projectRoot, relativePath, semanticLayer) {
2215
2158
  validation: validateBlockStudioSource(source, semanticLayer),
2216
2159
  };
2217
2160
  }
2218
- function validateBlockStudioSource(source, semanticLayer) {
2161
+ function parseBlockStudioArrayField(source, key) {
2162
+ const match = source.match(new RegExp(`\\b${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`, 'i'));
2163
+ if (!match)
2164
+ return [];
2165
+ return (match[1].match(/"([^"]*)"/g) ?? []).map((value) => value.slice(1, -1)).filter(Boolean);
2166
+ }
2167
+ function parseBlockStudioStringField(source, key) {
2168
+ return source.match(new RegExp(`\\b${key}\\s*=\\s*"([^"]*)"`, 'i'))?.[1] ?? undefined;
2169
+ }
2170
+ function parseSemanticBlockConfig(source) {
2171
+ const blockType = (parseBlockStudioStringField(source, 'type') ?? 'custom').toLowerCase() === 'semantic'
2172
+ ? 'semantic'
2173
+ : 'custom';
2174
+ const metric = parseBlockStudioStringField(source, 'metric');
2175
+ const metrics = parseBlockStudioArrayField(source, 'metrics');
2176
+ const dimensions = parseBlockStudioArrayField(source, 'dimensions');
2177
+ const timeDimension = parseBlockStudioStringField(source, 'time_dimension');
2178
+ const granularity = parseBlockStudioStringField(source, 'granularity');
2179
+ const limitMatch = source.match(/\blimit\s*=\s*(\d+)/i);
2180
+ return {
2181
+ blockType,
2182
+ metric,
2183
+ metrics,
2184
+ dimensions,
2185
+ timeDimension,
2186
+ granularity,
2187
+ limit: limitMatch ? Number.parseInt(limitMatch[1], 10) : undefined,
2188
+ };
2189
+ }
2190
+ function buildSemanticTableMapping(semanticLayer, rows) {
2191
+ const dbTableNames = new Set();
2192
+ const schemaQualified = new Map();
2193
+ for (const row of rows) {
2194
+ const schema = String(row['table_schema'] ?? '');
2195
+ const name = String(row['table_name'] ?? '');
2196
+ if (!name)
2197
+ continue;
2198
+ dbTableNames.add(name);
2199
+ schemaQualified.set(name, schema ? `${schema}.${name}` : name);
2200
+ }
2201
+ const tableMapping = {};
2202
+ const allSemanticTables = new Set();
2203
+ for (const metric of semanticLayer.listMetrics())
2204
+ allSemanticTables.add(metric.table);
2205
+ for (const dimension of semanticLayer.listDimensions())
2206
+ allSemanticTables.add(dimension.table);
2207
+ for (const semTable of allSemanticTables) {
2208
+ if (dbTableNames.has(semTable) && schemaQualified.has(semTable)) {
2209
+ tableMapping[semTable] = schemaQualified.get(semTable);
2210
+ }
2211
+ }
2212
+ return Object.keys(tableMapping).length > 0 ? tableMapping : undefined;
2213
+ }
2214
+ function composeSemanticBlockSql(source, semanticLayer, options) {
2215
+ const config = parseSemanticBlockConfig(source);
2216
+ const metrics = config.metrics.length > 0
2217
+ ? config.metrics
2218
+ : config.metric
2219
+ ? [config.metric]
2220
+ : [];
2221
+ const semanticRefs = {
2222
+ metrics,
2223
+ dimensions: config.dimensions,
2224
+ segments: [],
2225
+ };
2219
2226
  const diagnostics = [];
2220
- try {
2221
- const parser = new Parser(source, '<block-studio>');
2222
- parser.parse();
2227
+ if (config.blockType !== 'semantic') {
2228
+ return { sql: null, diagnostics, semanticRefs };
2223
2229
  }
2224
- catch (error) {
2230
+ if (metrics.length === 0) {
2225
2231
  diagnostics.push({
2226
2232
  severity: 'error',
2227
- code: 'syntax',
2228
- message: error instanceof Error ? error.message : String(error),
2233
+ code: 'semantic_metric_missing',
2234
+ message: 'Semantic block is missing a metric. Add metric = "metric_name" or metrics = ["metric_name"].',
2229
2235
  });
2236
+ return { sql: null, diagnostics, semanticRefs };
2230
2237
  }
2231
- const semanticRefs = extractBlockStudioSemanticReferences(source);
2232
- if (semanticLayer) {
2238
+ if (config.timeDimension && !config.granularity) {
2239
+ diagnostics.push({
2240
+ severity: 'error',
2241
+ code: 'semantic_granularity_missing',
2242
+ message: `Semantic block selects time_dimension = "${config.timeDimension}" but is missing granularity.`,
2243
+ });
2244
+ }
2245
+ const refValidation = semanticLayer.validateReferences([...metrics, ...config.dimensions]);
2246
+ for (const unknown of refValidation.unknown) {
2247
+ diagnostics.push({
2248
+ severity: 'error',
2249
+ code: 'semantic_ref',
2250
+ message: `Unknown semantic reference: ${unknown}`,
2251
+ });
2252
+ }
2253
+ if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) {
2254
+ return { sql: null, diagnostics, semanticRefs };
2255
+ }
2256
+ const composed = semanticLayer.composeQuery({
2257
+ metrics,
2258
+ dimensions: config.dimensions,
2259
+ timeDimension: config.timeDimension && config.granularity
2260
+ ? { name: config.timeDimension, granularity: config.granularity }
2261
+ : undefined,
2262
+ limit: config.limit,
2263
+ driver: options?.driver,
2264
+ tableMapping: options?.tableMapping,
2265
+ });
2266
+ if (!composed) {
2267
+ diagnostics.push({
2268
+ severity: 'error',
2269
+ code: 'semantic_compose_failed',
2270
+ message: `Could not compose SQL for semantic block metrics: [${metrics.join(', ')}].`,
2271
+ });
2272
+ return { sql: null, diagnostics, semanticRefs };
2273
+ }
2274
+ return {
2275
+ sql: composed.sql,
2276
+ diagnostics,
2277
+ semanticRefs,
2278
+ };
2279
+ }
2280
+ export function validateBlockStudioSource(source, semanticLayer) {
2281
+ const diagnostics = [];
2282
+ const semanticConfig = parseSemanticBlockConfig(source);
2283
+ if (semanticConfig.blockType !== 'semantic') {
2284
+ try {
2285
+ const parser = new Parser(source, '<block-studio>');
2286
+ parser.parse();
2287
+ }
2288
+ catch (error) {
2289
+ diagnostics.push({
2290
+ severity: 'error',
2291
+ code: 'syntax',
2292
+ message: error instanceof Error ? error.message : String(error),
2293
+ });
2294
+ }
2295
+ }
2296
+ else {
2297
+ const hasBlockHeader = /\bblock\s+"[^"]+"\s*\{/i.test(source);
2298
+ const hasClosingBrace = /\}\s*$/m.test(source);
2299
+ if (!hasBlockHeader || !hasClosingBrace) {
2300
+ diagnostics.push({
2301
+ severity: 'error',
2302
+ code: 'semantic_shape',
2303
+ message: 'Semantic block must use block "Name" { ... } structure.',
2304
+ });
2305
+ }
2306
+ }
2307
+ let semanticRefs = extractBlockStudioSemanticReferences(source);
2308
+ if (semanticConfig.blockType === 'semantic') {
2309
+ const selectedMetrics = semanticConfig.metrics.length > 0
2310
+ ? semanticConfig.metrics
2311
+ : semanticConfig.metric
2312
+ ? [semanticConfig.metric]
2313
+ : [];
2314
+ semanticRefs = {
2315
+ metrics: selectedMetrics,
2316
+ dimensions: semanticConfig.dimensions,
2317
+ segments: semanticRefs.segments,
2318
+ };
2319
+ }
2320
+ let executableSql = extractBlockStudioSql(source);
2321
+ if (semanticConfig.blockType === 'semantic') {
2322
+ if (semanticLayer) {
2323
+ const semanticCompose = composeSemanticBlockSql(source, semanticLayer);
2324
+ semanticRefs = semanticCompose.semanticRefs;
2325
+ diagnostics.push(...semanticCompose.diagnostics);
2326
+ executableSql = semanticCompose.sql;
2327
+ }
2328
+ else {
2329
+ diagnostics.push({
2330
+ severity: 'error',
2331
+ code: 'semantic_layer_missing',
2332
+ message: 'Semantic block cannot run because no semantic layer is configured.',
2333
+ });
2334
+ executableSql = null;
2335
+ }
2336
+ }
2337
+ else if (semanticLayer) {
2233
2338
  const refValidation = semanticLayer.validateReferences([
2234
2339
  ...semanticRefs.metrics,
2235
2340
  ...semanticRefs.dimensions,
@@ -2251,13 +2356,18 @@ function validateBlockStudioSource(source, semanticLayer) {
2251
2356
  message: 'Block has no visualization section yet.',
2252
2357
  });
2253
2358
  }
2254
- const executableSql = extractBlockStudioSql(source);
2255
2359
  if (!executableSql) {
2256
- diagnostics.push({
2257
- severity: 'warning',
2258
- code: 'sql_missing',
2259
- message: 'No executable SQL found in the block source.',
2260
- });
2360
+ diagnostics.push(semanticConfig.blockType === 'semantic'
2361
+ ? {
2362
+ severity: 'warning',
2363
+ code: 'semantic_not_runnable',
2364
+ message: 'Semantic block is not runnable yet. Select a metric and complete any required time settings.',
2365
+ }
2366
+ : {
2367
+ severity: 'warning',
2368
+ code: 'sql_missing',
2369
+ message: 'No executable SQL found in the block source.',
2370
+ });
2261
2371
  }
2262
2372
  return {
2263
2373
  valid: diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),