@google-cloud/kcmd 0.1.0 → 0.2.1
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 +18 -20
- package/build/ts/kcmd/cli.js +6164 -5631
- package/build/ts/kcmd/gcp/biglake.d.ts +2 -0
- package/build/ts/kcmd/gcp/biglake.js +13 -4
- package/build/ts/kcmd/gcp/spanner.d.ts +10 -0
- package/build/ts/kcmd/gcp/spanner.js +6 -3
- package/build/ts/kcmd/layouts/okf.js +49 -7
- package/build/ts/kcmd/layouts/semantic-model.d.ts +13 -0
- package/build/ts/kcmd/layouts/semantic-model.js +129 -50
- package/build/ts/kcmd/okf/concept.d.ts +1 -1
- package/build/ts/kcmd/okf/concept.js +6 -0
- package/build/ts/kcmd/okf/concept_to_entry.d.ts +2 -0
- package/build/ts/kcmd/okf/concept_to_entry.js +29 -2
- package/build/ts/kcmd/okf/init_okf_workspace.js +7 -9
- package/build/ts/kcmd/okf/okf_metadata.js +17 -0
- package/build/ts/kcmd/semantic/bigquery.js +139 -195
- package/build/ts/kcmd/semantic/binding.js +14 -19
- package/build/ts/kcmd/semantic/deploy_bigquery.js +6 -13
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.d.ts +10 -35
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.js +105 -162
- package/build/ts/kcmd/semantic/deploy_spanner.js +7 -14
- package/build/ts/kcmd/semantic/deployment_target.d.ts +1 -4
- package/build/ts/kcmd/semantic/deployment_target.js +6 -62
- package/build/ts/kcmd/semantic/graph_ddl.d.ts +10 -0
- package/build/ts/kcmd/semantic/graph_ddl.js +152 -59
- package/build/ts/kcmd/semantic/import_model.d.ts +26 -4
- package/build/ts/kcmd/semantic/import_model.js +531 -111
- package/build/ts/kcmd/semantic/ir.d.ts +2 -4
- package/build/ts/kcmd/semantic/kc_converter.d.ts +7 -1
- package/build/ts/kcmd/semantic/kc_converter.js +32 -27
- package/build/ts/kcmd/semantic/kc_custom_types.d.ts +5 -12
- package/build/ts/kcmd/semantic/kc_custom_types.js +43 -114
- package/build/ts/kcmd/semantic/kc_ids.d.ts +4 -5
- package/build/ts/kcmd/semantic/kc_ids.js +4 -7
- package/build/ts/kcmd/semantic/knowledge_catalog.d.ts +0 -3
- package/build/ts/kcmd/semantic/knowledge_catalog.js +3 -86
- package/build/ts/kcmd/semantic/loader.d.ts +7 -11
- package/build/ts/kcmd/semantic/loader.js +282 -171
- package/build/ts/kcmd/semantic/osi_converter.js +14 -65
- package/build/ts/kcmd/semantic/parameters.d.ts +1 -0
- package/build/ts/kcmd/semantic/parameters.js +22 -3
- package/build/ts/kcmd/semantic/pull_kc.d.ts +1 -5
- package/build/ts/kcmd/semantic/pull_kc.js +38 -112
- package/build/ts/kcmd/semantic/resolve_inheritance.d.ts +7 -2
- package/build/ts/kcmd/semantic/resolve_inheritance.js +37 -21
- package/build/ts/kcmd/semantic/resolve_profiles.d.ts +51 -10
- package/build/ts/kcmd/semantic/resolve_profiles.js +213 -32
- package/build/ts/kcmd/semantic/runtime/runtime.d.ts +4 -12
- package/build/ts/kcmd/semantic/runtime/runtime.js +4 -21
- package/build/ts/kcmd/semantic/runtime/store.d.ts +14 -19
- package/build/ts/kcmd/semantic/runtime/store.js +21 -37
- package/build/ts/kcmd/semantic/source_dialect.d.ts +9 -2
- package/build/ts/kcmd/semantic/source_dialect.js +83 -33
- package/build/ts/kcmd/semantic/source_entries.d.ts +1 -1
- package/build/ts/kcmd/semantic/source_entries.js +95 -36
- package/build/ts/kcmd/semantic/sql_expr_utils.d.ts +13 -7
- package/build/ts/kcmd/semantic/sql_expr_utils.js +240 -67
- package/build/ts/kcmd/semantic/sql_parser.d.ts +5 -2
- package/build/ts/kcmd/semantic/sql_parser.js +22 -0
- package/build/ts/kcmd/semantic/transpile.d.ts +12 -10
- package/build/ts/kcmd/semantic/transpile.js +79 -61
- package/build/ts/kcmd/semantic/validate.d.ts +37 -2
- package/build/ts/kcmd/semantic/validate.js +467 -126
- package/build/ts/kcmd/snapshot.d.ts +4 -0
- package/build/ts/kcmd/snapshot.js +7 -0
- package/build/ts/kcmd/sync.js +44 -7
- package/package.json +1 -1
|
@@ -37,14 +37,19 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
37
37
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
38
|
exports.importSemanticModelYaml = importSemanticModelYaml;
|
|
39
39
|
const yaml = __importStar(require("yaml"));
|
|
40
|
+
const semantic_model_1 = require("../layouts/semantic-model");
|
|
41
|
+
const deploy_knowledge_catalog_1 = require("./deploy_knowledge_catalog");
|
|
40
42
|
const deployment_target_1 = require("./deployment_target");
|
|
41
43
|
const ir_1 = require("./ir");
|
|
42
44
|
const loader_1 = require("./loader");
|
|
43
45
|
const names_1 = require("./names");
|
|
46
|
+
const parameters_1 = require("./parameters");
|
|
44
47
|
const resolve_profiles_1 = require("./resolve_profiles");
|
|
48
|
+
const source_dialect_1 = require("./source_dialect");
|
|
45
49
|
const sql_expr_utils_1 = require("./sql_expr_utils");
|
|
46
50
|
const sql_parser_1 = require("./sql_parser");
|
|
47
51
|
const transpile_1 = require("./transpile");
|
|
52
|
+
const validate_1 = require("./validate");
|
|
48
53
|
const yaml_options_1 = require("./yaml_options");
|
|
49
54
|
const yaml_update_1 = require("./yaml_update");
|
|
50
55
|
const STANDARD_AI_CONTEXT_KEYS = new Set(['instructions', 'synonyms', 'examples']);
|
|
@@ -53,10 +58,14 @@ const LOADER_SOURCE_WARNING = /^(.*?): source '(.*)' is not a valid GCP resource
|
|
|
53
58
|
// Why no name to write is given when the name holds a quote.
|
|
54
59
|
const NO_QUOTE = 'kcmd cannot name a table whose name holds a quote';
|
|
55
60
|
// Each kind of --source-prefix for the service `prefix` names, or every kind
|
|
56
|
-
// when there is no prefix.
|
|
61
|
+
// when there is no prefix. The prefix's own kind comes first, so advice names
|
|
62
|
+
// its form.
|
|
57
63
|
function prefixKinds(prefix) {
|
|
58
64
|
const host = prefix?.base.split('/')[0];
|
|
59
|
-
|
|
65
|
+
const own = prefix &&
|
|
66
|
+
SOURCE_PREFIX_PATTERNS.find(k => k.re.test(prefix.base))?.form;
|
|
67
|
+
return SOURCE_PREFIX_PATTERNS.filter(k => host === undefined || k.form.startsWith(`${host}/`))
|
|
68
|
+
.sort((a, b) => Number(b.form === own) - Number(a.form === own));
|
|
60
69
|
}
|
|
61
70
|
// The warning for `source`, which import leaves as written after a run with
|
|
62
71
|
// `prefix`, if any. It gives the name to write when the loader names one,
|
|
@@ -96,6 +105,19 @@ function sourceWarning(ctx, source, prefix) {
|
|
|
96
105
|
'.') +
|
|
97
106
|
((0, loader_1.isGoogleCloudForm)(source) ? '' : ` ${loader_1.THIRD_PARTY_SOURCE}`);
|
|
98
107
|
}
|
|
108
|
+
// Push's error for `source`, read as the loader reads a source in `ctx`, or
|
|
109
|
+
// undefined when push accepts it.
|
|
110
|
+
function sourceError(source, ctx) {
|
|
111
|
+
try {
|
|
112
|
+
(0, loader_1.parseSource)(source, {}, ctx);
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
117
|
+
throw err;
|
|
118
|
+
return (0, resolve_profiles_1.readerMessage)(err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
99
121
|
const ALLOWED_DIALECT_SET = new Set(ir_1.ALLOWED_DIALECTS);
|
|
100
122
|
const DIALECT_ALIASES = {
|
|
101
123
|
GOOGLE_SQL: 'BIGQUERY',
|
|
@@ -124,6 +146,10 @@ const SOURCE_PREFIX_PATTERNS = [
|
|
|
124
146
|
re: /^biglake\.googleapis\.com\/projects\/([^/]+)\/catalogs\/([^/.]+)\/namespaces\/([^/.]+)$/,
|
|
125
147
|
form: loader_1.TABLE_FORMS.biglake,
|
|
126
148
|
},
|
|
149
|
+
{
|
|
150
|
+
re: /^biglake\.googleapis\.com\/projects\/([^/]+)\/catalogs\/([^/.]+)\/databases\/([^/.]+)$/,
|
|
151
|
+
form: loader_1.TABLE_FORMS.biglakeHive,
|
|
152
|
+
},
|
|
127
153
|
{
|
|
128
154
|
re: /^spanner\.googleapis\.com\/projects\/([^/]+)\/instances\/([^/]+)\/databases\/([^/]+)$/,
|
|
129
155
|
form: loader_1.TABLE_FORMS.spanner,
|
|
@@ -146,6 +172,10 @@ const SOURCE_PREFIX_PATTERNS = [
|
|
|
146
172
|
form: loader_1.TABLE_FORMS.cloudsqlSchema,
|
|
147
173
|
},
|
|
148
174
|
];
|
|
175
|
+
// An entry group a `--source-prefix` names. A system entry group, such as
|
|
176
|
+
// `@bigquery`, is refused, since its entries are Google Cloud tables, which a
|
|
177
|
+
// source names by resource name.
|
|
178
|
+
const ENTRY_GROUP_PREFIX = /^projects\/([^/]+)\/locations\/([^/]+)\/entryGroups\/([^/]+)$/;
|
|
149
179
|
/** Filters an unknown array down to its plain-object elements. */
|
|
150
180
|
function asRecords(v) {
|
|
151
181
|
return Array.isArray(v) ? v.filter(loader_1.isRecord) : [];
|
|
@@ -217,20 +247,42 @@ function parseImportDocument(rawYaml, opts) {
|
|
|
217
247
|
return err?.message ?? String(err);
|
|
218
248
|
}
|
|
219
249
|
}
|
|
220
|
-
// 3. Validate `opts.sourcePrefix` against the
|
|
221
|
-
// container resource name patterns before doing any
|
|
250
|
+
// 3. Validate `opts.sourcePrefix` against the supported first-party GCP
|
|
251
|
+
// container resource name patterns, or as an entry group, before doing any
|
|
252
|
+
// document work.
|
|
222
253
|
let sourcePrefix;
|
|
254
|
+
let entryGroup;
|
|
223
255
|
if (opts.sourcePrefix !== undefined) {
|
|
224
256
|
const base = opts.sourcePrefix.trim();
|
|
225
257
|
const match = SOURCE_PREFIX_PATTERNS.map(p => ({ m: base.match(p.re), p }))
|
|
226
258
|
.find(x => x.m !== null);
|
|
227
|
-
|
|
259
|
+
const group = base.match(ENTRY_GROUP_PREFIX);
|
|
260
|
+
if (match?.m) {
|
|
261
|
+
sourcePrefix = { base, known: match.m.slice(1), child: match.p.child };
|
|
262
|
+
}
|
|
263
|
+
else if (group?.[3].startsWith('@')) {
|
|
264
|
+
return `sourcePrefix '${opts.sourcePrefix}' names the system entry ` +
|
|
265
|
+
`group '${group[3]}', whose entries are Google Cloud tables. Pass ` +
|
|
266
|
+
`the GCP resource name of their database instead, such as ` +
|
|
267
|
+
`'bigquery.googleapis.com/projects/<p>/datasets/<d>'.`;
|
|
268
|
+
}
|
|
269
|
+
else if (group) {
|
|
270
|
+
entryGroup = {
|
|
271
|
+
base,
|
|
272
|
+
project: group[1],
|
|
273
|
+
location: group[2],
|
|
274
|
+
group: group[3],
|
|
275
|
+
list: opts.listEntries,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
228
279
|
return `Invalid sourcePrefix '${opts.sourcePrefix}'; expected the GCP ` +
|
|
229
280
|
`resource name of a BigQuery project or dataset, a BigLake ` +
|
|
230
|
-
`catalog or
|
|
231
|
-
`or schema, or a Cloud SQL database or schema
|
|
281
|
+
`catalog, namespace or Hive database, a Spanner database, an ` +
|
|
282
|
+
`AlloyDB database or schema, or a Cloud SQL database or schema ` +
|
|
283
|
+
`(with no leading '//'), or the name of a Knowledge Catalog entry ` +
|
|
284
|
+
`group ('projects/<p>/locations/<l>/entryGroups/<g>').`;
|
|
232
285
|
}
|
|
233
|
-
sourcePrefix = { base, known: match.m.slice(1), child: match.p.child };
|
|
234
286
|
}
|
|
235
287
|
// 4. Reject a document written for kcmd when `strict` is false: one with a
|
|
236
288
|
// `GOOGLE` block, or a Google-flavor model converting to vanilla.
|
|
@@ -269,6 +321,7 @@ function parseImportDocument(rawYaml, opts) {
|
|
|
269
321
|
inputVersion,
|
|
270
322
|
targetVersion,
|
|
271
323
|
sourcePrefix,
|
|
324
|
+
entryGroup,
|
|
272
325
|
strict,
|
|
273
326
|
modified: false,
|
|
274
327
|
warnings,
|
|
@@ -308,11 +361,10 @@ function parseGoogleBlocks(exts) {
|
|
|
308
361
|
}
|
|
309
362
|
/**
|
|
310
363
|
* Validates all model, deployment, profile, entity/dataset, field,
|
|
311
|
-
* relationship, and metric names in `doc` (plus any `googleBlocks`
|
|
312
|
-
*
|
|
313
|
-
* is reported at once.
|
|
364
|
+
* relationship, and metric names in `doc` (plus any `googleBlocks`) in a
|
|
365
|
+
* single pass so every invalid or over-length identifier is reported at once.
|
|
314
366
|
*/
|
|
315
|
-
function validateObjectNames(doc, googleBlocks, modelScope
|
|
367
|
+
function validateObjectNames(doc, googleBlocks, modelScope) {
|
|
316
368
|
// Validates a single identifier `name` against `kind`'s rules via
|
|
317
369
|
// `nameProblem` and appends any error to `doc.errors`.
|
|
318
370
|
const validateName = (kind, name, scope) => {
|
|
@@ -327,8 +379,8 @@ function validateObjectNames(doc, googleBlocks, modelScope, profileFiles) {
|
|
|
327
379
|
for (const record of records)
|
|
328
380
|
validateName(kind, record.name, scope);
|
|
329
381
|
};
|
|
330
|
-
// 1. Validate model, deployment, and profile names (inline
|
|
331
|
-
//
|
|
382
|
+
// 1. Validate model, deployment, and profile names (inline and `GOOGLE`
|
|
383
|
+
// block).
|
|
332
384
|
validateName('model', doc.model.name);
|
|
333
385
|
validateRecords('deployment', asRecords(doc.model.deployments), modelScope);
|
|
334
386
|
validateRecords('profile', asRecords(doc.model.profiles), modelScope);
|
|
@@ -336,11 +388,6 @@ function validateObjectNames(doc, googleBlocks, modelScope, profileFiles) {
|
|
|
336
388
|
validateRecords('deployment', asRecords(g.deployments), modelScope);
|
|
337
389
|
validateRecords('profile', asRecords(g.profiles), modelScope);
|
|
338
390
|
}
|
|
339
|
-
if (doc.targetVersion === loader_1.OSSIE_VERSION) {
|
|
340
|
-
for (const pf of profileFiles ?? []) {
|
|
341
|
-
validateName('profile', parseProfileName(pf.fileName), modelScope);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
391
|
// 2. Validate dataset/entity, field, relationship, and metric names.
|
|
345
392
|
for (const ds of doc.datasets) {
|
|
346
393
|
validateName('entity', ds.name);
|
|
@@ -352,15 +399,14 @@ function validateObjectNames(doc, googleBlocks, modelScope, profileFiles) {
|
|
|
352
399
|
}
|
|
353
400
|
/**
|
|
354
401
|
* Phase 2 (Pre-validation): Validates all identifier names in the input
|
|
355
|
-
* document
|
|
356
|
-
* legacy `deploymentTargets`.
|
|
402
|
+
* document. The loader rejects a model's legacy `deploymentTargets`.
|
|
357
403
|
*/
|
|
358
|
-
function validateInputDocument(doc
|
|
404
|
+
function validateInputDocument(doc) {
|
|
359
405
|
const modelScope = typeof doc.model.name === 'string' ?
|
|
360
406
|
`model '${doc.model.name}'` :
|
|
361
407
|
'model';
|
|
362
408
|
const googleBlocks = parseGoogleBlocks(doc.model.custom_extensions);
|
|
363
|
-
validateObjectNames(doc, googleBlocks, modelScope
|
|
409
|
+
validateObjectNames(doc, googleBlocks, modelScope);
|
|
364
410
|
}
|
|
365
411
|
/**
|
|
366
412
|
* The parts of a bare dot-separated table name `source` as `prefix` reads
|
|
@@ -375,7 +421,7 @@ function sourceParts(source, prefix) {
|
|
|
375
421
|
(0, loader_1.hasUnquotedWhitespace)(trimmed)) {
|
|
376
422
|
return undefined;
|
|
377
423
|
}
|
|
378
|
-
const segs = prefixParts(trimmed, prefix
|
|
424
|
+
const segs = prefixParts(trimmed, prefix?.base ?? '');
|
|
379
425
|
return segs.length && segs.every(s => s.text.length) ? segs : undefined;
|
|
380
426
|
}
|
|
381
427
|
/**
|
|
@@ -478,6 +524,79 @@ function completes(source, kinds) {
|
|
|
478
524
|
qualifyTableSource(source, prefix) !== undefined;
|
|
479
525
|
});
|
|
480
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* Phase 2b (Catalog read): When `doc.entryGroup` is set and a dataset's
|
|
529
|
+
* source is a bare table name, lists the entry group's entries once into
|
|
530
|
+
* `doc.entryTables`, keeping those whose fullyQualifiedName names a table or
|
|
531
|
+
* view. Returns the error when the list fails.
|
|
532
|
+
*/
|
|
533
|
+
async function listEntryTables(doc) {
|
|
534
|
+
const group = doc.entryGroup;
|
|
535
|
+
if (!group ||
|
|
536
|
+
!doc.datasets.some(ds => typeof ds.source === 'string' && sourceParts(ds.source))) {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
const tables = [];
|
|
540
|
+
try {
|
|
541
|
+
for await (const e of group.list(group.project, group.location, group.group)) {
|
|
542
|
+
const fqn = e.fullyQualifiedName ?? '';
|
|
543
|
+
const table = (0, source_dialect_1.tableFqnParts)(fqn);
|
|
544
|
+
if (!table)
|
|
545
|
+
continue;
|
|
546
|
+
tables.push({
|
|
547
|
+
name: `${group.base}${e.name.slice(e.name.indexOf('/entries/'))}`,
|
|
548
|
+
fullyQualifiedName: fqn,
|
|
549
|
+
parts: table.parts,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
return `Could not list the entries of entry group '${group.base}' to ` +
|
|
555
|
+
`complete bare table names: ${err?.message ?? err}`;
|
|
556
|
+
}
|
|
557
|
+
doc.entryTables = tables;
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Completes the bare table name `source` of `ds` with the one entry in
|
|
562
|
+
* `doc.entryTables` whose fullyQualifiedName ends in its parts, an unquoted
|
|
563
|
+
* part in any letter case and a quoted part in its own case. Leaves a name no
|
|
564
|
+
* entry or several entries end in as written, and reports it with the
|
|
565
|
+
* candidates.
|
|
566
|
+
*/
|
|
567
|
+
function completeFromEntryGroup(ds, source, parts, doc) {
|
|
568
|
+
const dsLabel = `dataset '${ds.name}'`;
|
|
569
|
+
const group = doc.entryGroup.base;
|
|
570
|
+
// Compared from the end, so a name with more parts than an entry's matches
|
|
571
|
+
// none.
|
|
572
|
+
const matches = doc.entryTables.filter(t => parts.every((p, i) => {
|
|
573
|
+
const e = t.parts.at(i - parts.length);
|
|
574
|
+
return p.quoted ? p.text === e : p.text.toLowerCase() === e?.toLowerCase();
|
|
575
|
+
}));
|
|
576
|
+
if (matches.length === 1) {
|
|
577
|
+
// The author asked for this rewrite with --source-prefix, so it is a
|
|
578
|
+
// warning in strict mode too.
|
|
579
|
+
const [m] = matches;
|
|
580
|
+
doc.warnings.push(`${dsLabel}: rewrote bare source '${source}' to '${m.name}', the ` +
|
|
581
|
+
`entry whose fullyQualifiedName is '${m.fullyQualifiedName}'.`);
|
|
582
|
+
ds.source = m.name;
|
|
583
|
+
doc.modified = true;
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const left = `${dsLabel}: left bare source '${source}' as written, since `;
|
|
587
|
+
if (matches.length) {
|
|
588
|
+
const names = matches.map(m => `'${m.name}' (${m.fullyQualifiedName})`).join(', ');
|
|
589
|
+
doc.warnOrError(`${left}several entries in entry group '${group}' have a ` +
|
|
590
|
+
`fullyQualifiedName that ends in it: ${names}. 'kcmd push' rejects ` +
|
|
591
|
+
`the file until the source names one of them.`);
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
doc.warnOrError(`${left}no entry for a table or view in entry group '${group}' has ` +
|
|
595
|
+
`a fullyQualifiedName that ends in it. 'kcmd push' rejects the file ` +
|
|
596
|
+
`until the source is the table's GCP resource name. ` +
|
|
597
|
+
loader_1.THIRD_PARTY_SOURCE);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
481
600
|
/**
|
|
482
601
|
* When the output's flavor rejects `obj.datatype`, writes it in its canonical
|
|
483
602
|
* casing, such as `Integer` for `integer`, or deletes an `Opaque` in another
|
|
@@ -618,19 +737,22 @@ function repairField(f, dsName, doc) {
|
|
|
618
737
|
}
|
|
619
738
|
/**
|
|
620
739
|
* Repairs a dataset's `source` (removing query-valued sources, qualifying bare
|
|
621
|
-
* table names against `doc.sourcePrefix
|
|
622
|
-
*
|
|
740
|
+
* table names against `doc.sourcePrefix` or completing them from
|
|
741
|
+
* `doc.entryTables`, and warning on unaccepted sources), truncates its
|
|
742
|
+
* `description`, and repairs each of its `fields`.
|
|
623
743
|
*/
|
|
624
744
|
function repairDataset(ds, doc) {
|
|
625
745
|
// 1. Repair or diagnose `ds.source`: remove query-valued sources (in the
|
|
626
|
-
// Google version), qualify bare table names against `doc.sourcePrefix
|
|
627
|
-
// warn on unaccepted source
|
|
746
|
+
// Google version), qualify bare table names against `doc.sourcePrefix` or
|
|
747
|
+
// complete them from `doc.entryTables`, or warn on unaccepted source
|
|
748
|
+
// formats.
|
|
628
749
|
const dsLabel = `dataset '${ds.name}'`;
|
|
629
750
|
if (typeof ds.source === 'string') {
|
|
630
751
|
const trimmed = ds.source.trim();
|
|
631
752
|
const qualified = doc.sourcePrefix ?
|
|
632
753
|
qualifyTableSource(trimmed, doc.sourcePrefix) :
|
|
633
754
|
undefined;
|
|
755
|
+
const parts = doc.entryTables ? sourceParts(trimmed) : undefined;
|
|
634
756
|
if ((0, loader_1.hasUnquotedWhitespace)(trimmed)) {
|
|
635
757
|
if (doc.targetVersion === loader_1.OSSIE_VERSION) {
|
|
636
758
|
doc.errors.push(`${dsLabel}: query-valued source '${trimmed}' is not supported, ` +
|
|
@@ -651,8 +773,10 @@ function repairDataset(ds, doc) {
|
|
|
651
773
|
ds.source = qualified;
|
|
652
774
|
doc.modified = true;
|
|
653
775
|
}
|
|
654
|
-
else if (
|
|
655
|
-
(
|
|
776
|
+
else if (parts) {
|
|
777
|
+
completeFromEntryGroup(ds, trimmed, parts, doc);
|
|
778
|
+
}
|
|
779
|
+
else if (sourceError(trimmed, dsLabel) !== undefined) {
|
|
656
780
|
// Why a prefix that nearly fits the name cannot complete it.
|
|
657
781
|
const p = doc.sourcePrefix;
|
|
658
782
|
const caseOnly = !!p && qualifyTableSource(trimmed, p, true) !== undefined;
|
|
@@ -719,12 +843,51 @@ function repairMetric(mt, entityNames, impliedDialect, doc) {
|
|
|
719
843
|
truncateDescription(mt, mtLabel, doc);
|
|
720
844
|
return true;
|
|
721
845
|
}
|
|
846
|
+
/**
|
|
847
|
+
* Reads, with `opts.readDialects`, the dialect of each Cloud SQL, Spanner and
|
|
848
|
+
* third-party table the repaired model and its profile files name, as push
|
|
849
|
+
* reads them. A read that fails is one warning per table, or per database for
|
|
850
|
+
* Spanner, and the dialect of the tables it reads stays unknown. A lookup that
|
|
851
|
+
* cannot start is one warning for the run, and every such table's dialect
|
|
852
|
+
* stays unknown. An entry that was read and gives no dialect is reported as
|
|
853
|
+
* push's other refusals are.
|
|
854
|
+
*/
|
|
855
|
+
async function readSourceDialects(doc, opts) {
|
|
856
|
+
// The repaired sources are in `doc.root`, and JSON is YAML.
|
|
857
|
+
const texts = [
|
|
858
|
+
JSON.stringify(doc.root),
|
|
859
|
+
...(opts.profileFiles ?? []).map(f => f.content),
|
|
860
|
+
];
|
|
861
|
+
if (!opts.readDialects || !(0, source_dialect_1.sourcesToLookUp)(texts).length)
|
|
862
|
+
return {};
|
|
863
|
+
let got;
|
|
864
|
+
try {
|
|
865
|
+
got = await opts.readDialects(texts);
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
// The lookup could not start, for example with no credentials. gcloud
|
|
869
|
+
// prints the details, so the warning keeps the first line.
|
|
870
|
+
const why = String(err?.message ?? err).split('\n')[0];
|
|
871
|
+
doc.warnings.push(`${(0, parameters_1.sentence)(`kcmd could not look up table dialects: ${why}`)} Import ` +
|
|
872
|
+
"skipped the checks that need a table's dialect.");
|
|
873
|
+
return {};
|
|
874
|
+
}
|
|
875
|
+
const skipped = 'Import skipped the checks that need its dialect.';
|
|
876
|
+
for (const e of got.errors) {
|
|
877
|
+
if (got.unread.includes(e))
|
|
878
|
+
doc.warnings.push(`${(0, parameters_1.sentence)(e)} ${skipped}`);
|
|
879
|
+
else
|
|
880
|
+
doc.warnOrError(e);
|
|
881
|
+
}
|
|
882
|
+
return { sourceDialects: got.dialects, sourceDatabases: got.databases };
|
|
883
|
+
}
|
|
722
884
|
/**
|
|
723
885
|
* Phase 3 (Repair): Repairs third-party constructs in-place on `doc` (pruning
|
|
724
886
|
* extra models, repairing datasets and their fields, pruning unanchored
|
|
725
|
-
* metrics, and truncating descriptions > 2,000 chars).
|
|
887
|
+
* metrics, and truncating descriptions > 2,000 chars). Returns the source
|
|
888
|
+
* dialects it reads after repairing the datasets.
|
|
726
889
|
*/
|
|
727
|
-
function repairDocument(doc) {
|
|
890
|
+
async function repairDocument(doc, opts) {
|
|
728
891
|
// 1. Prune extra models after `semantic_model[0]` because `kcmd` allows only
|
|
729
892
|
// a single model per document, and cap the model's `description` at 2,000
|
|
730
893
|
// characters (`MAX_ENTRY_DESCRIPTION`).
|
|
@@ -742,16 +905,24 @@ function repairDocument(doc) {
|
|
|
742
905
|
for (const ds of doc.datasets) {
|
|
743
906
|
repairDataset(ds, doc);
|
|
744
907
|
}
|
|
745
|
-
// 3.
|
|
746
|
-
// `sourcesDialect`, and repair or prune each metric
|
|
908
|
+
// 3. Read the source dialects, infer the model's source dialect from its
|
|
909
|
+
// repaired datasets via `sourcesDialect`, and repair or prune each metric
|
|
910
|
+
// (`repairMetric`). An ANSI_SQL text is not read when no lookup gave its
|
|
911
|
+
// tables' dialect, as push's checks read none.
|
|
912
|
+
const load = await readSourceDialects(doc, opts);
|
|
747
913
|
const entityNames = doc.datasets.map(ds => ds.name)
|
|
748
914
|
.filter((n) => typeof n === 'string');
|
|
749
915
|
const impliedDialect = (0, resolve_profiles_1.sourcesDialect)({
|
|
750
|
-
entities: doc.datasets.map(ds =>
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
916
|
+
entities: doc.datasets.map(ds => {
|
|
917
|
+
const source = typeof ds.source === 'string' ? ds.source : undefined;
|
|
918
|
+
const key = source && (0, loader_1.dialectLookupKey)(source.trim());
|
|
919
|
+
return {
|
|
920
|
+
abstract: Boolean(ds.abstract),
|
|
921
|
+
authoredSource: source,
|
|
922
|
+
dataSource: '',
|
|
923
|
+
sourceDialect: key ? load.sourceDialects?.get(key) : undefined,
|
|
924
|
+
};
|
|
925
|
+
}),
|
|
755
926
|
});
|
|
756
927
|
doc.metrics = doc.metrics.filter(mt => repairMetric(mt, entityNames, impliedDialect, doc));
|
|
757
928
|
if (Array.isArray(doc.model.metrics) && doc.model.metrics.length) {
|
|
@@ -766,6 +937,7 @@ function repairDocument(doc) {
|
|
|
766
937
|
truncateDescription(b, rLabel, doc);
|
|
767
938
|
}
|
|
768
939
|
}
|
|
940
|
+
return load;
|
|
769
941
|
}
|
|
770
942
|
/**
|
|
771
943
|
* Sets `version` to `doc.targetVersion`, and names the model's entity list as
|
|
@@ -985,9 +1157,13 @@ function moveConstructs(doc, profileFiles) {
|
|
|
985
1157
|
/**
|
|
986
1158
|
* Loads `doc.root` into the `SemanticModel` IR, runs `transpileModel` to
|
|
987
1159
|
* translate vendor or double-quoted `ANSI_SQL` expressions to `BIGQUERY`, and
|
|
988
|
-
* writes any newly added `BIGQUERY` dialect entries back onto `doc
|
|
1160
|
+
* writes any newly added `BIGQUERY` dialect entries back onto `doc`, including
|
|
1161
|
+
* the field bindings of each BigQuery profile (`isBigQueryProfile`) in the
|
|
1162
|
+
* model's `GOOGLE` blocks. Translates the BigQuery profiles among
|
|
1163
|
+
* `profileFiles` the same way and returns every file. A warning about a JSON
|
|
1164
|
+
* profile file names the matching file of `inputFiles`.
|
|
989
1165
|
*/
|
|
990
|
-
async function transpileDocument(doc) {
|
|
1166
|
+
async function transpileDocument(doc, profileFiles, inputFiles = profileFiles) {
|
|
991
1167
|
let preLoaded;
|
|
992
1168
|
try {
|
|
993
1169
|
preLoaded = (0, loader_1.fromDocument)(doc.root, { bindingOptional: true, warnUnacceptedSource: true });
|
|
@@ -995,24 +1171,37 @@ async function transpileDocument(doc) {
|
|
|
995
1171
|
catch (err) {
|
|
996
1172
|
if (!(err instanceof loader_1.SemanticModelError))
|
|
997
1173
|
throw err;
|
|
998
|
-
return;
|
|
1174
|
+
return profileFiles;
|
|
999
1175
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
const syncNode = (rawNode, irDialects) => {
|
|
1003
|
-
const prevCount = typeof rawNode.expression === 'string' ?
|
|
1004
|
-
1 :
|
|
1005
|
-
asRecords(rawNode.expression?.dialects).length;
|
|
1006
|
-
if (prevCount > 0 && irDialects && irDialects.length > prevCount) {
|
|
1007
|
-
rawNode.expression = {
|
|
1008
|
-
dialects: irDialects.map(d => ({ dialect: d.dialect, expression: d.expression })),
|
|
1009
|
-
};
|
|
1176
|
+
const syncNode = (...args) => {
|
|
1177
|
+
if (syncDialects(...args))
|
|
1010
1178
|
doc.modified = true;
|
|
1011
|
-
}
|
|
1012
1179
|
};
|
|
1180
|
+
// Each of `profileFiles` as loaded, or undefined for one that does not
|
|
1181
|
+
// load. That one is written as it is when it parses, since push reports it.
|
|
1182
|
+
const loaded = profileFiles.map(pf => {
|
|
1183
|
+
try {
|
|
1184
|
+
return (0, resolve_profiles_1.loadProfileFile)(pf.content, parseProfileName(pf.fileName), { skipActions: true });
|
|
1185
|
+
}
|
|
1186
|
+
catch (err) {
|
|
1187
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
1188
|
+
throw err;
|
|
1189
|
+
return undefined;
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
let translatedFiles = profileFiles;
|
|
1013
1193
|
for (const m of preLoaded.models) {
|
|
1014
|
-
const transpiled = await (0, transpile_1.transpileModel)(
|
|
1194
|
+
const transpiled = await (0, transpile_1.transpileModel)({
|
|
1195
|
+
...m,
|
|
1196
|
+
profiles: [...(m.profiles ?? []), ...loaded.filter(p => p !== undefined)],
|
|
1197
|
+
}, { translatesProfile: isBigQueryProfile });
|
|
1015
1198
|
doc.warnings.push(...transpiled.warnings);
|
|
1199
|
+
const profile = (name) => transpiled.model.profiles?.find(p => p.name === name);
|
|
1200
|
+
translatedFiles = profileFiles.map((pf, i) => {
|
|
1201
|
+
const p = loaded[i] && profile(loaded[i].name);
|
|
1202
|
+
const named = { ...pf, fileName: inputFiles[i].fileName };
|
|
1203
|
+
return p ? { ...pf, content: withDialects(named, p, doc) } : pf;
|
|
1204
|
+
});
|
|
1016
1205
|
for (const ds of doc.datasets) {
|
|
1017
1206
|
const irEntity = transpiled.model.entities.find(e => e.name === ds.name);
|
|
1018
1207
|
for (const f of asRecords(ds.fields)) {
|
|
@@ -1022,57 +1211,143 @@ async function transpileDocument(doc) {
|
|
|
1022
1211
|
for (const mt of doc.metrics) {
|
|
1023
1212
|
syncNode(mt, transpiled.model.metrics.find(x => x.name === mt.name)?.dialects);
|
|
1024
1213
|
}
|
|
1214
|
+
// A profile in a GOOGLE block is JSON text, so a block whose profiles
|
|
1215
|
+
// gained an entry is written back as JSON.
|
|
1216
|
+
for (const block of asRecords(doc.model.custom_extensions)) {
|
|
1217
|
+
const data = parseGoogleBlocks([block])[0];
|
|
1218
|
+
let changed = false;
|
|
1219
|
+
for (const p of asRecords(data?.profiles)) {
|
|
1220
|
+
changed = syncProfile(p, profile(p.name)) || changed;
|
|
1221
|
+
}
|
|
1222
|
+
if (changed) {
|
|
1223
|
+
block.data = JSON.stringify(data);
|
|
1224
|
+
doc.modified = true;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return translatedFiles;
|
|
1229
|
+
}
|
|
1230
|
+
// Whether `profile` is a BigQuery profile of `model`, by the rule push's import
|
|
1231
|
+
// hint uses: each source push accepts that it states for a concrete entity of
|
|
1232
|
+
// `model` is a BigQuery or BigLake table, and it states at least one.
|
|
1233
|
+
function isBigQueryProfile(profile, model) {
|
|
1234
|
+
const concrete = new Set(model.entities.filter(e => !e.abstract).map(e => e.name));
|
|
1235
|
+
const entities = profile.entities.flatMap(({ name, source }) => concrete.has(name) && source !== undefined &&
|
|
1236
|
+
sourceError(source, '') === undefined ?
|
|
1237
|
+
[{ authoredSource: source, dataSource: source }] :
|
|
1238
|
+
[]);
|
|
1239
|
+
return (0, resolve_profiles_1.sourcesDialect)({ entities }) === 'BIGQUERY';
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Adds to `rawNode.expression` each dialect entry, such as `BIGQUERY`, that
|
|
1243
|
+
* `transpileModel` appended to `irDialects`, and returns whether it added any.
|
|
1244
|
+
* The entries already in a dialects list stay as written.
|
|
1245
|
+
*/
|
|
1246
|
+
function syncDialects(rawNode, irDialects) {
|
|
1247
|
+
const dialects = rawNode.expression?.dialects;
|
|
1248
|
+
const prevCount = typeof rawNode.expression === 'string' ?
|
|
1249
|
+
1 :
|
|
1250
|
+
asRecords(dialects).length;
|
|
1251
|
+
if (prevCount === 0 || !irDialects || irDialects.length <= prevCount) {
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
const entries = irDialects.map(d => ({ dialect: d.dialect, expression: d.expression }));
|
|
1255
|
+
if (typeof rawNode.expression === 'string') {
|
|
1256
|
+
rawNode.expression = { dialects: entries };
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
dialects.push(...entries.slice(prevCount));
|
|
1260
|
+
}
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Writes onto the raw profile `p` each field expression that gained a dialect
|
|
1265
|
+
* entry in the translated `irProfile`, and returns whether any did.
|
|
1266
|
+
*/
|
|
1267
|
+
function syncProfile(p, irProfile) {
|
|
1268
|
+
let changed = false;
|
|
1269
|
+
for (const pe of asRecords(p.entities)) {
|
|
1270
|
+
const irEntity = irProfile?.entities.find(x => x.name === pe.name);
|
|
1271
|
+
for (const f of asRecords(pe.fields)) {
|
|
1272
|
+
const irField = irEntity?.fields?.find(x => x.name === f.name);
|
|
1273
|
+
changed = syncDialects(f, irField?.dialects) || changed;
|
|
1274
|
+
}
|
|
1025
1275
|
}
|
|
1276
|
+
return changed;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Returns the content of the profile file `file` with each field expression
|
|
1280
|
+
* that gained a dialect entry in the translated `profile`, written as the
|
|
1281
|
+
* model file is. A JSON file that changes is written as YAML, and a warning
|
|
1282
|
+
* on `doc` names it.
|
|
1283
|
+
*/
|
|
1284
|
+
function withDialects({ fileName, content }, profile, doc) {
|
|
1285
|
+
const { document, text } = (0, yaml_update_1.parseForUpdate)(content);
|
|
1286
|
+
const root = (0, resolve_profiles_1.plainCopy)(document.toJS());
|
|
1287
|
+
const links = (0, yaml_update_1.linkNodes)(root, document.contents);
|
|
1288
|
+
if (!syncProfile(root, profile))
|
|
1289
|
+
return content;
|
|
1290
|
+
if (text !== content)
|
|
1291
|
+
doc.warnings.push(`Converted '${fileName}' to YAML.`);
|
|
1292
|
+
return writeDocument({ document, links, text }, root);
|
|
1026
1293
|
}
|
|
1027
1294
|
/**
|
|
1028
|
-
* Validates every deployment target on `model` using `
|
|
1029
|
-
*
|
|
1030
|
-
* message.
|
|
1295
|
+
* Validates every deployment target on `model` using `targetProblem` so
|
|
1296
|
+
* invalid targets fail import with `kcmd push`'s exact message.
|
|
1031
1297
|
*/
|
|
1032
1298
|
function validateDeploymentTargets(model, doc) {
|
|
1033
|
-
for (const
|
|
1034
|
-
if ((0, deployment_target_1.targetProblem)('',
|
|
1035
|
-
|
|
1036
|
-
doc.errors.push(d ? (0, deployment_target_1.deploymentTargetError)(model, d) :
|
|
1037
|
-
(0, deployment_target_1.googleBlockTargetError)(model, t));
|
|
1299
|
+
for (const d of model.deployments ?? []) {
|
|
1300
|
+
if ((0, deployment_target_1.targetProblem)('', d.target)) {
|
|
1301
|
+
doc.errors.push((0, deployment_target_1.deploymentTargetError)(model, d));
|
|
1038
1302
|
}
|
|
1039
1303
|
}
|
|
1040
1304
|
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Records push's error for the profile files `profileFiles` beside a vanilla
|
|
1307
|
+
* input in `modelFile`, which keeps its profiles in its `GOOGLE` block. Push
|
|
1308
|
+
* checks the name of each file first. The message is an error in strict mode
|
|
1309
|
+
* and a warning otherwise.
|
|
1310
|
+
*/
|
|
1311
|
+
function checkVanillaProfileFiles(doc, profileFiles, modelFile) {
|
|
1312
|
+
if (doc.inputVersion !== loader_1.OSSIE_VERSION || profileFiles.length === 0)
|
|
1313
|
+
return;
|
|
1314
|
+
const badName = profileFiles
|
|
1315
|
+
.map(pf => (0, semantic_model_1.profileNameError)(pf.fileName, parseProfileName(pf.fileName)))
|
|
1316
|
+
.find(e => e !== undefined);
|
|
1317
|
+
doc.warnOrError(badName ?? (0, semantic_model_1.vanillaSiblingError)(profileFiles[0].fileName, modelFile));
|
|
1318
|
+
}
|
|
1041
1319
|
/**
|
|
1042
1320
|
* Loads each sibling `<model>.profile.<name>.yaml` entry in `profileFiles` via
|
|
1043
1321
|
* `loadProfileFile` and attaches the resulting `ProfileSpec` list to
|
|
1044
|
-
* `model.profiles
|
|
1045
|
-
* profile load error
|
|
1322
|
+
* `model.profiles`. Records in `doc.errors` push's error for the first file
|
|
1323
|
+
* that breaks a file rule beside `modelFile`, or each profile's load error
|
|
1324
|
+
* labeled with its file.
|
|
1046
1325
|
*/
|
|
1047
|
-
function attachProfileFiles(model, profileFiles, doc) {
|
|
1048
|
-
|
|
1326
|
+
function attachProfileFiles(model, profileFiles, doc, modelFile) {
|
|
1327
|
+
const files = (profileFiles ?? []).map(pf => ({ ...pf, name: parseProfileName(pf.fileName) }));
|
|
1328
|
+
// Push checks every file's name before it loads any profile, and stops at
|
|
1329
|
+
// the first file that breaks a file rule.
|
|
1330
|
+
const seen = new Map();
|
|
1331
|
+
try {
|
|
1332
|
+
for (const pf of files) {
|
|
1333
|
+
(0, semantic_model_1.checkProfileFileName)(modelFile, pf.fileName, pf.name, pf.content, seen);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
catch (err) {
|
|
1337
|
+
if (!(err instanceof semantic_model_1.ProfileFileError))
|
|
1338
|
+
throw err;
|
|
1339
|
+
doc.errors.push(err.message);
|
|
1049
1340
|
return;
|
|
1341
|
+
}
|
|
1050
1342
|
const profiles = [];
|
|
1051
|
-
const
|
|
1052
|
-
for (const pf of profileFiles) {
|
|
1053
|
-
const profileName = parseProfileName(pf.fileName);
|
|
1054
|
-
if ((0, names_1.nameProblem)('profile', profileName))
|
|
1055
|
-
continue;
|
|
1056
|
-
const lower = profileName.toLowerCase();
|
|
1057
|
-
if (lower === resolve_profiles_1.DEFAULT_PROFILE) {
|
|
1058
|
-
doc.errors.push(`model '${model.name}': a profile may not be named ` +
|
|
1059
|
-
`'${profileName}'; '${resolve_profiles_1.DEFAULT_PROFILE}' names the model's inline ` +
|
|
1060
|
-
`bindings.`);
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
if (seen.has(lower)) {
|
|
1064
|
-
doc.errors.push(`model '${model.name}': duplicate profile name '${profileName}'; ` +
|
|
1065
|
-
`profile names must be unique ignoring case.`);
|
|
1066
|
-
continue;
|
|
1067
|
-
}
|
|
1068
|
-
seen.add(lower);
|
|
1343
|
+
for (const pf of files) {
|
|
1069
1344
|
try {
|
|
1070
|
-
profiles.push((0, resolve_profiles_1.loadProfileFile)(pf.content,
|
|
1345
|
+
profiles.push((0, resolve_profiles_1.loadProfileFile)(pf.content, pf.name));
|
|
1071
1346
|
}
|
|
1072
1347
|
catch (err) {
|
|
1073
1348
|
if (!(err instanceof loader_1.SemanticModelError))
|
|
1074
1349
|
throw err;
|
|
1075
|
-
doc.errors.push(err.message);
|
|
1350
|
+
doc.errors.push(`[${pf.fileName}] ${err.message}`);
|
|
1076
1351
|
}
|
|
1077
1352
|
}
|
|
1078
1353
|
if (profiles.length > 0) {
|
|
@@ -1152,7 +1427,7 @@ async function generateConvertedFiles(doc, opts) {
|
|
|
1152
1427
|
if (!model)
|
|
1153
1428
|
return [];
|
|
1154
1429
|
if (doc.targetVersion === loader_1.OSSIE_VERSION) {
|
|
1155
|
-
attachProfileFiles(model, opts.profileFiles, doc);
|
|
1430
|
+
attachProfileFiles(model, opts.profileFiles, doc, opts.fileName ?? `${model.name}.yaml`);
|
|
1156
1431
|
validateOssieConversion(model, doc);
|
|
1157
1432
|
if (doc.errors.length > 0)
|
|
1158
1433
|
return [];
|
|
@@ -1163,13 +1438,22 @@ async function generateConvertedFiles(doc, opts) {
|
|
|
1163
1438
|
* Phase 4 (Adapt): Adapts `doc` in place to its target version
|
|
1164
1439
|
* (`moveConstructs`, then `adaptToGoogleVersion` or `adaptToOssieVersion`),
|
|
1165
1440
|
* optionally transpiles expressions via `transpileDocument`, and returns the
|
|
1166
|
-
* output files
|
|
1441
|
+
* output files: the model file, then the profile files. An import that keeps
|
|
1442
|
+
* the flavor writes `profileFiles` under the model's name. Every value these
|
|
1443
|
+
* steps do not change keeps its text.
|
|
1167
1444
|
*/
|
|
1168
1445
|
async function generateAdaptedFiles(doc, transpile, profileFiles = []) {
|
|
1169
1446
|
// 1. Adapt `doc` in place to the target output version (`0.2.0.dev0/google`
|
|
1170
|
-
// or `0.2.0.dev0`).
|
|
1171
|
-
|
|
1172
|
-
|
|
1447
|
+
// or `0.2.0.dev0`). Each of `profileFiles` goes under the model's name. A
|
|
1448
|
+
// nameless `<file>.profile.yaml` stays nameless, so push reports it.
|
|
1449
|
+
let profiles = doc.inputVersion === doc.targetVersion ?
|
|
1450
|
+
profileFiles.map(pf => ({
|
|
1451
|
+
fileName: /\.profile\.ya?ml$/i.test(pf.fileName) &&
|
|
1452
|
+
parseProfileName(pf.fileName) === '' ?
|
|
1453
|
+
`${doc.model.name}.profile.yaml` :
|
|
1454
|
+
`${doc.model.name}.profile.${parseProfileName(pf.fileName)}.yaml`,
|
|
1455
|
+
content: pf.content,
|
|
1456
|
+
})) :
|
|
1173
1457
|
moveConstructs(doc, profileFiles);
|
|
1174
1458
|
if (doc.inputVersion === loader_1.OSSIE_VERSION &&
|
|
1175
1459
|
doc.targetVersion === loader_1.GOOGLE_VERSION) {
|
|
@@ -1179,9 +1463,10 @@ async function generateAdaptedFiles(doc, transpile, profileFiles = []) {
|
|
|
1179
1463
|
adaptToOssieVersion(doc);
|
|
1180
1464
|
}
|
|
1181
1465
|
// 2. When `transpile` is enabled and no prior errors occurred, translate
|
|
1182
|
-
// vendor or double-quoted `ANSI_SQL` expressions to `BIGQUERY
|
|
1466
|
+
// vendor or double-quoted `ANSI_SQL` expressions to `BIGQUERY`, in the model
|
|
1467
|
+
// and in each BigQuery profile, in its GOOGLE blocks or among `profiles`.
|
|
1183
1468
|
if (transpile && doc.errors.length === 0) {
|
|
1184
|
-
await transpileDocument(doc);
|
|
1469
|
+
profiles = await transpileDocument(doc, profiles, doc.inputVersion === doc.targetVersion ? profileFiles : profiles);
|
|
1185
1470
|
}
|
|
1186
1471
|
// 3. Write `doc.root` into the input's text when modified or preserve the
|
|
1187
1472
|
// text verbatim when unmodified.
|
|
@@ -1211,17 +1496,29 @@ function writeDocument(doc, root) {
|
|
|
1211
1496
|
return (0, yaml_update_1.updateText)(doc.text, doc.document, root, doc.links);
|
|
1212
1497
|
}
|
|
1213
1498
|
/**
|
|
1214
|
-
* Phase 5 (Post-validation): Validates the
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
1499
|
+
* Phase 5 (Post-validation): Validates the output model file with `loadModels`
|
|
1500
|
+
* (`warnUnacceptedSource: !doc.strict`), checks deployment targets, and runs
|
|
1501
|
+
* push's local checks (`localChecks`) on the model file and each profile it
|
|
1502
|
+
* declares, with the source dialects in `load`. With KC_V2_ASPECTS on, it also
|
|
1503
|
+
* checks that Knowledge Catalog can store each aspect. It also gives push's
|
|
1504
|
+
* warnings from the Knowledge Catalog entry builder, the pruning and each
|
|
1505
|
+
* graph, and leaves out push's notes.
|
|
1217
1506
|
*/
|
|
1218
|
-
function validateOutputDocument(
|
|
1507
|
+
async function validateOutputDocument(files, doc, opts, load) {
|
|
1508
|
+
const [{ fileName, content }, ...profileFiles] = files;
|
|
1219
1509
|
try {
|
|
1220
1510
|
// 1. Load the serialized output YAML with `loadModels`
|
|
1221
1511
|
// (`warnUnacceptedSource: !doc.strict`) to run full schema and semantic
|
|
1222
1512
|
// validation while turning unaccepted table sources into warnings in
|
|
1223
|
-
// non-strict mode, deduplicating warnings already recorded.
|
|
1224
|
-
|
|
1513
|
+
// non-strict mode, deduplicating warnings already recorded. Step 2 gives
|
|
1514
|
+
// push's error for a field its table cannot run, so the load does not
|
|
1515
|
+
// warn about it too.
|
|
1516
|
+
const loaded = (0, loader_1.loadModels)(content, {
|
|
1517
|
+
bindingOptional: true,
|
|
1518
|
+
warnUnacceptedSource: !doc.strict,
|
|
1519
|
+
fieldTextChecked: true,
|
|
1520
|
+
sourceDialects: load.sourceDialects,
|
|
1521
|
+
});
|
|
1225
1522
|
for (const w of loaded.warnings) {
|
|
1226
1523
|
// The repair step has reported each source the loader warns about.
|
|
1227
1524
|
if (LOADER_SOURCE_WARNING.test(w))
|
|
@@ -1230,10 +1527,105 @@ function validateOutputDocument(content, doc) {
|
|
|
1230
1527
|
doc.warnings.push(w);
|
|
1231
1528
|
}
|
|
1232
1529
|
}
|
|
1233
|
-
// 2.
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1530
|
+
// 2. Run push's local checks on the model file and each profile it
|
|
1531
|
+
// declares. What push rejects is an error in strict mode and a warning
|
|
1532
|
+
// otherwise.
|
|
1533
|
+
const input = doc.strict ? opts.fileName : undefined;
|
|
1534
|
+
const model = {
|
|
1535
|
+
name: (input ?? fileName).replace(/\.ya?ml$/, ''),
|
|
1536
|
+
text: content,
|
|
1537
|
+
};
|
|
1538
|
+
// Where the input holds profile `name`, which the output writes in
|
|
1539
|
+
// `written`: its profile file, or the model file's GOOGLE block. Strict
|
|
1540
|
+
// mode writes nothing on an error, so its messages name the input.
|
|
1541
|
+
const inputFiles = doc.inputVersion === loader_1.GOOGLE_VERSION ? opts.profileFiles ?? [] : [];
|
|
1542
|
+
const where = (name, written) => !input ? written :
|
|
1543
|
+
inputFiles.find(pf => parseProfileName(pf.fileName) === name)
|
|
1544
|
+
?.fileName ??
|
|
1545
|
+
model.name;
|
|
1546
|
+
// Push stops at a profile file that breaks a file rule, then at a
|
|
1547
|
+
// deployment target in no accepted form. validateDeploymentTargets reports
|
|
1548
|
+
// such a target as an error in either mode.
|
|
1549
|
+
let read;
|
|
1550
|
+
try {
|
|
1551
|
+
const seen = new Map();
|
|
1552
|
+
read = profileFiles.map(f => {
|
|
1553
|
+
const name = parseProfileName(f.fileName);
|
|
1554
|
+
const label = where(name, f.fileName);
|
|
1555
|
+
(0, semantic_model_1.checkProfileFileName)(input ?? fileName, label, name, f.content, seen);
|
|
1556
|
+
return { name, text: f.content, fileName: label };
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
catch (err) {
|
|
1560
|
+
if (!(err instanceof semantic_model_1.ProfileFileError))
|
|
1561
|
+
throw err;
|
|
1562
|
+
doc.warnOrError(err.message);
|
|
1563
|
+
}
|
|
1564
|
+
if (!doc.errors.length) {
|
|
1565
|
+
for (const m of loaded.models)
|
|
1566
|
+
validateDeploymentTargets(m, doc);
|
|
1567
|
+
}
|
|
1568
|
+
const profiles = read;
|
|
1569
|
+
if (!profiles)
|
|
1570
|
+
return;
|
|
1571
|
+
const declaredBy = () => (0, resolve_profiles_1.declaredProfiles)(model, profiles).map(p => 'spec' in p ? { ...p, label: where(p.name, model.name) } : p);
|
|
1572
|
+
// Push stops at a source its loader rejects. Import still reports each
|
|
1573
|
+
// profile's problems, its sources among them.
|
|
1574
|
+
if (loaded.warnings.some(w => LOADER_SOURCE_WARNING.test(w))) {
|
|
1575
|
+
for (const { error } of (0, resolve_profiles_1.namedProfileErrors)([model], new Map([[model.name, loaded.models[0]]]), () => profiles, declaredBy, doc.warnings, load)) {
|
|
1576
|
+
doc.warnOrError(error);
|
|
1577
|
+
}
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
if (doc.errors.length)
|
|
1581
|
+
return;
|
|
1582
|
+
const base = { document: model.name, model: loaded.models[0] };
|
|
1583
|
+
const own = loaded.warnings.map(w => `[${model.name}] ${w}`);
|
|
1584
|
+
// Push prints each warning once, after the load's. Each is a warning in
|
|
1585
|
+
// either mode.
|
|
1586
|
+
const warn = (w) => {
|
|
1587
|
+
if (!own.includes(w) && !doc.warnings.includes(w))
|
|
1588
|
+
doc.warnings.push(w);
|
|
1589
|
+
};
|
|
1590
|
+
const withFiles = (0, validate_1.withProfileFiles)(base, () => profiles);
|
|
1591
|
+
const checked = 'errors' in withFiles ? withFiles : await (0, validate_1.localChecks)({
|
|
1592
|
+
docs: [model],
|
|
1593
|
+
loaded: { models: [base], warnings: own },
|
|
1594
|
+
models: [withFiles],
|
|
1595
|
+
profileDocs: () => profiles,
|
|
1596
|
+
declaredBy,
|
|
1597
|
+
load,
|
|
1598
|
+
}, warn);
|
|
1599
|
+
// Push prints each error once.
|
|
1600
|
+
const errors = 'errors' in checked ? checked.errors : [];
|
|
1601
|
+
for (const error of new Set(errors.map(e => e.error))) {
|
|
1602
|
+
doc.warnOrError(error);
|
|
1603
|
+
}
|
|
1604
|
+
if ('errors' in checked || 'errors' in withFiles)
|
|
1605
|
+
return;
|
|
1606
|
+
// Push next builds the Knowledge Catalog entries and prints the builder's
|
|
1607
|
+
// warnings. With KC_V2_ASPECTS on, it also checks that Knowledge Catalog
|
|
1608
|
+
// can store each aspect. The entry group does not change an aspect's size.
|
|
1609
|
+
const v2Aspects = (0, validate_1.kcV2Aspects)();
|
|
1610
|
+
const catalog = (0, deploy_knowledge_catalog_1.checkCatalogAspects)([withFiles], {
|
|
1611
|
+
project: 'p', location: 'l', entryGroup: 'g',
|
|
1612
|
+
systemTypeProject: process.env.KC_TYPE_PROJECT, v2Aspects,
|
|
1613
|
+
});
|
|
1614
|
+
for (const w of catalog.warnings)
|
|
1615
|
+
warn(w);
|
|
1616
|
+
if (v2Aspects && catalog.error) {
|
|
1617
|
+
doc.warnOrError(catalog.error);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
// Push then warns about what pruning leaves out of each deployment, and
|
|
1621
|
+
// about what each graph leaves out. Import leaves out push's notes.
|
|
1622
|
+
for (const line of checked.plans.flatMap(validate_1.pruningLines)) {
|
|
1623
|
+
if (line.startsWith('Warning: '))
|
|
1624
|
+
warn(line.slice('Warning: '.length));
|
|
1625
|
+
}
|
|
1626
|
+
for (const { generated } of checked.plans) {
|
|
1627
|
+
for (const w of generated?.warnings ?? [])
|
|
1628
|
+
warn(w);
|
|
1237
1629
|
}
|
|
1238
1630
|
}
|
|
1239
1631
|
catch (err) {
|
|
@@ -1248,23 +1640,51 @@ function validateOutputDocument(content, doc) {
|
|
|
1248
1640
|
* errors.
|
|
1249
1641
|
*/
|
|
1250
1642
|
async function importSemanticModelYaml(rawYaml, opts = {}) {
|
|
1251
|
-
// 1. Initialize the SQL parser and parse/pre-validate the input document
|
|
1643
|
+
// 1. Initialize the SQL parser and parse/pre-validate the input document,
|
|
1644
|
+
// and report any profile file beside a vanilla input as push does.
|
|
1252
1645
|
await (0, sql_parser_1.loadSqlEngine)();
|
|
1253
1646
|
const doc = parseImportDocument(rawYaml, opts);
|
|
1254
1647
|
if (typeof doc === 'string') {
|
|
1255
1648
|
return { ok: false, files: [], warnings: [], errors: [doc] };
|
|
1256
1649
|
}
|
|
1257
|
-
validateInputDocument(doc
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
//
|
|
1650
|
+
validateInputDocument(doc);
|
|
1651
|
+
checkVanillaProfileFiles(doc, opts.profileFiles ?? [], opts.fileName ?? `${doc.model.name}.yaml`);
|
|
1652
|
+
// 2. Read the `--source-prefix` entry group, if needed, and run third-party
|
|
1653
|
+
// repairs (which record errors in `strict` mode) and read the source
|
|
1654
|
+
// dialects.
|
|
1655
|
+
const listError = await listEntryTables(doc);
|
|
1656
|
+
if (listError) {
|
|
1657
|
+
return {
|
|
1658
|
+
ok: false,
|
|
1659
|
+
files: [],
|
|
1660
|
+
warnings: doc.warnings,
|
|
1661
|
+
errors: [...doc.errors, listError],
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
const load = await repairDocument(doc, opts);
|
|
1665
|
+
// 3. Generate the converted or adapted output file(s). A conversion to
|
|
1666
|
+
// `0.2.0.dev0` writes the sibling profile files inside the model file, and
|
|
1667
|
+
// a Google-flavor import of a Google-flavor model writes them beside it.
|
|
1261
1668
|
const shouldConvert = doc.inputVersion !== doc.targetVersion && doc.strict;
|
|
1669
|
+
const googleToGoogle = doc.inputVersion === loader_1.GOOGLE_VERSION &&
|
|
1670
|
+
doc.targetVersion === loader_1.GOOGLE_VERSION;
|
|
1262
1671
|
const files = shouldConvert ?
|
|
1263
1672
|
await generateConvertedFiles(doc, opts) :
|
|
1264
|
-
await generateAdaptedFiles(doc, !!opts.transpile);
|
|
1265
|
-
// 4.
|
|
1673
|
+
await generateAdaptedFiles(doc, !!opts.transpile, googleToGoogle ? opts.profileFiles ?? [] : []);
|
|
1674
|
+
// 4. Check that each profile file the import writes parses, validate the
|
|
1675
|
+
// output files, and return the result.
|
|
1676
|
+
for (const [i, f] of files.slice(1).entries()) {
|
|
1677
|
+
const [problem] = yaml.parseDocument(f.content, yaml_options_1.YAML_OPTIONS).errors;
|
|
1678
|
+
if (!problem)
|
|
1679
|
+
continue;
|
|
1680
|
+
// A file import passes through unchanged is the input's own file.
|
|
1681
|
+
const input = opts.profileFiles?.[i];
|
|
1682
|
+
doc.errors.push(input?.content === f.content ?
|
|
1683
|
+
(0, semantic_model_1.profileParseError)(input.fileName, problem.message) :
|
|
1684
|
+
`the import writes '${f.fileName}', which does not parse: ${problem.message}`);
|
|
1685
|
+
}
|
|
1266
1686
|
if (files.length > 0 && doc.errors.length === 0) {
|
|
1267
|
-
validateOutputDocument(files
|
|
1687
|
+
await validateOutputDocument(files, doc, opts, load);
|
|
1268
1688
|
}
|
|
1269
1689
|
return {
|
|
1270
1690
|
ok: doc.errors.length === 0,
|