@duckcodeailabs/dql-cli 1.6.18 → 1.6.20
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/dist/apps-api.d.ts +41 -1
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +297 -18
- package/dist/apps-api.js.map +1 -1
- package/dist/args.d.ts +4 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +10 -1
- package/dist/args.js.map +1 -1
- package/dist/assets/dql-notebook/assets/index-Dgqvyn5s.js +3886 -0
- package/dist/assets/dql-notebook/index.html +1 -1
- package/dist/block-studio-import.d.ts +99 -3
- package/dist/block-studio-import.d.ts.map +1 -1
- package/dist/block-studio-import.js +772 -27
- package/dist/block-studio-import.js.map +1 -1
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +12 -1
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/app.d.ts +2 -1
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +22 -4
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/certify.d.ts.map +1 -1
- package/dist/commands/certify.js +104 -10
- package/dist/commands/certify.js.map +1 -1
- package/dist/commands/compile.d.ts +1 -0
- package/dist/commands/compile.d.ts.map +1 -1
- package/dist/commands/compile.js +26 -4
- package/dist/commands/compile.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +345 -6
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/import.d.ts.map +1 -1
- package/dist/commands/import.js +45 -70
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/lineage.d.ts +2 -0
- package/dist/commands/lineage.d.ts.map +1 -1
- package/dist/commands/lineage.js +111 -0
- package/dist/commands/lineage.js.map +1 -1
- package/dist/commands/migrate.d.ts +1 -0
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +106 -2
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/new.d.ts.map +1 -1
- package/dist/commands/new.js +246 -9
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/promote.d.ts +11 -0
- package/dist/commands/promote.d.ts.map +1 -0
- package/dist/commands/promote.js +236 -0
- package/dist/commands/promote.js.map +1 -0
- package/dist/commands/semantic.js +2 -2
- package/dist/commands/semantic.js.map +1 -1
- package/dist/index.js +48 -8
- package/dist/index.js.map +1 -1
- package/dist/llm/providers/claude-agent-sdk.js +1 -1
- package/dist/llm/providers/claude-agent-sdk.js.map +1 -1
- package/dist/llm/tools.js +1 -1
- package/dist/llm/tools.js.map +1 -1
- package/dist/local-runtime.d.ts +176 -5
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +3695 -146
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- package/dist/promote-from-draft.d.ts +11 -10
- package/dist/promote-from-draft.d.ts.map +1 -1
- package/dist/promote-from-draft.js +53 -30
- package/dist/promote-from-draft.js.map +1 -1
- package/dist/schedule/runner.js +2 -2
- package/dist/schedule/runner.js.map +1 -1
- package/dist/semantic-import.d.ts.map +1 -1
- package/dist/semantic-import.js +28 -12
- package/dist/semantic-import.js.map +1 -1
- package/package.json +11 -11
- package/dist/assets/dql-notebook/assets/index-BbGJ8gZk.js +0 -3639
- package/dist/assets/dql-notebook/assets/index-C4oZK5xm.js +0 -3612
- package/dist/assets/dql-notebook/assets/index-CEMXuPm0.js +0 -3802
- package/dist/assets/dql-notebook/assets/index-CIMLd3Cb.js +0 -3289
- package/dist/assets/dql-notebook/assets/index-DISMWw19.js +0 -3780
- package/dist/assets/dql-notebook/assets/index-DPcxWQuS.js +0 -3859
- package/dist/assets/dql-notebook/assets/index-McpLeeN0.js +0 -3802
|
@@ -142,11 +142,55 @@ export function updateBlockStudioImportCandidate(projectRoot, importId, candidat
|
|
|
142
142
|
next.owner = patch.owner;
|
|
143
143
|
if (patch.tags !== undefined)
|
|
144
144
|
next.tags = normalizeTags(patch.tags);
|
|
145
|
+
if (patch.terms !== undefined)
|
|
146
|
+
next.terms = normalizeStringList(patch.terms);
|
|
147
|
+
if (patch.pattern !== undefined)
|
|
148
|
+
next.pattern = normalizePattern(patch.pattern);
|
|
149
|
+
if (patch.grain !== undefined)
|
|
150
|
+
next.grain = patch.grain;
|
|
151
|
+
if (patch.entities !== undefined)
|
|
152
|
+
next.entities = normalizeStringList(patch.entities);
|
|
153
|
+
if (patch.outputs !== undefined)
|
|
154
|
+
next.outputs = normalizeStringList(patch.outputs);
|
|
155
|
+
if (patch.dimensions !== undefined)
|
|
156
|
+
next.dimensions = normalizeStringList(patch.dimensions);
|
|
157
|
+
if (patch.allowedFilters !== undefined)
|
|
158
|
+
next.allowedFilters = normalizeStringList(patch.allowedFilters);
|
|
159
|
+
if (patch.parameterPolicy !== undefined)
|
|
160
|
+
next.parameterPolicy = normalizeParameterPolicy(patch.parameterPolicy);
|
|
161
|
+
if (patch.filterBindings !== undefined)
|
|
162
|
+
next.filterBindings = normalizeFilterBindings(patch.filterBindings);
|
|
163
|
+
if (patch.sourceSystems !== undefined)
|
|
164
|
+
next.sourceSystems = normalizeStringList(patch.sourceSystems);
|
|
165
|
+
if (patch.replacementFor !== undefined)
|
|
166
|
+
next.replacementFor = normalizeStringList(patch.replacementFor);
|
|
167
|
+
if (patch.reviewCadence !== undefined)
|
|
168
|
+
next.reviewCadence = normalizeReviewCadence(patch.reviewCadence) || 'monthly';
|
|
169
|
+
if (patch.parameterDecisions !== undefined)
|
|
170
|
+
next.parameterDecisions = patch.parameterDecisions;
|
|
171
|
+
if (patch.similarityMatches !== undefined)
|
|
172
|
+
next.similarityMatches = patch.similarityMatches;
|
|
173
|
+
if (patch.recommendedAction !== undefined)
|
|
174
|
+
next.recommendedAction = patch.recommendedAction;
|
|
145
175
|
if (patch.sql !== undefined)
|
|
146
176
|
next.sql = patch.sql;
|
|
147
177
|
if (patch.reviewStatus !== undefined)
|
|
148
178
|
next.reviewStatus = patch.reviewStatus;
|
|
149
|
-
if (patch.
|
|
179
|
+
if (patch.llmContext !== undefined)
|
|
180
|
+
next.llmContext = patch.llmContext;
|
|
181
|
+
if (patch.evidence !== undefined)
|
|
182
|
+
next.evidence = patch.evidence;
|
|
183
|
+
if (patch.draftSave !== undefined)
|
|
184
|
+
next.draftSave = patch.draftSave;
|
|
185
|
+
if (patch.generationMode !== undefined)
|
|
186
|
+
next.generationMode = patch.generationMode;
|
|
187
|
+
if (patch.generationProvider !== undefined)
|
|
188
|
+
next.generationProvider = patch.generationProvider;
|
|
189
|
+
if (patch.savedPath !== undefined)
|
|
190
|
+
next.savedPath = patch.savedPath;
|
|
191
|
+
if (patch.conversionNotes !== undefined)
|
|
192
|
+
next.conversionNotes = patch.conversionNotes;
|
|
193
|
+
if (patch.name || patch.domain || patch.description || patch.owner || patch.tags || patch.terms || patch.pattern || patch.grain || patch.entities || patch.outputs || patch.dimensions || patch.allowedFilters || patch.parameterPolicy || patch.filterBindings || patch.sourceSystems || patch.replacementFor || patch.reviewCadence || patch.parameterDecisions || patch.sql || patch.llmContext) {
|
|
150
194
|
next.dqlSource = candidateToDqlSource(next);
|
|
151
195
|
next.lineage = {
|
|
152
196
|
...next.lineage,
|
|
@@ -162,28 +206,561 @@ export function updateBlockStudioImportCandidate(projectRoot, importId, candidat
|
|
|
162
206
|
}
|
|
163
207
|
export function candidateToDqlSource(candidate) {
|
|
164
208
|
const tags = normalizeTags(candidate.tags).map((tag) => dqlString(tag)).join(', ');
|
|
209
|
+
const terms = normalizeStringList(candidate.terms ?? []).slice(0, 16);
|
|
165
210
|
const sql = candidate.sql.trim().replace(/"""/g, '\\"\\"\\"');
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
211
|
+
const llmContext = candidate.llmContext?.trim();
|
|
212
|
+
const pattern = normalizePattern(candidate.pattern || inferSqlPattern(candidate.sql));
|
|
213
|
+
const grain = candidate.grain?.trim() || inferSqlGrain(candidate.sql);
|
|
214
|
+
const outputs = normalizeStringList(candidate.outputs?.length ? candidate.outputs : inferSqlOutputs(candidate.sql)).slice(0, 24);
|
|
215
|
+
const dimensions = normalizeStringList(candidate.dimensions?.length ? candidate.dimensions : inferSqlDimensions(candidate.sql, grain, outputs)).slice(0, 16);
|
|
216
|
+
const sourceSystems = normalizeStringList(candidate.sourceSystems?.length ? candidate.sourceSystems : inferSqlSourceSystems(candidate.sql)).slice(0, 12);
|
|
217
|
+
const entities = normalizeStringList(candidate.entities?.length ? candidate.entities : inferSqlEntities(grain, sourceSystems)).slice(0, 12);
|
|
218
|
+
const allowedFilters = normalizeStringList(candidate.allowedFilters?.length ? candidate.allowedFilters : inferSqlFilters(candidate.sql)).slice(0, 16);
|
|
219
|
+
const parameterDecisions = normalizeParameterDecisions(candidate.parameterDecisions ?? []);
|
|
220
|
+
const parameterPolicy = normalizeParameterPolicy(candidate.parameterPolicy?.length
|
|
221
|
+
? candidate.parameterPolicy
|
|
222
|
+
: parameterDecisions.map((decision) => ({ name: decision.name, policy: decision.policy })));
|
|
223
|
+
const filterBindings = normalizeFilterBindings(candidate.filterBindings ?? []);
|
|
224
|
+
const reviewCadence = normalizeReviewCadence(candidate.reviewCadence || 'monthly');
|
|
225
|
+
const params = parameterDecisions.filter((decision) => decision.policy !== 'static');
|
|
226
|
+
const lines = [
|
|
227
|
+
`block ${dqlString(candidate.name)} {`,
|
|
228
|
+
' status = "draft"',
|
|
229
|
+
` domain = ${dqlString(sanitizeDomain(candidate.domain))}`,
|
|
230
|
+
' type = "custom"',
|
|
231
|
+
` description = ${dqlString(candidate.description)}`,
|
|
232
|
+
` tags = [${tags}]`,
|
|
233
|
+
` owner = ${dqlString(candidate.owner)}`,
|
|
234
|
+
];
|
|
235
|
+
if (pattern)
|
|
236
|
+
lines.push(` pattern = ${dqlString(pattern)}`);
|
|
237
|
+
if (grain)
|
|
238
|
+
lines.push(` grain = ${dqlString(grain)}`);
|
|
239
|
+
if (entities.length > 0)
|
|
240
|
+
lines.push(` entities = [${entities.map(dqlString).join(', ')}]`);
|
|
241
|
+
if (outputs.length > 0)
|
|
242
|
+
lines.push(` outputs = [${outputs.map(dqlString).join(', ')}]`);
|
|
243
|
+
if (terms.length > 0)
|
|
244
|
+
lines.push(` terms = [${terms.map(dqlString).join(', ')}]`);
|
|
245
|
+
if (dimensions.length > 0)
|
|
246
|
+
lines.push(` dimensions = [${dimensions.map(dqlString).join(', ')}]`);
|
|
247
|
+
if (allowedFilters.length > 0)
|
|
248
|
+
lines.push(` allowedFilters = [${allowedFilters.map(dqlString).join(', ')}]`);
|
|
249
|
+
if (parameterPolicy.length > 0) {
|
|
250
|
+
lines.push(' parameterPolicy {');
|
|
251
|
+
lines.push(...parameterPolicy.map((entry) => ` ${entry.name} = ${dqlString(entry.policy)}`));
|
|
252
|
+
lines.push(' }');
|
|
253
|
+
}
|
|
254
|
+
if (filterBindings.length > 0) {
|
|
255
|
+
lines.push(' filterBindings {');
|
|
256
|
+
lines.push(...filterBindings.map((entry) => ` ${entry.filter} = ${dqlString(entry.binding)}`));
|
|
257
|
+
lines.push(' }');
|
|
258
|
+
}
|
|
259
|
+
if (sourceSystems.length > 0)
|
|
260
|
+
lines.push(` sourceSystems = [${sourceSystems.map(dqlString).join(', ')}]`);
|
|
261
|
+
if (candidate.replacementFor?.length)
|
|
262
|
+
lines.push(` replacementFor = [${candidate.replacementFor.map(dqlString).join(', ')}]`);
|
|
263
|
+
if (reviewCadence)
|
|
264
|
+
lines.push(` reviewCadence = ${dqlString(reviewCadence)}`);
|
|
265
|
+
if (llmContext)
|
|
266
|
+
lines.push(` llmContext = ${dqlString(llmContext)}`);
|
|
267
|
+
if (params.length > 0) {
|
|
268
|
+
lines.push('', ' params {');
|
|
269
|
+
lines.push(...params.map((param) => ` ${param.name} = ${formatDqlLiteral(param.value)}`));
|
|
270
|
+
lines.push(' }');
|
|
271
|
+
}
|
|
272
|
+
lines.push('', ' query = """', sql, ' """');
|
|
273
|
+
lines.push('', ' visualization {', ' chart = "table"', ' }');
|
|
274
|
+
lines.push('', ' tests {', ' assert row_count > 0', ' }', '}');
|
|
275
|
+
return `${lines.join('\n')}\n`;
|
|
276
|
+
}
|
|
277
|
+
function normalizeParameterPolicy(value) {
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
const result = [];
|
|
280
|
+
for (const item of value) {
|
|
281
|
+
const name = normalizeIdentifier(item.name);
|
|
282
|
+
const policy = String(item.policy ?? '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_');
|
|
283
|
+
if (!name || !policy || seen.has(name))
|
|
284
|
+
continue;
|
|
285
|
+
seen.add(name);
|
|
286
|
+
result.push({ name, policy });
|
|
287
|
+
}
|
|
288
|
+
return result.slice(0, 24);
|
|
289
|
+
}
|
|
290
|
+
function normalizeFilterBindings(value) {
|
|
291
|
+
const seen = new Set();
|
|
292
|
+
const result = [];
|
|
293
|
+
for (const item of value) {
|
|
294
|
+
const filter = normalizeIdentifier(item.filter);
|
|
295
|
+
const binding = String(item.binding ?? '').trim();
|
|
296
|
+
if (!filter || !binding || seen.has(filter))
|
|
297
|
+
continue;
|
|
298
|
+
seen.add(filter);
|
|
299
|
+
result.push({ filter, binding });
|
|
300
|
+
}
|
|
301
|
+
return result.slice(0, 24);
|
|
302
|
+
}
|
|
303
|
+
function normalizeReviewCadence(value) {
|
|
304
|
+
const normalized = String(value ?? '').trim().toLowerCase().replace(/[^a-z0-9_ -]+/g, '').replace(/\s+/g, '_');
|
|
305
|
+
const allowed = new Set(['daily', 'weekly', 'biweekly', 'monthly', 'quarterly', 'semiannual', 'annual']);
|
|
306
|
+
return allowed.has(normalized) ? normalized : '';
|
|
307
|
+
}
|
|
308
|
+
function normalizeParameterDecisions(value) {
|
|
309
|
+
const seen = new Set();
|
|
310
|
+
const result = [];
|
|
311
|
+
for (const item of value) {
|
|
312
|
+
const name = normalizeIdentifier(item.name);
|
|
313
|
+
if (!name || seen.has(name))
|
|
314
|
+
continue;
|
|
315
|
+
seen.add(name);
|
|
316
|
+
result.push({
|
|
317
|
+
...item,
|
|
318
|
+
name,
|
|
319
|
+
policy: normalizeParameterPolicyKind(item.policy),
|
|
320
|
+
reason: item.reason || 'Detected from imported SQL literal.',
|
|
321
|
+
confidence: Math.max(0, Math.min(1, Number(item.confidence) || 0.5)),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return result.slice(0, 24);
|
|
325
|
+
}
|
|
326
|
+
function normalizeParameterPolicyKind(value) {
|
|
327
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
328
|
+
if (normalized === 'dynamic'
|
|
329
|
+
|| normalized === 'static'
|
|
330
|
+
|| normalized === 'business'
|
|
331
|
+
|| normalized === 'derived'
|
|
332
|
+
|| normalized === 'optional'
|
|
333
|
+
|| normalized === 'ambiguous_review_required')
|
|
334
|
+
return normalized;
|
|
335
|
+
return 'ambiguous_review_required';
|
|
336
|
+
}
|
|
337
|
+
function normalizeIdentifier(value) {
|
|
338
|
+
return String(value || '').trim().replace(/[^A-Za-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').replace(/^([0-9])/, '_$1');
|
|
339
|
+
}
|
|
340
|
+
function formatDqlLiteral(value) {
|
|
341
|
+
if (Array.isArray(value))
|
|
342
|
+
return `[${value.map(formatDqlLiteral).join(', ')}]`;
|
|
343
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
344
|
+
return String(value);
|
|
345
|
+
if (typeof value === 'boolean')
|
|
346
|
+
return value ? 'true' : 'false';
|
|
347
|
+
return dqlString(String(value));
|
|
348
|
+
}
|
|
349
|
+
export function parameterizeSqlForDqlImport(sql) {
|
|
350
|
+
let nextSql = sql;
|
|
351
|
+
const decisions = new Map();
|
|
352
|
+
const bindings = new Map();
|
|
353
|
+
const warnings = [];
|
|
354
|
+
const addDecision = (decision) => {
|
|
355
|
+
const name = normalizeIdentifier(decision.name);
|
|
356
|
+
if (!name || decisions.has(name))
|
|
357
|
+
return name;
|
|
358
|
+
decisions.set(name, { ...decision, name });
|
|
359
|
+
return name;
|
|
360
|
+
};
|
|
361
|
+
const addBinding = (filter, binding) => {
|
|
362
|
+
const normalized = normalizeIdentifier(filter);
|
|
363
|
+
if (!normalized || bindings.has(normalized))
|
|
364
|
+
return;
|
|
365
|
+
bindings.set(normalized, { filter: normalized, binding: binding.trim() });
|
|
366
|
+
};
|
|
367
|
+
const availableParamName = (preferred, fallback) => {
|
|
368
|
+
const normalized = normalizeIdentifier(preferred);
|
|
369
|
+
return normalized && !decisions.has(normalized) ? normalized : normalizeIdentifier(fallback);
|
|
370
|
+
};
|
|
371
|
+
const availableFilterName = (preferred, fallback) => {
|
|
372
|
+
const normalized = normalizeIdentifier(preferred);
|
|
373
|
+
return normalized && !bindings.has(normalized) ? normalized : normalizeIdentifier(fallback);
|
|
374
|
+
};
|
|
375
|
+
const placeholder = (name) => '${' + name + '}';
|
|
376
|
+
const yearLiterals = extractYearLiterals(nextSql);
|
|
377
|
+
const yearParamName = (year) => {
|
|
378
|
+
if (yearLiterals.length <= 1)
|
|
379
|
+
return 'season_year';
|
|
380
|
+
if (year === yearLiterals[0])
|
|
381
|
+
return 'season_start';
|
|
382
|
+
if (year === yearLiterals[yearLiterals.length - 1])
|
|
383
|
+
return 'season_end';
|
|
384
|
+
return `season_year_${year}`;
|
|
385
|
+
};
|
|
386
|
+
if (yearLiterals.length > 0) {
|
|
387
|
+
nextSql = nextSql.replace(/(extract\s*\(\s*year\s+from\s+([^)]+?)\s*\)\s*(?:>=|<=|<>|!=|=|>|<)\s*)([12][0-9]{3})/gi, (match, prefix, column, yearValue) => {
|
|
388
|
+
if (match.includes('${'))
|
|
389
|
+
return match;
|
|
390
|
+
const year = Number(yearValue);
|
|
391
|
+
const name = addDecision({
|
|
392
|
+
name: yearParamName(year),
|
|
393
|
+
policy: 'dynamic',
|
|
394
|
+
value: year,
|
|
395
|
+
valueType: 'year',
|
|
396
|
+
sourceExpression: `EXTRACT(YEAR FROM ${column.trim()})`,
|
|
397
|
+
reason: 'Year literal is user scope and should be reusable across seasons.',
|
|
398
|
+
confidence: 0.94,
|
|
399
|
+
});
|
|
400
|
+
addBinding(yearLiterals.length > 1 ? 'season_range' : 'season', `${column.trim()}.year`);
|
|
401
|
+
return `${prefix}${placeholder(name)}`;
|
|
402
|
+
});
|
|
403
|
+
nextSql = nextSql.replace(/(extract\s*\(\s*year\s+from\s+([^)]+?)\s*\)\s+in\s*\()([^)]+)(\))/gi, (match, prefix, column, list, suffix) => {
|
|
404
|
+
if (match.includes('${'))
|
|
405
|
+
return match;
|
|
406
|
+
const listYears = extractYearValuesFromList(list);
|
|
407
|
+
const normalizedColumn = column.trim();
|
|
408
|
+
if (listYears.length === 1) {
|
|
409
|
+
const name = addDecision({
|
|
410
|
+
name: 'season_year',
|
|
411
|
+
policy: 'dynamic',
|
|
412
|
+
value: listYears[0],
|
|
413
|
+
valueType: 'year',
|
|
414
|
+
sourceExpression: `EXTRACT(YEAR FROM ${normalizedColumn})`,
|
|
415
|
+
reason: 'Single-year list literal is user scope and should be reusable across seasons.',
|
|
416
|
+
confidence: 0.94,
|
|
417
|
+
});
|
|
418
|
+
addBinding('season', `${normalizedColumn}.year`);
|
|
419
|
+
return `EXTRACT(YEAR FROM ${normalizedColumn}) = ${placeholder(name)}`;
|
|
420
|
+
}
|
|
421
|
+
if (listYears.length > 1 && isContiguousYearRange(listYears)) {
|
|
422
|
+
const firstYear = listYears[0];
|
|
423
|
+
const lastYear = listYears[listYears.length - 1];
|
|
424
|
+
const startName = addDecision({
|
|
425
|
+
name: 'season_start',
|
|
426
|
+
policy: 'dynamic',
|
|
427
|
+
value: firstYear,
|
|
428
|
+
valueType: 'year',
|
|
429
|
+
sourceExpression: `EXTRACT(YEAR FROM ${normalizedColumn})`,
|
|
430
|
+
reason: 'Contiguous year list is treated as a reusable season range.',
|
|
431
|
+
confidence: 0.95,
|
|
432
|
+
});
|
|
433
|
+
const endName = addDecision({
|
|
434
|
+
name: 'season_end',
|
|
435
|
+
policy: 'dynamic',
|
|
436
|
+
value: lastYear,
|
|
437
|
+
valueType: 'year',
|
|
438
|
+
sourceExpression: `EXTRACT(YEAR FROM ${normalizedColumn})`,
|
|
439
|
+
reason: 'Contiguous year list is treated as a reusable season range.',
|
|
440
|
+
confidence: 0.95,
|
|
441
|
+
});
|
|
442
|
+
addBinding('season_range', `${normalizedColumn}.year`);
|
|
443
|
+
return `EXTRACT(YEAR FROM ${normalizedColumn}) BETWEEN ${placeholder(startName)} AND ${placeholder(endName)}`;
|
|
444
|
+
}
|
|
445
|
+
const replaced = list.replace(/\b([12][0-9]{3})\b/g, (_yearMatch, yearValue) => {
|
|
446
|
+
const year = Number(yearValue);
|
|
447
|
+
const name = addDecision({
|
|
448
|
+
name: `season_year_${year}`,
|
|
449
|
+
policy: 'dynamic',
|
|
450
|
+
value: year,
|
|
451
|
+
valueType: 'year',
|
|
452
|
+
sourceExpression: `EXTRACT(YEAR FROM ${normalizedColumn})`,
|
|
453
|
+
reason: 'Non-contiguous year list is user scope and should remain an explicit selected set.',
|
|
454
|
+
confidence: 0.94,
|
|
455
|
+
});
|
|
456
|
+
return placeholder(name);
|
|
457
|
+
});
|
|
458
|
+
addBinding('season_set', `${normalizedColumn}.year`);
|
|
459
|
+
return `${prefix}${replaced}${suffix}`;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
nextSql = nextSql.replace(/\blimit\s+([0-9]+)\b/gi, (match, value) => {
|
|
463
|
+
if (match.includes('${'))
|
|
464
|
+
return match;
|
|
465
|
+
const topN = Number(value);
|
|
466
|
+
if (!Number.isFinite(topN) || topN <= 0 || topN > 10000)
|
|
467
|
+
return match;
|
|
468
|
+
const name = addDecision({
|
|
469
|
+
name: 'top_n',
|
|
470
|
+
policy: 'dynamic',
|
|
471
|
+
value: topN,
|
|
472
|
+
valueType: 'number',
|
|
473
|
+
sourceExpression: 'LIMIT',
|
|
474
|
+
reason: 'Top/bottom row count is presentation scope and should be reusable.',
|
|
475
|
+
confidence: 0.9,
|
|
476
|
+
});
|
|
477
|
+
return match.replace(value, placeholder(name));
|
|
478
|
+
});
|
|
479
|
+
nextSql = nextSql.replace(/\b([A-Za-z_][A-Za-z0-9_.]*)\s+between\s+(?:date\s+)?'([12][0-9]{3}-[01][0-9]-[0-3][0-9])'\s+and\s+(?:date\s+)?'([12][0-9]{3}-[01][0-9]-[0-3][0-9])'/gi, (match, column, startValue, endValue) => {
|
|
480
|
+
if (match.includes('${') || !isDateScopeColumn(column))
|
|
481
|
+
return match;
|
|
482
|
+
const columnName = lastIdentifierPart(column);
|
|
483
|
+
const startName = addDecision({
|
|
484
|
+
name: availableParamName('start_date', `${columnName}_start_date`),
|
|
485
|
+
policy: 'dynamic',
|
|
486
|
+
value: startValue,
|
|
487
|
+
valueType: 'date',
|
|
488
|
+
sourceExpression: column,
|
|
489
|
+
reason: 'Date range literal is runtime scope and should be controlled by app filters.',
|
|
490
|
+
confidence: 0.9,
|
|
491
|
+
});
|
|
492
|
+
const endName = addDecision({
|
|
493
|
+
name: availableParamName('end_date', `${columnName}_end_date`),
|
|
494
|
+
policy: 'dynamic',
|
|
495
|
+
value: endValue,
|
|
496
|
+
valueType: 'date',
|
|
497
|
+
sourceExpression: column,
|
|
498
|
+
reason: 'Date range literal is runtime scope and should be controlled by app filters.',
|
|
499
|
+
confidence: 0.9,
|
|
500
|
+
});
|
|
501
|
+
addBinding(availableFilterName('date_range', `${columnName}_range`), column);
|
|
502
|
+
return `${column} BETWEEN ${placeholder(startName)} AND ${placeholder(endName)}`;
|
|
503
|
+
});
|
|
504
|
+
nextSql = nextSql.replace(/\b([A-Za-z_][A-Za-z0-9_.]*)\s*(>=|<=|=|>|<)\s*'([12][0-9]{3}-[01][0-9]-[0-3][0-9])'/gi, (match, column, operator, value) => {
|
|
505
|
+
if (match.includes('${') || !isDateScopeColumn(column))
|
|
506
|
+
return match;
|
|
507
|
+
const name = operator === '>=' || operator === '>' ? 'start_date' : operator === '<=' || operator === '<' ? 'end_date' : `${lastIdentifierPart(column)}_date`;
|
|
508
|
+
const paramName = addDecision({
|
|
509
|
+
name,
|
|
510
|
+
policy: 'dynamic',
|
|
511
|
+
value,
|
|
512
|
+
valueType: 'date',
|
|
513
|
+
sourceExpression: column,
|
|
514
|
+
reason: 'Date literal is runtime scope and should be controlled by app filters.',
|
|
515
|
+
confidence: 0.88,
|
|
516
|
+
});
|
|
517
|
+
addBinding(name === 'start_date' || name === 'end_date' ? 'date_range' : name, column);
|
|
518
|
+
return `${column} ${operator} ${placeholder(paramName)}`;
|
|
519
|
+
});
|
|
520
|
+
nextSql = nextSql.replace(/\b([A-Za-z_][A-Za-z0-9_.]*)\s+in\s*\(([^)]*)\)/gi, (match, column, rawList) => {
|
|
521
|
+
if (match.includes('${') || /\bextract\s*\(/i.test(match))
|
|
522
|
+
return match;
|
|
523
|
+
const columnName = lastIdentifierPart(column);
|
|
524
|
+
if (!isRuntimeScopeColumn(columnName))
|
|
525
|
+
return match;
|
|
526
|
+
const values = parseSqlLiteralList(rawList);
|
|
527
|
+
if (!values || values.length === 0)
|
|
528
|
+
return match;
|
|
529
|
+
const name = addDecision({
|
|
530
|
+
name: `${columnName}_set`,
|
|
531
|
+
policy: 'dynamic',
|
|
532
|
+
value: values,
|
|
533
|
+
valueType: 'set',
|
|
534
|
+
sourceExpression: column,
|
|
535
|
+
reason: 'Selected-set literal is user scope and should stay reusable for one or many values.',
|
|
536
|
+
confidence: 0.86,
|
|
537
|
+
});
|
|
538
|
+
addBinding(`${columnName}_set`, column);
|
|
539
|
+
return `${column} IN (${placeholder(name)})`;
|
|
540
|
+
});
|
|
541
|
+
nextSql = nextSql.replace(/\b([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*('(?:''|[^'])*'|[0-9]+(?:\.[0-9]+)?)/gi, (match, column, rawValue) => {
|
|
542
|
+
if (match.includes('${') || /extract\s*\(/i.test(match))
|
|
543
|
+
return match;
|
|
544
|
+
const columnName = lastIdentifierPart(column);
|
|
545
|
+
if (!isRuntimeScopeColumn(columnName)) {
|
|
546
|
+
const literal = parseSqlLiteral(rawValue);
|
|
547
|
+
if (isBusinessRuleLiteral(columnName, literal)) {
|
|
548
|
+
const name = addDecision({
|
|
549
|
+
name: `${columnName}_rule`,
|
|
550
|
+
policy: 'static',
|
|
551
|
+
value: literal,
|
|
552
|
+
valueType: typeof literal === 'number' ? 'number' : 'string',
|
|
553
|
+
sourceExpression: column,
|
|
554
|
+
reason: 'Literal appears to define a fixed business rule, not runtime scope.',
|
|
555
|
+
confidence: 0.72,
|
|
556
|
+
});
|
|
557
|
+
warnings.push(`Kept ${columnName} as static business logic (${name}).`);
|
|
558
|
+
}
|
|
559
|
+
return match;
|
|
560
|
+
}
|
|
561
|
+
const literal = parseSqlLiteral(rawValue);
|
|
562
|
+
const name = addDecision({
|
|
563
|
+
name: columnName,
|
|
564
|
+
policy: 'dynamic',
|
|
565
|
+
value: literal,
|
|
566
|
+
valueType: typeof literal === 'number' ? 'number' : 'string',
|
|
567
|
+
sourceExpression: column,
|
|
568
|
+
reason: 'Filter literal is user scope and should be reusable.',
|
|
569
|
+
confidence: 0.82,
|
|
570
|
+
});
|
|
571
|
+
addBinding(columnName, column);
|
|
572
|
+
return `${column} = ${placeholder(name)}`;
|
|
573
|
+
});
|
|
574
|
+
const parameterDecisions = Array.from(decisions.values());
|
|
575
|
+
const parameterPolicy = parameterDecisions.map((decision) => ({ name: decision.name, policy: decision.policy }));
|
|
576
|
+
const filterBindings = Array.from(bindings.values());
|
|
577
|
+
const allowedFilters = Array.from(new Set([
|
|
578
|
+
...filterBindings.map((binding) => binding.filter),
|
|
579
|
+
...parameterDecisions.filter((decision) => decision.policy === 'dynamic').map((decision) => decision.name),
|
|
580
|
+
])).slice(0, 16);
|
|
581
|
+
return { sql: nextSql, parameterDecisions, parameterPolicy, filterBindings, allowedFilters, warnings };
|
|
582
|
+
}
|
|
583
|
+
function extractYearLiterals(sql) {
|
|
584
|
+
const years = new Set();
|
|
585
|
+
const cleaned = stripSqlComments(sql);
|
|
586
|
+
const extractRegex = /extract\s*\(\s*year\s+from\s+[^)]+\)\s*(?:(?:>=|<=|<>|!=|=|>|<)\s*([12][0-9]{3})|\s+in\s*\(([^)]*)\))/gi;
|
|
587
|
+
let match;
|
|
588
|
+
while ((match = extractRegex.exec(cleaned))) {
|
|
589
|
+
for (const value of [match[1], ...(match[2]?.match(/[12][0-9]{3}/g) ?? [])]) {
|
|
590
|
+
if (value)
|
|
591
|
+
years.add(Number(value));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return Array.from(years).sort((a, b) => a - b);
|
|
595
|
+
}
|
|
596
|
+
function extractYearValuesFromList(list) {
|
|
597
|
+
const years = new Set();
|
|
598
|
+
for (const value of list.match(/\b[12][0-9]{3}\b/g) ?? []) {
|
|
599
|
+
years.add(Number(value));
|
|
600
|
+
}
|
|
601
|
+
return Array.from(years).sort((a, b) => a - b);
|
|
602
|
+
}
|
|
603
|
+
function isContiguousYearRange(years) {
|
|
604
|
+
if (years.length < 2)
|
|
605
|
+
return false;
|
|
606
|
+
for (let index = 1; index < years.length; index += 1) {
|
|
607
|
+
if (years[index] !== years[index - 1] + 1)
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
function lastIdentifierPart(column) {
|
|
613
|
+
return normalizeIdentifier(column.split('.').pop() ?? column).toLowerCase();
|
|
614
|
+
}
|
|
615
|
+
function stripSqlIdentifierQuotes(value) {
|
|
616
|
+
return value.replace(/["\[\]]/g, '').replaceAll('`', '');
|
|
617
|
+
}
|
|
618
|
+
function parseSqlLiteral(raw) {
|
|
619
|
+
if (/^'/.test(raw))
|
|
620
|
+
return raw.slice(1, -1).replace(/''/g, "'");
|
|
621
|
+
const numeric = Number(raw);
|
|
622
|
+
return Number.isFinite(numeric) ? numeric : raw;
|
|
623
|
+
}
|
|
624
|
+
function parseSqlLiteralList(rawList) {
|
|
625
|
+
const parts = splitSqlList(rawList).map((part) => part.trim()).filter(Boolean);
|
|
626
|
+
if (parts.length === 0)
|
|
627
|
+
return null;
|
|
628
|
+
const values = [];
|
|
629
|
+
for (const part of parts) {
|
|
630
|
+
if (!/^(?:'(?:''|[^'])*'|[0-9]+(?:\.[0-9]+)?)$/i.test(part))
|
|
631
|
+
return null;
|
|
632
|
+
values.push(parseSqlLiteral(part));
|
|
633
|
+
}
|
|
634
|
+
return values;
|
|
635
|
+
}
|
|
636
|
+
function isDateScopeColumn(column) {
|
|
637
|
+
return /(date|day|time|period|month|week)/i.test(column);
|
|
638
|
+
}
|
|
639
|
+
function isRuntimeScopeColumn(column) {
|
|
640
|
+
const normalized = normalizeIdentifier(column).toLowerCase();
|
|
641
|
+
return /(?:^|_)(player|team|customer|account|user|segment|region|product|position|market|channel|country|state|city|league|season|year)(?:_|$)/i.test(normalized)
|
|
642
|
+
|| /(?:^|_)(id|key|name|code)$/i.test(normalized);
|
|
643
|
+
}
|
|
644
|
+
function isBusinessRuleLiteral(column, value) {
|
|
645
|
+
const text = String(value).toLowerCase();
|
|
646
|
+
return /(status|state|type|flag|is_|mode|category)/i.test(column)
|
|
647
|
+
&& /^(active|approved|complete|completed|valid|regular|true|1|yes|current)$/i.test(text);
|
|
648
|
+
}
|
|
649
|
+
function inferSqlGrain(sql) {
|
|
650
|
+
const groupFields = extractGroupByFields(sql);
|
|
651
|
+
return groupFields[0] ?? '';
|
|
652
|
+
}
|
|
653
|
+
function inferSqlPattern(sql) {
|
|
654
|
+
const lower = stripSqlComments(sql).toLowerCase();
|
|
655
|
+
const groupFields = extractGroupByFields(sql);
|
|
656
|
+
if (/@metric\s*\(/i.test(sql))
|
|
657
|
+
return 'metric_wrapper';
|
|
658
|
+
if (/\bjoin\b/i.test(sql)) {
|
|
659
|
+
const systems = new Set(inferSqlSourceSystems(sql));
|
|
660
|
+
if (systems.size > 1)
|
|
661
|
+
return 'bridge';
|
|
662
|
+
}
|
|
663
|
+
if (hasRankingLimit(sql))
|
|
664
|
+
return 'ranking';
|
|
665
|
+
if (groupFields.some((field) => /\b(date|day|week|month|quarter|year|period|time)\b/i.test(field)))
|
|
666
|
+
return 'trend';
|
|
667
|
+
if (groupFields.length === 1 && /_id$|_key$/i.test(groupFields[0]))
|
|
668
|
+
return 'entity_rollup';
|
|
669
|
+
if (!/\b(sum|count|avg|min|max|median|percentile|rank)\s*\(/i.test(lower) && /\b(dim|profile|customer|account|player|product|user|entity)\b/i.test(lower)) {
|
|
670
|
+
return 'entity_profile';
|
|
671
|
+
}
|
|
672
|
+
return 'custom';
|
|
673
|
+
}
|
|
674
|
+
function hasRankingLimit(sql) {
|
|
675
|
+
return /\border\s+by\b[\s\S]*\blimit\s+(?:\d+|\$\{\s*[A-Za-z_][A-Za-z0-9_]*\s*\}|[:?][A-Za-z_][A-Za-z0-9_]*)/i.test(sql);
|
|
676
|
+
}
|
|
677
|
+
function inferSqlOutputs(sql) {
|
|
678
|
+
const selectMatch = sql.match(/\bselect\b([\s\S]+?)\bfrom\b/i);
|
|
679
|
+
if (!selectMatch)
|
|
680
|
+
return [];
|
|
681
|
+
return splitSqlList(selectMatch[1])
|
|
682
|
+
.map((expr) => {
|
|
683
|
+
const alias = expr.match(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)\b/i)?.[1]
|
|
684
|
+
?? expr.match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/)?.[1]
|
|
685
|
+
?? '';
|
|
686
|
+
return stripSqlIdentifierQuotes(alias);
|
|
687
|
+
})
|
|
688
|
+
.filter(Boolean)
|
|
689
|
+
.filter((name) => !/^(from|where|group|order|limit)$/i.test(name));
|
|
690
|
+
}
|
|
691
|
+
function inferSqlFilters(sql) {
|
|
692
|
+
const filters = new Set();
|
|
693
|
+
const where = sql.match(/\bwhere\b([\s\S]+?)(?:\bgroup\s+by\b|\border\s+by\b|\blimit\b|$)/i)?.[1] ?? '';
|
|
694
|
+
const addFilter = (value) => {
|
|
695
|
+
const name = stripSqlIdentifierQuotes(value?.split('.').pop() ?? '');
|
|
696
|
+
if (name && !/^(and|or|not|null|year|month|day)$/i.test(name))
|
|
697
|
+
filters.add(name);
|
|
698
|
+
};
|
|
699
|
+
const extractRegex = /\bextract\s*\([^)]*\bfrom\s+([A-Za-z_][A-Za-z0-9_.]*)\s*\)\s*(?:=|<>|!=|>|<|>=|<=|\bin\b|\blike\b)/gi;
|
|
700
|
+
const isNullRegex = /\b([A-Za-z_][A-Za-z0-9_.]*)\s+is\s+(?:not\s+)?null\b/gi;
|
|
701
|
+
const regex = /\b([A-Za-z_][A-Za-z0-9_.]*)\s*(?:=|<>|!=|>|<|>=|<=|\bin\b|\blike\b)/gi;
|
|
702
|
+
let match;
|
|
703
|
+
while ((match = extractRegex.exec(where)))
|
|
704
|
+
addFilter(match[1]);
|
|
705
|
+
while ((match = isNullRegex.exec(where)))
|
|
706
|
+
addFilter(match[1]);
|
|
707
|
+
while ((match = regex.exec(where))) {
|
|
708
|
+
addFilter(match[1]);
|
|
709
|
+
}
|
|
710
|
+
return [...filters];
|
|
711
|
+
}
|
|
712
|
+
function inferSqlSourceSystems(sql) {
|
|
713
|
+
return extractSourceTables(sql)
|
|
714
|
+
.map((table) => table.split('.').filter(Boolean).slice(-2, -1)[0] ?? table.split('.').filter(Boolean)[0])
|
|
715
|
+
.filter(Boolean)
|
|
716
|
+
.map((value) => businessToken(value))
|
|
717
|
+
.filter(Boolean);
|
|
718
|
+
}
|
|
719
|
+
function inferSqlEntities(grain, sourceSystems) {
|
|
720
|
+
const entity = grain && /_id$|_key$/i.test(grain) ? businessEntityFromIdentifier(grain) : '';
|
|
721
|
+
return Array.from(new Set([entity, ...sourceSystems.map(businessEntityFromIdentifier)].filter(Boolean)));
|
|
722
|
+
}
|
|
723
|
+
function inferSqlDimensions(sql, grain, outputs) {
|
|
724
|
+
const outputSet = new Set(outputs.map((output) => output.toLowerCase()));
|
|
725
|
+
return extractGroupByFields(sql)
|
|
726
|
+
.filter((field) => field !== grain)
|
|
727
|
+
.filter((field) => !outputSet.has(field.toLowerCase()))
|
|
728
|
+
.filter((field) => !/\b(total|count|sum|avg|average|min|max|rate|pct|percent|amount|revenue|points?|score)\b/i.test(field))
|
|
729
|
+
.slice(0, 16);
|
|
730
|
+
}
|
|
731
|
+
function extractGroupByFields(sql) {
|
|
732
|
+
const match = sql.match(/\bgroup\s+by\b([\s\S]+?)(?:\border\s+by\b|\blimit\b|\bqualify\b|\bhaving\b|$)/i);
|
|
733
|
+
if (!match)
|
|
734
|
+
return [];
|
|
735
|
+
return splitSqlList(match[1])
|
|
736
|
+
.map((item) => stripSqlIdentifierQuotes(item).trim())
|
|
737
|
+
.filter((item) => item && !/^\d+$/.test(item))
|
|
738
|
+
.map((item) => item.split('.').pop() ?? item)
|
|
739
|
+
.slice(0, 4);
|
|
740
|
+
}
|
|
741
|
+
function splitSqlList(value) {
|
|
742
|
+
const parts = [];
|
|
743
|
+
let start = 0;
|
|
744
|
+
let depth = 0;
|
|
745
|
+
let single = false;
|
|
746
|
+
let double = false;
|
|
747
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
748
|
+
const char = value[i];
|
|
749
|
+
if (!double && char === "'" && value[i - 1] !== '\\')
|
|
750
|
+
single = !single;
|
|
751
|
+
else if (!single && char === '"' && value[i - 1] !== '\\')
|
|
752
|
+
double = !double;
|
|
753
|
+
else if (!single && !double && char === '(')
|
|
754
|
+
depth += 1;
|
|
755
|
+
else if (!single && !double && char === ')' && depth > 0)
|
|
756
|
+
depth -= 1;
|
|
757
|
+
else if (!single && !double && depth === 0 && char === ',') {
|
|
758
|
+
parts.push(value.slice(start, i));
|
|
759
|
+
start = i + 1;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
parts.push(value.slice(start));
|
|
763
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
187
764
|
}
|
|
188
765
|
function collectSqlStatements(sources) {
|
|
189
766
|
const statements = [];
|
|
@@ -210,11 +787,12 @@ function buildSqlCandidate(options) {
|
|
|
210
787
|
const statement = options.statement;
|
|
211
788
|
const sourcePath = displayPath(options.projectRoot, statement.sourcePath);
|
|
212
789
|
const metadata = extractStatementMetadata(statement.sql);
|
|
213
|
-
const
|
|
790
|
+
const sourceTables = extractSourceTables(statement.sql);
|
|
791
|
+
const inferred = metadata.name ? null : inferStatementBusinessMetadata(statement.sql, sourcePath, sourceTables);
|
|
792
|
+
const baseName = metadata.name || inferred?.name || basename(statement.sourcePath, extname(statement.sourcePath));
|
|
214
793
|
const name = statement.totalStatements > 1 && !metadata.name
|
|
215
794
|
? `${baseName} ${statement.statementIndex}`
|
|
216
795
|
: baseName;
|
|
217
|
-
const sourceTables = extractSourceTables(statement.sql);
|
|
218
796
|
const parameters = extractSqlParameters(statement.sql);
|
|
219
797
|
const warnings = [
|
|
220
798
|
...(parameters.length > 0 ? [`Contains parameters: ${parameters.join(', ')}`] : []),
|
|
@@ -234,9 +812,9 @@ function buildSqlCandidate(options) {
|
|
|
234
812
|
sourcePath,
|
|
235
813
|
name: titleizeName(name),
|
|
236
814
|
domain: metadata.domain ? sanitizeDomain(metadata.domain) : options.defaults.domain,
|
|
237
|
-
description: metadata.description || `Imported from ${sourcePath}`,
|
|
815
|
+
description: metadata.description || inferred?.description || `Imported from ${sourcePath}`,
|
|
238
816
|
owner: options.defaults.owner,
|
|
239
|
-
tags: normalizeTags([...options.defaults.tags, ...metadata.tags]),
|
|
817
|
+
tags: normalizeTags([...options.defaults.tags, ...metadata.tags, ...(inferred?.tags ?? [])]),
|
|
240
818
|
sql: statement.sql.trim(),
|
|
241
819
|
dqlSource: '',
|
|
242
820
|
validation: null,
|
|
@@ -255,6 +833,7 @@ function buildSqlCandidate(options) {
|
|
|
255
833
|
'Optional AI assist is review-gated and only receives this candidate context.',
|
|
256
834
|
],
|
|
257
835
|
aiAssistance: [],
|
|
836
|
+
reviewCadence: 'monthly',
|
|
258
837
|
reviewStatus: 'draft',
|
|
259
838
|
};
|
|
260
839
|
candidate.dqlSource = candidateToDqlSource(candidate);
|
|
@@ -407,9 +986,154 @@ function extractStatementMetadata(sql) {
|
|
|
407
986
|
const firstComment = leading.match(/^\s*--\s*(?!name\s*:|block\s*:)(.+)$/im)?.[1]?.trim() ?? '';
|
|
408
987
|
return { name, description: firstComment, domain, tags };
|
|
409
988
|
}
|
|
989
|
+
function inferStatementBusinessMetadata(sql, sourcePath, sourceTables) {
|
|
990
|
+
const expressions = extractSelectExpressions(sql);
|
|
991
|
+
const aggregateAliases = expressions.filter(isAggregateExpression).map(extractExpressionAlias).filter(Boolean);
|
|
992
|
+
const dimensionAliases = expressions.filter((expression) => !isAggregateExpression(expression)).map(extractExpressionAlias).filter(Boolean);
|
|
993
|
+
const orderByAliases = extractOrderByAliases(sql);
|
|
994
|
+
const metric = aggregateAliases[0] || orderByAliases.find((alias) => /total|sum|count|avg|score|point|revenue|amount|sales|games?/i.test(alias)) || aggregateAliases[0] || '';
|
|
995
|
+
const dimension = dimensionAliases.find((alias) => !/^row_?number$/i.test(alias)) || '';
|
|
996
|
+
const years = extractYearFilters(sql);
|
|
997
|
+
const tableLabel = sourceTables[0] || sourcePath;
|
|
998
|
+
const tableEntity = businessEntityFromIdentifier(tableLabel.split('.').pop() || tableLabel);
|
|
999
|
+
const metricLabel = metric ? titleizeName(metric) : '';
|
|
1000
|
+
const pluralDimension = dimension ? pluralizeBusinessEntity(businessEntityFromIdentifier(dimension)) : pluralizeBusinessEntity(tableEntity);
|
|
1001
|
+
const yearLabel = years.length === 1 ? years[0] : years.length > 1 ? years.join(' ') : '';
|
|
1002
|
+
const yearClause = years.length === 1 ? ` for ${years[0]}` : years.length > 1 ? ` for ${years.join(' and ')}` : '';
|
|
1003
|
+
let name = '';
|
|
1004
|
+
if (metricLabel && dimension) {
|
|
1005
|
+
name = `Top ${pluralDimension} By ${metricLabel}${yearLabel ? ` ${yearLabel}` : ''}`;
|
|
1006
|
+
}
|
|
1007
|
+
else if (metricLabel) {
|
|
1008
|
+
name = `${metricLabel}${yearLabel ? ` ${yearLabel}` : ''}`;
|
|
1009
|
+
}
|
|
1010
|
+
else if (dimension) {
|
|
1011
|
+
name = `${pluralDimension} Detail${yearLabel ? ` ${yearLabel}` : ''}`;
|
|
1012
|
+
}
|
|
1013
|
+
const selectedMeasures = aggregateAliases.map(titleizeName).filter((label) => label && label !== metricLabel);
|
|
1014
|
+
let description = '';
|
|
1015
|
+
if (metricLabel && dimension) {
|
|
1016
|
+
description = `Ranks ${pluralDimension.toLowerCase()} by ${metricLabel.toLowerCase()}${yearClause} using ${sourceTables.join(', ') || sourcePath}.`;
|
|
1017
|
+
}
|
|
1018
|
+
else if (metricLabel) {
|
|
1019
|
+
description = `Calculates ${metricLabel.toLowerCase()}${yearClause} using ${sourceTables.join(', ') || sourcePath}.`;
|
|
1020
|
+
}
|
|
1021
|
+
else if (dimension) {
|
|
1022
|
+
description = `Lists ${pluralDimension.toLowerCase()}${yearClause} from ${sourceTables.join(', ') || sourcePath}.`;
|
|
1023
|
+
}
|
|
1024
|
+
if (description && selectedMeasures.length > 0) {
|
|
1025
|
+
description = `${description.replace(/\.$/, '')}, including ${selectedMeasures.map((item) => item.toLowerCase()).join(', ')}.`;
|
|
1026
|
+
}
|
|
1027
|
+
const tags = [
|
|
1028
|
+
...sourceTables.flatMap((table) => table.split('.').slice(-2).map(businessToken)),
|
|
1029
|
+
metric,
|
|
1030
|
+
dimension,
|
|
1031
|
+
...years,
|
|
1032
|
+
].flatMap((item) => item.split(/[_\s.-]+/)).map(businessToken).filter(Boolean);
|
|
1033
|
+
return {
|
|
1034
|
+
name: name || '',
|
|
1035
|
+
description,
|
|
1036
|
+
tags,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
function extractSelectExpressions(sql) {
|
|
1040
|
+
const cleaned = stripSqlComments(sql);
|
|
1041
|
+
const match = cleaned.match(/\bselect\b([\s\S]+?)\bfrom\b/i);
|
|
1042
|
+
if (!match)
|
|
1043
|
+
return [];
|
|
1044
|
+
return splitTopLevelCommas(match[1])
|
|
1045
|
+
.map((expression) => expression.trim())
|
|
1046
|
+
.filter((expression) => expression && expression !== '*');
|
|
1047
|
+
}
|
|
1048
|
+
function splitTopLevelCommas(value) {
|
|
1049
|
+
const parts = [];
|
|
1050
|
+
let start = 0;
|
|
1051
|
+
let depth = 0;
|
|
1052
|
+
let single = false;
|
|
1053
|
+
let double = false;
|
|
1054
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
1055
|
+
const char = value[i];
|
|
1056
|
+
if (!double && char === "'" && value[i - 1] !== '\\')
|
|
1057
|
+
single = !single;
|
|
1058
|
+
else if (!single && char === '"' && value[i - 1] !== '\\')
|
|
1059
|
+
double = !double;
|
|
1060
|
+
else if (!single && !double && char === '(')
|
|
1061
|
+
depth += 1;
|
|
1062
|
+
else if (!single && !double && char === ')' && depth > 0)
|
|
1063
|
+
depth -= 1;
|
|
1064
|
+
else if (!single && !double && depth === 0 && char === ',') {
|
|
1065
|
+
parts.push(value.slice(start, i));
|
|
1066
|
+
start = i + 1;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
parts.push(value.slice(start));
|
|
1070
|
+
return parts;
|
|
1071
|
+
}
|
|
1072
|
+
function isAggregateExpression(expression) {
|
|
1073
|
+
return /\b(sum|count|avg|min|max|median|percentile_cont|percentile_disc)\s*\(/i.test(expression);
|
|
1074
|
+
}
|
|
1075
|
+
function extractExpressionAlias(expression) {
|
|
1076
|
+
const asMatch = expression.match(/\bas\s+[`"[]?([A-Za-z_][A-Za-z0-9_$]*)[`"\]]?\s*$/i);
|
|
1077
|
+
if (asMatch)
|
|
1078
|
+
return asMatch[1];
|
|
1079
|
+
const trailing = expression.match(/\s+[`"[]?([A-Za-z_][A-Za-z0-9_$]*)[`"\]]?\s*$/);
|
|
1080
|
+
if (trailing && !/[).]$/.test(trailing[1]))
|
|
1081
|
+
return trailing[1];
|
|
1082
|
+
const simple = expression.match(/(?:^|\.)[`"[]?([A-Za-z_][A-Za-z0-9_$]*)[`"\]]?\s*$/);
|
|
1083
|
+
return simple?.[1] ?? '';
|
|
1084
|
+
}
|
|
1085
|
+
function extractOrderByAliases(sql) {
|
|
1086
|
+
const cleaned = stripSqlComments(sql);
|
|
1087
|
+
const match = cleaned.match(/\border\s+by\b([\s\S]+?)(?:\blimit\b|\bfetch\b|\boffset\b|$)/i);
|
|
1088
|
+
if (!match)
|
|
1089
|
+
return [];
|
|
1090
|
+
return splitTopLevelCommas(match[1])
|
|
1091
|
+
.map((item) => item.replace(/\b(asc|desc|nulls\s+first|nulls\s+last)\b/gi, '').trim())
|
|
1092
|
+
.map(extractExpressionAlias)
|
|
1093
|
+
.filter(Boolean);
|
|
1094
|
+
}
|
|
1095
|
+
function extractYearFilters(sql) {
|
|
1096
|
+
const years = new Set();
|
|
1097
|
+
const cleaned = stripSqlComments(sql);
|
|
1098
|
+
let match;
|
|
1099
|
+
const extractYear = /extract\s*\(\s*year\s+from\s+[^)]+\)\s*(?:=\s*([12][0-9]{3})|in\s*\(([^)]*)\))/gi;
|
|
1100
|
+
while ((match = extractYear.exec(cleaned))) {
|
|
1101
|
+
const raw = match[1] || match[2] || '';
|
|
1102
|
+
for (const year of raw.match(/[12][0-9]{3}/g) ?? [])
|
|
1103
|
+
years.add(year);
|
|
1104
|
+
}
|
|
1105
|
+
const namedYear = /\b(?:year|season)\b\s*(?:=|in\s*\()\s*([^)\s]+(?:\s*,\s*[^)\s]+)*)/gi;
|
|
1106
|
+
while ((match = namedYear.exec(cleaned))) {
|
|
1107
|
+
for (const year of match[1].match(/[12][0-9]{3}/g) ?? [])
|
|
1108
|
+
years.add(year);
|
|
1109
|
+
}
|
|
1110
|
+
return Array.from(years).sort();
|
|
1111
|
+
}
|
|
1112
|
+
function businessEntityFromIdentifier(identifier) {
|
|
1113
|
+
const clean = identifier
|
|
1114
|
+
.replace(/^(dim|fact|fct|stg|src|int)_/i, '')
|
|
1115
|
+
.replace(/_(id|key|name|code)$/i, '')
|
|
1116
|
+
.replace(/s$/i, '');
|
|
1117
|
+
return titleizeName(clean) || 'Records';
|
|
1118
|
+
}
|
|
1119
|
+
function pluralizeBusinessEntity(entity) {
|
|
1120
|
+
if (/s$/i.test(entity))
|
|
1121
|
+
return entity;
|
|
1122
|
+
if (/y$/i.test(entity))
|
|
1123
|
+
return `${entity.slice(0, -1)}ies`;
|
|
1124
|
+
return `${entity}s`;
|
|
1125
|
+
}
|
|
1126
|
+
function businessToken(value) {
|
|
1127
|
+
return value
|
|
1128
|
+
.trim()
|
|
1129
|
+
.toLowerCase()
|
|
1130
|
+
.replace(/^(dim|fact|fct|stg|src|int)_/, '')
|
|
1131
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
1132
|
+
.replace(/^-+|-+$/g, '');
|
|
1133
|
+
}
|
|
410
1134
|
function extractSourceTables(sql) {
|
|
411
1135
|
const tables = new Set();
|
|
412
|
-
const cleaned = stripSqlComments(sql);
|
|
1136
|
+
const cleaned = stripSqlComments(sql).replace(/extract\s*\(\s*\w+\s+from\s+[^)]+\)/gi, 'EXTRACT_VALUE');
|
|
413
1137
|
const cteNames = extractCteNames(cleaned);
|
|
414
1138
|
const regex = /\b(?:from|join|update|into)\s+([`"[]?[A-Za-z0-9_./:-]+(?:\.[A-Za-z0-9_./:-]+)*[`"\]]?)/gi;
|
|
415
1139
|
let match;
|
|
@@ -447,6 +1171,9 @@ function extractSqlParameters(sql) {
|
|
|
447
1171
|
const handlebars = /\{\{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*\}\}/g;
|
|
448
1172
|
while ((match = handlebars.exec(cleaned)))
|
|
449
1173
|
params.add(match[1]);
|
|
1174
|
+
const dqlTemplate = /\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g;
|
|
1175
|
+
while ((match = dqlTemplate.exec(cleaned)))
|
|
1176
|
+
params.add(match[1]);
|
|
450
1177
|
const colon = /(^|[^:]):([A-Za-z_][A-Za-z0-9_]*)\b/g;
|
|
451
1178
|
while ((match = colon.exec(cleaned)))
|
|
452
1179
|
params.add(match[2]);
|
|
@@ -575,6 +1302,24 @@ function sanitizeDomain(domain) {
|
|
|
575
1302
|
function normalizeTags(tags) {
|
|
576
1303
|
return Array.from(new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)));
|
|
577
1304
|
}
|
|
1305
|
+
function normalizePattern(value) {
|
|
1306
|
+
const normalized = (value ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
1307
|
+
const allowed = new Set([
|
|
1308
|
+
'metric_wrapper',
|
|
1309
|
+
'entity_profile',
|
|
1310
|
+
'entity_rollup',
|
|
1311
|
+
'ranking',
|
|
1312
|
+
'trend',
|
|
1313
|
+
'bridge',
|
|
1314
|
+
'drilldown',
|
|
1315
|
+
'replacement',
|
|
1316
|
+
'custom',
|
|
1317
|
+
]);
|
|
1318
|
+
return allowed.has(normalized) ? normalized : '';
|
|
1319
|
+
}
|
|
1320
|
+
function normalizeStringList(values) {
|
|
1321
|
+
return Array.from(new Set((values ?? []).map((value) => value.trim()).filter(Boolean)));
|
|
1322
|
+
}
|
|
578
1323
|
function titleizeName(name) {
|
|
579
1324
|
return name
|
|
580
1325
|
.replace(/\.[^.]+$/, '')
|