@google-cloud/kcmd 0.0.0-stage → 0.1.0
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/LICENSE +203 -0
- package/README.md +266 -2
- package/build/ts/kcmd/cli.js +19578 -0
- package/build/ts/kcmd/gcp/alloydb.d.ts +12 -0
- package/build/ts/kcmd/gcp/alloydb.js +70 -0
- package/build/ts/kcmd/gcp/api.d.ts +29 -0
- package/build/ts/kcmd/gcp/api.js +162 -0
- package/build/ts/kcmd/gcp/biglake.d.ts +16 -0
- package/build/ts/kcmd/gcp/biglake.js +50 -0
- package/build/ts/kcmd/gcp/bigquery.d.ts +57 -0
- package/build/ts/kcmd/gcp/bigquery.js +134 -0
- package/build/ts/kcmd/gcp/context.d.ts +10 -0
- package/build/ts/kcmd/gcp/context.js +74 -0
- package/build/ts/kcmd/gcp/crm.d.ts +13 -0
- package/build/ts/kcmd/gcp/crm.js +91 -0
- package/build/ts/kcmd/gcp/dataplex.d.ts +147 -0
- package/build/ts/kcmd/gcp/dataplex.js +406 -0
- package/build/ts/kcmd/gcp/index.d.ts +3 -0
- package/build/ts/kcmd/gcp/index.js +21 -0
- package/build/ts/kcmd/gcp/spanner.d.ts +63 -0
- package/build/ts/kcmd/gcp/spanner.js +123 -0
- package/build/ts/kcmd/index.d.ts +7 -0
- package/build/ts/kcmd/index.js +46 -0
- package/build/ts/kcmd/layout.d.ts +16 -0
- package/build/ts/kcmd/layout.js +31 -0
- package/build/ts/kcmd/layouts/documents.d.ts +24 -0
- package/build/ts/kcmd/layouts/documents.js +264 -0
- package/build/ts/kcmd/layouts/okf.d.ts +31 -0
- package/build/ts/kcmd/layouts/okf.js +344 -0
- package/build/ts/kcmd/layouts/semantic-model.d.ts +61 -0
- package/build/ts/kcmd/layouts/semantic-model.js +542 -0
- package/build/ts/kcmd/layouts/standard.d.ts +13 -0
- package/build/ts/kcmd/layouts/standard.js +108 -0
- package/build/ts/kcmd/manifest.d.ts +34 -0
- package/build/ts/kcmd/manifest.js +262 -0
- package/build/ts/kcmd/metadata.d.ts +25 -0
- package/build/ts/kcmd/metadata.js +8 -0
- package/build/ts/kcmd/okf/catalog_residue.d.ts +14 -0
- package/build/ts/kcmd/okf/catalog_residue.js +124 -0
- package/build/ts/kcmd/okf/concept.d.ts +14 -0
- package/build/ts/kcmd/okf/concept.js +104 -0
- package/build/ts/kcmd/okf/concept_to_entry.d.ts +8 -0
- package/build/ts/kcmd/okf/concept_to_entry.js +254 -0
- package/build/ts/kcmd/okf/init_okf_workspace.d.ts +15 -0
- package/build/ts/kcmd/okf/init_okf_workspace.js +115 -0
- package/build/ts/kcmd/okf/links.d.ts +23 -0
- package/build/ts/kcmd/okf/links.js +468 -0
- package/build/ts/kcmd/okf/okf_metadata.d.ts +12 -0
- package/build/ts/kcmd/okf/okf_metadata.js +82 -0
- package/build/ts/kcmd/semantic/bigquery.d.ts +22 -0
- package/build/ts/kcmd/semantic/bigquery.js +525 -0
- package/build/ts/kcmd/semantic/binding.d.ts +3 -0
- package/build/ts/kcmd/semantic/binding.js +97 -0
- package/build/ts/kcmd/semantic/catalog_entry.d.ts +24 -0
- package/build/ts/kcmd/semantic/catalog_entry.js +35 -0
- package/build/ts/kcmd/semantic/converters/owl/convert.d.ts +26 -0
- package/build/ts/kcmd/semantic/converters/owl/convert.js +41 -0
- package/build/ts/kcmd/semantic/converters/owl/model.d.ts +98 -0
- package/build/ts/kcmd/semantic/converters/owl/model.js +30 -0
- package/build/ts/kcmd/semantic/converters/owl/parse.d.ts +11 -0
- package/build/ts/kcmd/semantic/converters/owl/parse.js +634 -0
- package/build/ts/kcmd/semantic/converters/owl/to_ir.d.ts +19 -0
- package/build/ts/kcmd/semantic/converters/owl/to_ir.js +390 -0
- package/build/ts/kcmd/semantic/deploy_bigquery.d.ts +20 -0
- package/build/ts/kcmd/semantic/deploy_bigquery.js +226 -0
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.d.ts +129 -0
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.js +1396 -0
- package/build/ts/kcmd/semantic/deploy_spanner.d.ts +15 -0
- package/build/ts/kcmd/semantic/deploy_spanner.js +150 -0
- package/build/ts/kcmd/semantic/deployment_target.d.ts +93 -0
- package/build/ts/kcmd/semantic/deployment_target.js +379 -0
- package/build/ts/kcmd/semantic/graph_ddl.d.ts +34 -0
- package/build/ts/kcmd/semantic/graph_ddl.js +563 -0
- package/build/ts/kcmd/semantic/import_model.d.ts +50 -0
- package/build/ts/kcmd/semantic/import_model.js +1275 -0
- package/build/ts/kcmd/semantic/ir.d.ts +589 -0
- package/build/ts/kcmd/semantic/ir.js +247 -0
- package/build/ts/kcmd/semantic/kc_actions.d.ts +45 -0
- package/build/ts/kcmd/semantic/kc_actions.js +531 -0
- package/build/ts/kcmd/semantic/kc_constraints.d.ts +44 -0
- package/build/ts/kcmd/semantic/kc_constraints.js +279 -0
- package/build/ts/kcmd/semantic/kc_converter.d.ts +31 -0
- package/build/ts/kcmd/semantic/kc_converter.js +824 -0
- package/build/ts/kcmd/semantic/kc_custom_types.d.ts +70 -0
- package/build/ts/kcmd/semantic/kc_custom_types.js +828 -0
- package/build/ts/kcmd/semantic/kc_entries.d.ts +155 -0
- package/build/ts/kcmd/semantic/kc_entries.js +284 -0
- package/build/ts/kcmd/semantic/kc_ids.d.ts +103 -0
- package/build/ts/kcmd/semantic/kc_ids.js +191 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.d.ts +32 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.js +1043 -0
- package/build/ts/kcmd/semantic/loader.d.ts +262 -0
- package/build/ts/kcmd/semantic/loader.js +3440 -0
- package/build/ts/kcmd/semantic/names.d.ts +10 -0
- package/build/ts/kcmd/semantic/names.js +85 -0
- package/build/ts/kcmd/semantic/osi_converter.d.ts +60 -0
- package/build/ts/kcmd/semantic/osi_converter.js +911 -0
- package/build/ts/kcmd/semantic/parameters.d.ts +18 -0
- package/build/ts/kcmd/semantic/parameters.js +152 -0
- package/build/ts/kcmd/semantic/pull_kc.d.ts +14 -0
- package/build/ts/kcmd/semantic/pull_kc.js +346 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.d.ts +54 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.js +330 -0
- package/build/ts/kcmd/semantic/resolve_profiles.d.ts +250 -0
- package/build/ts/kcmd/semantic/resolve_profiles.js +1822 -0
- package/build/ts/kcmd/semantic/runtime/agent_tools.d.ts +93 -0
- package/build/ts/kcmd/semantic/runtime/agent_tools.js +243 -0
- package/build/ts/kcmd/semantic/runtime/dialect.d.ts +13 -0
- package/build/ts/kcmd/semantic/runtime/dialect.js +39 -0
- package/build/ts/kcmd/semantic/runtime/runtime.d.ts +94 -0
- package/build/ts/kcmd/semantic/runtime/runtime.js +318 -0
- package/build/ts/kcmd/semantic/runtime/store.d.ts +112 -0
- package/build/ts/kcmd/semantic/runtime/store.js +344 -0
- package/build/ts/kcmd/semantic/skills.d.ts +110 -0
- package/build/ts/kcmd/semantic/skills.js +697 -0
- package/build/ts/kcmd/semantic/source_dialect.d.ts +10 -0
- package/build/ts/kcmd/semantic/source_dialect.js +261 -0
- package/build/ts/kcmd/semantic/source_entries.d.ts +12 -0
- package/build/ts/kcmd/semantic/source_entries.js +282 -0
- package/build/ts/kcmd/semantic/spanner.d.ts +18 -0
- package/build/ts/kcmd/semantic/spanner.js +60 -0
- package/build/ts/kcmd/semantic/sql_expr_utils.d.ts +17 -0
- package/build/ts/kcmd/semantic/sql_expr_utils.js +221 -0
- package/build/ts/kcmd/semantic/sql_identifiers.d.ts +6 -0
- package/build/ts/kcmd/semantic/sql_identifiers.js +267 -0
- package/build/ts/kcmd/semantic/sql_parser.d.ts +44 -0
- package/build/ts/kcmd/semantic/sql_parser.js +378 -0
- package/build/ts/kcmd/semantic/transpile.d.ts +74 -0
- package/build/ts/kcmd/semantic/transpile.js +563 -0
- package/build/ts/kcmd/semantic/validate.d.ts +35 -0
- package/build/ts/kcmd/semantic/validate.js +1899 -0
- package/build/ts/kcmd/semantic/yaml_options.d.ts +6 -0
- package/build/ts/kcmd/semantic/yaml_options.js +58 -0
- package/build/ts/kcmd/semantic/yaml_update.d.ts +26 -0
- package/build/ts/kcmd/semantic/yaml_update.js +782 -0
- package/build/ts/kcmd/snapshot.d.ts +33 -0
- package/build/ts/kcmd/snapshot.js +379 -0
- package/build/ts/kcmd/source.d.ts +31 -0
- package/build/ts/kcmd/source.js +119 -0
- package/build/ts/kcmd/sources/bq-dataset.d.ts +17 -0
- package/build/ts/kcmd/sources/bq-dataset.js +131 -0
- package/build/ts/kcmd/sources/entrygroup.d.ts +21 -0
- package/build/ts/kcmd/sources/entrygroup.js +83 -0
- package/build/ts/kcmd/sources/kb.d.ts +21 -0
- package/build/ts/kcmd/sources/kb.js +82 -0
- package/build/ts/kcmd/sources/semantic-model.d.ts +18 -0
- package/build/ts/kcmd/sources/semantic-model.js +48 -0
- package/build/ts/kcmd/sync.d.ts +35 -0
- package/build/ts/kcmd/sync.js +697 -0
- package/package.json +62 -4
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Imports a third-party (or first-party) semantic model YAML document into a
|
|
3
|
+
// valid kcmd semantic model document.
|
|
4
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
5
|
+
if (k2 === undefined) k2 = k;
|
|
6
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
7
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
8
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
9
|
+
}
|
|
10
|
+
Object.defineProperty(o, k2, desc);
|
|
11
|
+
}) : (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
o[k2] = m[k];
|
|
14
|
+
}));
|
|
15
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
16
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
17
|
+
}) : function(o, v) {
|
|
18
|
+
o["default"] = v;
|
|
19
|
+
});
|
|
20
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
21
|
+
var ownKeys = function(o) {
|
|
22
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
23
|
+
var ar = [];
|
|
24
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
25
|
+
return ar;
|
|
26
|
+
};
|
|
27
|
+
return ownKeys(o);
|
|
28
|
+
};
|
|
29
|
+
return function (mod) {
|
|
30
|
+
if (mod && mod.__esModule) return mod;
|
|
31
|
+
var result = {};
|
|
32
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
33
|
+
__setModuleDefault(result, mod);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.importSemanticModelYaml = importSemanticModelYaml;
|
|
39
|
+
const yaml = __importStar(require("yaml"));
|
|
40
|
+
const deployment_target_1 = require("./deployment_target");
|
|
41
|
+
const ir_1 = require("./ir");
|
|
42
|
+
const loader_1 = require("./loader");
|
|
43
|
+
const names_1 = require("./names");
|
|
44
|
+
const resolve_profiles_1 = require("./resolve_profiles");
|
|
45
|
+
const sql_expr_utils_1 = require("./sql_expr_utils");
|
|
46
|
+
const sql_parser_1 = require("./sql_parser");
|
|
47
|
+
const transpile_1 = require("./transpile");
|
|
48
|
+
const yaml_options_1 = require("./yaml_options");
|
|
49
|
+
const yaml_update_1 = require("./yaml_update");
|
|
50
|
+
const STANDARD_AI_CONTEXT_KEYS = new Set(['instructions', 'synonyms', 'examples']);
|
|
51
|
+
// The loader's warning for a source it leaves as written.
|
|
52
|
+
const LOADER_SOURCE_WARNING = /^(.*?): source '(.*)' is not a valid GCP resource name\.$/s;
|
|
53
|
+
// Why no name to write is given when the name holds a quote.
|
|
54
|
+
const NO_QUOTE = 'kcmd cannot name a table whose name holds a quote';
|
|
55
|
+
// Each kind of --source-prefix for the service `prefix` names, or every kind
|
|
56
|
+
// when there is no prefix.
|
|
57
|
+
function prefixKinds(prefix) {
|
|
58
|
+
const host = prefix?.base.split('/')[0];
|
|
59
|
+
return SOURCE_PREFIX_PATTERNS.filter(k => host === undefined || k.form.startsWith(`${host}/`));
|
|
60
|
+
}
|
|
61
|
+
// The warning for `source`, which import leaves as written after a run with
|
|
62
|
+
// `prefix`, if any. It gives the name to write when the loader names one,
|
|
63
|
+
// or another --source-prefix run when a prefix of that kind, or of any kind
|
|
64
|
+
// when none ran, completes the name, or else why the table cannot be named.
|
|
65
|
+
function sourceWarning(ctx, source, prefix) {
|
|
66
|
+
const problem = (0, loader_1.oldSourceForm)(source);
|
|
67
|
+
if (problem !== undefined) {
|
|
68
|
+
return `${ctx}: source '${source}' is ${problem}. 'kcmd push' rejects ` +
|
|
69
|
+
`the file until it is repointed.`;
|
|
70
|
+
}
|
|
71
|
+
const kinds = prefixKinds(prefix);
|
|
72
|
+
const bare = !/[/:]/.test(source);
|
|
73
|
+
const rerun = bare && completes(source, kinds);
|
|
74
|
+
const quoted = kinds.some(k => {
|
|
75
|
+
const parts = prefixSegments(source, k.form);
|
|
76
|
+
return !parts.length || parts.some(p => /[`"]/.test(p));
|
|
77
|
+
});
|
|
78
|
+
const cannot = !bare || rerun ? undefined :
|
|
79
|
+
/\s/.test(source) ? loader_1.NO_SPACE :
|
|
80
|
+
quoted ? NO_QUOTE :
|
|
81
|
+
undefined;
|
|
82
|
+
const dotted = !bare || rerun || cannot ?
|
|
83
|
+
undefined :
|
|
84
|
+
kinds.map(k => ({
|
|
85
|
+
form: k.form,
|
|
86
|
+
why: (0, loader_1.dottedPartProblem)(prefixSegments(source, k.form), k.form),
|
|
87
|
+
}))
|
|
88
|
+
.find(d => d.why);
|
|
89
|
+
return `${ctx}: source '${source}' is not a valid GCP resource name` +
|
|
90
|
+
(cannot ? `, and ${cannot}. ` :
|
|
91
|
+
dotted ? `; ${dotted.why}. Write the table's GCP resource name ` +
|
|
92
|
+
`instead, such as '${dotted.form}'. ` :
|
|
93
|
+
'; ') +
|
|
94
|
+
`'kcmd push' rejects the file until it is repointed` +
|
|
95
|
+
(rerun ? `, or until 'kcmd import' runs again with '--source-prefix'.` :
|
|
96
|
+
'.') +
|
|
97
|
+
((0, loader_1.isGoogleCloudForm)(source) ? '' : ` ${loader_1.THIRD_PARTY_SOURCE}`);
|
|
98
|
+
}
|
|
99
|
+
const ALLOWED_DIALECT_SET = new Set(ir_1.ALLOWED_DIALECTS);
|
|
100
|
+
const DIALECT_ALIASES = {
|
|
101
|
+
GOOGLE_SQL: 'BIGQUERY',
|
|
102
|
+
GOOGLESQL: 'BIGQUERY',
|
|
103
|
+
POSTGRESQL: 'POSTGRES',
|
|
104
|
+
TSQL: 'SQLSERVER',
|
|
105
|
+
};
|
|
106
|
+
// Each kind of --source-prefix: its pattern, the child container a bare name
|
|
107
|
+
// adds under it, if any, and the resource name of a table under it.
|
|
108
|
+
const SOURCE_PREFIX_PATTERNS = [
|
|
109
|
+
{
|
|
110
|
+
re: /^bigquery\.googleapis\.com\/projects\/([^/]+)$/,
|
|
111
|
+
child: 'datasets',
|
|
112
|
+
form: loader_1.TABLE_FORMS.bigquery,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
re: /^bigquery\.googleapis\.com\/projects\/([^/]+)\/datasets\/([^/.]+)$/,
|
|
116
|
+
form: loader_1.TABLE_FORMS.bigquery,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
re: /^biglake\.googleapis\.com\/projects\/([^/]+)\/catalogs\/([^/.]+)$/,
|
|
120
|
+
child: 'namespaces',
|
|
121
|
+
form: loader_1.TABLE_FORMS.biglake,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
re: /^biglake\.googleapis\.com\/projects\/([^/]+)\/catalogs\/([^/.]+)\/namespaces\/([^/.]+)$/,
|
|
125
|
+
form: loader_1.TABLE_FORMS.biglake,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
re: /^spanner\.googleapis\.com\/projects\/([^/]+)\/instances\/([^/]+)\/databases\/([^/]+)$/,
|
|
129
|
+
form: loader_1.TABLE_FORMS.spanner,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
re: /^alloydb\.googleapis\.com\/projects\/[^/]+\/locations\/[^/]+\/clusters\/[^/]+\/databases\/([^/]+)$/,
|
|
133
|
+
child: 'schemas',
|
|
134
|
+
form: loader_1.TABLE_FORMS.alloydb,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
re: /^alloydb\.googleapis\.com\/projects\/[^/]+\/locations\/[^/]+\/clusters\/[^/]+\/databases\/([^/]+)\/schemas\/([^/]+)$/,
|
|
138
|
+
form: loader_1.TABLE_FORMS.alloydb,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
re: /^cloudsql\.googleapis\.com\/projects\/[^/]+\/locations\/[^/]+\/instances\/[^/]+\/databases\/([^/]+)$/,
|
|
142
|
+
form: loader_1.TABLE_FORMS.cloudsql,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
re: /^cloudsql\.googleapis\.com\/projects\/[^/]+\/locations\/[^/]+\/instances\/[^/]+\/databases\/([^/]+)\/schemas\/([^/]+)$/,
|
|
146
|
+
form: loader_1.TABLE_FORMS.cloudsqlSchema,
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
/** Filters an unknown array down to its plain-object elements. */
|
|
150
|
+
function asRecords(v) {
|
|
151
|
+
return Array.isArray(v) ? v.filter(loader_1.isRecord) : [];
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Returns true if any node in `doc` carries a `GOOGLE` `custom_extensions`
|
|
155
|
+
* block.
|
|
156
|
+
*/
|
|
157
|
+
function documentHasGoogleBlock(doc) {
|
|
158
|
+
for (const m of asRecords(doc.semantic_model)) {
|
|
159
|
+
const nodes = [
|
|
160
|
+
m,
|
|
161
|
+
...asRecords(m.datasets),
|
|
162
|
+
...asRecords(m.entities),
|
|
163
|
+
...asRecords(m.relationships),
|
|
164
|
+
...asRecords(m.metrics),
|
|
165
|
+
...asRecords(m.datasets).flatMap(ds => asRecords(ds.fields)),
|
|
166
|
+
...asRecords(m.entities).flatMap(ds => asRecords(ds.fields)),
|
|
167
|
+
];
|
|
168
|
+
if (nodes.some(n => (0, loader_1.hasGoogleBlock)(asRecords(n.custom_extensions)))) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
/** Moves non-standard `ai_context` sibling keys into `ai_context.custom`. */
|
|
175
|
+
function adaptAiContext(ai) {
|
|
176
|
+
const customKeys = Object.keys(ai).filter(k => !STANDARD_AI_CONTEXT_KEYS.has(k));
|
|
177
|
+
if (!customKeys.length)
|
|
178
|
+
return;
|
|
179
|
+
const custom = {};
|
|
180
|
+
for (const k of customKeys) {
|
|
181
|
+
custom[k] = ai[k];
|
|
182
|
+
delete ai[k];
|
|
183
|
+
}
|
|
184
|
+
ai.custom = custom;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Phase 1 (Parse): Parses raw YAML, verifies the top-level document envelope,
|
|
188
|
+
* validates `opts.sourcePrefix` when present, refuses a document with a
|
|
189
|
+
* `GOOGLE` block or a Google-flavor model to convert to vanilla unless
|
|
190
|
+
* `strict` is set, and constructs the mutable `ImportDocument` (or returns a
|
|
191
|
+
* fatal error string).
|
|
192
|
+
*/
|
|
193
|
+
function parseImportDocument(rawYaml, opts) {
|
|
194
|
+
// 1. Parse raw YAML with strict YAML 1.2 options (`YAML_OPTIONS`) so syntax
|
|
195
|
+
// errors and duplicate keys are caught immediately.
|
|
196
|
+
const { document, text } = (0, yaml_update_1.parseForUpdate)(rawYaml);
|
|
197
|
+
let parsedDoc;
|
|
198
|
+
try {
|
|
199
|
+
if (document.errors.length > 0)
|
|
200
|
+
throw document.errors[0];
|
|
201
|
+
parsedDoc = document.toJS();
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
return `Semantic model load error: could not parse input: ${err?.message ?? err}`;
|
|
205
|
+
}
|
|
206
|
+
// 2. Verify the top-level document envelope (`version` is a recognized format
|
|
207
|
+
// version and `semantic_model[0]` is a model object). If malformed, delegate
|
|
208
|
+
// to `loadModels` so the caller receives the loader's canonical schema error.
|
|
209
|
+
if (!(0, loader_1.isRecord)(parsedDoc) || typeof parsedDoc.version !== 'string' ||
|
|
210
|
+
!(0, loader_1.isFormatVersion)(parsedDoc.version) ||
|
|
211
|
+
!Array.isArray(parsedDoc.semantic_model) ||
|
|
212
|
+
!(0, loader_1.isRecord)(parsedDoc.semantic_model[0])) {
|
|
213
|
+
try {
|
|
214
|
+
(0, loader_1.loadModels)(rawYaml, { bindingOptional: true, warnUnacceptedSource: true });
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
return err?.message ?? String(err);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// 3. Validate `opts.sourcePrefix` against the 8 supported first-party GCP
|
|
221
|
+
// container resource name patterns before doing any document work.
|
|
222
|
+
let sourcePrefix;
|
|
223
|
+
if (opts.sourcePrefix !== undefined) {
|
|
224
|
+
const base = opts.sourcePrefix.trim();
|
|
225
|
+
const match = SOURCE_PREFIX_PATTERNS.map(p => ({ m: base.match(p.re), p }))
|
|
226
|
+
.find(x => x.m !== null);
|
|
227
|
+
if (!match?.m) {
|
|
228
|
+
return `Invalid sourcePrefix '${opts.sourcePrefix}'; expected the GCP ` +
|
|
229
|
+
`resource name of a BigQuery project or dataset, a BigLake ` +
|
|
230
|
+
`catalog or namespace, a Spanner database, an AlloyDB database ` +
|
|
231
|
+
`or schema, or a Cloud SQL database or schema (with no leading '//').`;
|
|
232
|
+
}
|
|
233
|
+
sourcePrefix = { base, known: match.m.slice(1), child: match.p.child };
|
|
234
|
+
}
|
|
235
|
+
// 4. Reject a document written for kcmd when `strict` is false: one with a
|
|
236
|
+
// `GOOGLE` block, or a Google-flavor model converting to vanilla.
|
|
237
|
+
const strict = !!opts.strict;
|
|
238
|
+
if (!strict && documentHasGoogleBlock(parsedDoc)) {
|
|
239
|
+
return `Document contains a '${loader_1.GOOGLE_VENDOR}' custom_extensions block ` +
|
|
240
|
+
`(written for kcmd); use '--input first-party' to convert a ` +
|
|
241
|
+
`first-party model.`;
|
|
242
|
+
}
|
|
243
|
+
if (!strict && opts.flavor === 'ossie' &&
|
|
244
|
+
parsedDoc.version === loader_1.GOOGLE_VERSION) {
|
|
245
|
+
return `Document is a '${loader_1.GOOGLE_VERSION}' model (written for kcmd); use ` +
|
|
246
|
+
`'--input first-party' to convert it to '${loader_1.OSSIE_VERSION}'.`;
|
|
247
|
+
}
|
|
248
|
+
// 5. Deep-copy `parsedDoc` via `plainCopy` to decouple shared YAML anchor /
|
|
249
|
+
// alias references before in-place mutations, and assemble `ImportDocument`.
|
|
250
|
+
const root = (0, resolve_profiles_1.plainCopy)(parsedDoc);
|
|
251
|
+
const model = root.semantic_model[0];
|
|
252
|
+
const inputVersion = root.version;
|
|
253
|
+
const targetVersion = opts.flavor ?
|
|
254
|
+
(opts.flavor === 'google' ? loader_1.GOOGLE_VERSION : loader_1.OSSIE_VERSION) :
|
|
255
|
+
inputVersion;
|
|
256
|
+
// A model file is YAML, so a JSON file is written as YAML.
|
|
257
|
+
const fileName = opts.fileName ?? `${model.name}.yaml`;
|
|
258
|
+
const warnings = text === rawYaml ? [] : [`Converted '${fileName}' to YAML.`];
|
|
259
|
+
const errors = [];
|
|
260
|
+
return {
|
|
261
|
+
text,
|
|
262
|
+
document,
|
|
263
|
+
links: (0, yaml_update_1.linkNodes)(root, document.contents),
|
|
264
|
+
root,
|
|
265
|
+
model,
|
|
266
|
+
datasets: asRecords(model.entities ?? model.datasets),
|
|
267
|
+
relationships: asRecords(model.relationships),
|
|
268
|
+
metrics: asRecords(model.metrics),
|
|
269
|
+
inputVersion,
|
|
270
|
+
targetVersion,
|
|
271
|
+
sourcePrefix,
|
|
272
|
+
strict,
|
|
273
|
+
modified: false,
|
|
274
|
+
warnings,
|
|
275
|
+
errors,
|
|
276
|
+
warnOrError: (msg) => (strict ? errors : warnings).push(msg),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Extracts the profile name from a `<model>.profile.<name>.yaml` filename (or
|
|
281
|
+
* bare `<name>.yaml` / `<name>` identifier).
|
|
282
|
+
*/
|
|
283
|
+
function parseProfileName(fileName) {
|
|
284
|
+
const stem = fileName.replace(/^.*[/\\]/, '').replace(/\.ya?ml$/i, '');
|
|
285
|
+
const match = stem.match(/^.+?\.profile(?:\.(.*))?$/i);
|
|
286
|
+
return match ? (match[1] ?? '') : stem;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Extracts and parses all valid JSON object payloads from `GOOGLE`
|
|
290
|
+
* `custom_extensions` blocks on `exts`, leaving malformed JSON to be reported
|
|
291
|
+
* by `fromDocument`.
|
|
292
|
+
*/
|
|
293
|
+
function parseGoogleBlocks(exts) {
|
|
294
|
+
const blocks = [];
|
|
295
|
+
for (const b of asRecords(exts)) {
|
|
296
|
+
if (!(0, loader_1.isGoogleVendor)(b.vendor_name) || typeof b.data !== 'string')
|
|
297
|
+
continue;
|
|
298
|
+
try {
|
|
299
|
+
const parsed = JSON.parse(b.data);
|
|
300
|
+
if ((0, loader_1.isRecord)(parsed))
|
|
301
|
+
blocks.push(parsed);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Leave malformed JSON to be reported by `fromDocument`.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return blocks;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Validates all model, deployment, profile, entity/dataset, field,
|
|
311
|
+
* relationship, and metric names in `doc` (plus any `googleBlocks` and
|
|
312
|
+
* `profileFiles`) in a single pass so every invalid or over-length identifier
|
|
313
|
+
* is reported at once.
|
|
314
|
+
*/
|
|
315
|
+
function validateObjectNames(doc, googleBlocks, modelScope, profileFiles) {
|
|
316
|
+
// Validates a single identifier `name` against `kind`'s rules via
|
|
317
|
+
// `nameProblem` and appends any error to `doc.errors`.
|
|
318
|
+
const validateName = (kind, name, scope) => {
|
|
319
|
+
if (typeof name !== 'string')
|
|
320
|
+
return;
|
|
321
|
+
const problem = (0, names_1.nameProblem)(kind, name);
|
|
322
|
+
if (problem)
|
|
323
|
+
doc.errors.push(scope ? `${scope}: ${problem}` : problem);
|
|
324
|
+
};
|
|
325
|
+
// Validates the `name` property of every record in `records`.
|
|
326
|
+
const validateRecords = (kind, records, scope) => {
|
|
327
|
+
for (const record of records)
|
|
328
|
+
validateName(kind, record.name, scope);
|
|
329
|
+
};
|
|
330
|
+
// 1. Validate model, deployment, and profile names (inline, `GOOGLE` block,
|
|
331
|
+
// and sibling `profileFiles`).
|
|
332
|
+
validateName('model', doc.model.name);
|
|
333
|
+
validateRecords('deployment', asRecords(doc.model.deployments), modelScope);
|
|
334
|
+
validateRecords('profile', asRecords(doc.model.profiles), modelScope);
|
|
335
|
+
for (const g of googleBlocks) {
|
|
336
|
+
validateRecords('deployment', asRecords(g.deployments), modelScope);
|
|
337
|
+
validateRecords('profile', asRecords(g.profiles), modelScope);
|
|
338
|
+
}
|
|
339
|
+
if (doc.targetVersion === loader_1.OSSIE_VERSION) {
|
|
340
|
+
for (const pf of profileFiles ?? []) {
|
|
341
|
+
validateName('profile', parseProfileName(pf.fileName), modelScope);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// 2. Validate dataset/entity, field, relationship, and metric names.
|
|
345
|
+
for (const ds of doc.datasets) {
|
|
346
|
+
validateName('entity', ds.name);
|
|
347
|
+
const dsScope = typeof ds.name === 'string' ? `dataset '${ds.name}'` : 'dataset';
|
|
348
|
+
validateRecords('field', asRecords(ds.fields), dsScope);
|
|
349
|
+
}
|
|
350
|
+
validateRecords('relationship', doc.relationships);
|
|
351
|
+
validateRecords('metric', doc.metrics);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Phase 2 (Pre-validation): Validates all identifier names in the input
|
|
355
|
+
* document and any attached `profileFiles`. The loader rejects a model's
|
|
356
|
+
* legacy `deploymentTargets`.
|
|
357
|
+
*/
|
|
358
|
+
function validateInputDocument(doc, profileFiles) {
|
|
359
|
+
const modelScope = typeof doc.model.name === 'string' ?
|
|
360
|
+
`model '${doc.model.name}'` :
|
|
361
|
+
'model';
|
|
362
|
+
const googleBlocks = parseGoogleBlocks(doc.model.custom_extensions);
|
|
363
|
+
validateObjectNames(doc, googleBlocks, modelScope, profileFiles);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The parts of a bare dot-separated table name `source` as `prefix` reads
|
|
367
|
+
* them, each unquoted and marked when it was quoted, or `undefined` when
|
|
368
|
+
* `source` is not a bare name.
|
|
369
|
+
*/
|
|
370
|
+
function sourceParts(source, prefix) {
|
|
371
|
+
// Reject empty strings, already-slashed or colon-prefixed sources, and
|
|
372
|
+
// query-valued sources with unquoted whitespace.
|
|
373
|
+
const trimmed = source.trim();
|
|
374
|
+
if (!trimmed || trimmed.includes('/') || trimmed.includes(':') ||
|
|
375
|
+
(0, loader_1.hasUnquotedWhitespace)(trimmed)) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
const segs = prefixParts(trimmed, prefix.base);
|
|
379
|
+
return segs.length && segs.every(s => s.text.length) ? segs : undefined;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* `text` with its ASCII letters in lower case. PostgreSQL folds only these in a
|
|
383
|
+
* UTF-8 database.
|
|
384
|
+
*/
|
|
385
|
+
function lowerAscii(text) {
|
|
386
|
+
return text.replace(/[A-Z]/g, c => c.toLowerCase());
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* True when a prefix that starts with `base` names a database that reads
|
|
390
|
+
* unquoted names in lower case.
|
|
391
|
+
*/
|
|
392
|
+
function foldsUnquotedCase(base) {
|
|
393
|
+
return base.startsWith('alloydb.googleapis.com/');
|
|
394
|
+
}
|
|
395
|
+
/** `part` as the database of a prefix that starts with `base` reads it. */
|
|
396
|
+
function readPart(base, part) {
|
|
397
|
+
return foldsUnquotedCase(base) && !part.quoted ? lowerAscii(part.text) :
|
|
398
|
+
part.text;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The first unquoted part of `source` that PostgreSQL reads in lower case and
|
|
402
|
+
* so misses the name with capitals that `prefix` gives it, with the part's
|
|
403
|
+
* kind and both names. Expects a `source` that matches `prefix` when letter
|
|
404
|
+
* case is ignored.
|
|
405
|
+
*/
|
|
406
|
+
function foldedPart(source, prefix) {
|
|
407
|
+
const segs = sourceParts(source, prefix);
|
|
408
|
+
if (!segs || !foldsUnquotedCase(prefix.base))
|
|
409
|
+
return undefined;
|
|
410
|
+
const start = prefix.known.length - segs.length + (prefix.child ? 2 : 1);
|
|
411
|
+
for (let i = start; i < prefix.known.length; i++) {
|
|
412
|
+
const s = segs[i - start];
|
|
413
|
+
const read = lowerAscii(s.text);
|
|
414
|
+
// An AlloyDB prefix names a database, then perhaps a schema.
|
|
415
|
+
if (!s.quoted && read !== prefix.known[i]) {
|
|
416
|
+
return { kind: i ? 'schema' : 'database', name: prefix.known[i], read };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Qualifies a bare dot-separated table name `source` against `prefix` into a
|
|
423
|
+
* GCP resource name when `prefix` supplies the missing hierarchy segments and
|
|
424
|
+
* any overlapping segments agree, or returns `undefined` if `source` cannot be
|
|
425
|
+
* qualified. Against an AlloyDB prefix, an unquoted segment is read in lower
|
|
426
|
+
* case, as PostgreSQL reads it. With `ignoreCase`, every segment agrees in any
|
|
427
|
+
* letter case.
|
|
428
|
+
*/
|
|
429
|
+
function qualifyTableSource(source, prefix, ignoreCase = false) {
|
|
430
|
+
// Verify that any leading segments overlapping `prefix.known` match.
|
|
431
|
+
const segs = sourceParts(source, prefix);
|
|
432
|
+
if (!segs)
|
|
433
|
+
return undefined;
|
|
434
|
+
const needed = prefix.child ? 2 : 1;
|
|
435
|
+
const overlap = segs.length - needed;
|
|
436
|
+
if (overlap < 0 || overlap > prefix.known.length)
|
|
437
|
+
return undefined;
|
|
438
|
+
const read = (s) => readPart(prefix.base, s);
|
|
439
|
+
const agrees = (s, k) => read(s) === k || (ignoreCase && lowerAscii(s.text) === lowerAscii(k));
|
|
440
|
+
const tail = prefix.known.slice(prefix.known.length - overlap);
|
|
441
|
+
if (tail.some((k, i) => !agrees(segs[i], k)))
|
|
442
|
+
return undefined;
|
|
443
|
+
// Append the remaining child container (if any) and table segment onto
|
|
444
|
+
// `prefix.base` and verify the resulting resource name is accepted.
|
|
445
|
+
const rest = segs.slice(overlap).map(read);
|
|
446
|
+
const qualified = prefix.child ?
|
|
447
|
+
`${prefix.base}/${prefix.child}/${rest[0]}/tables/${rest[1]}` :
|
|
448
|
+
`${prefix.base}/tables/${rest[0]}`;
|
|
449
|
+
return (0, loader_1.oldSourceForm)(qualified) === undefined && (0, loader_1.isAcceptedSource)(qualified) ?
|
|
450
|
+
qualified :
|
|
451
|
+
undefined;
|
|
452
|
+
}
|
|
453
|
+
// The parts of the bare name `source` as a prefix that begins as `base` does
|
|
454
|
+
// reads them, each with whether quotes wrap it. A BigQuery or BigLake prefix
|
|
455
|
+
// reads a path in one pair of backticks, such as `` `p.d.t` ``, as its parts.
|
|
456
|
+
function prefixParts(source, base) {
|
|
457
|
+
return (0, loader_1.bareNameParts)(source.trim(), /^(bigquery|biglake)\.googleapis\.com\//.test(base));
|
|
458
|
+
}
|
|
459
|
+
// The text of each part prefixParts gives.
|
|
460
|
+
function prefixSegments(source, base) {
|
|
461
|
+
return prefixParts(source, base).map(p => p.text);
|
|
462
|
+
}
|
|
463
|
+
// The prefix of `kind` whose containers are the leading parts of a bare name
|
|
464
|
+
// with parts `parts`, or undefined when `kind` has no such prefix.
|
|
465
|
+
function prefixFor(kind, parts) {
|
|
466
|
+
const base = (0, loader_1.fillForm)(kind.form, parts)?.split('/').slice(0, kind.child ? -4 : -2)
|
|
467
|
+
.join('/');
|
|
468
|
+
const known = base?.match(kind.re)?.slice(1);
|
|
469
|
+
return base && known ? { base, known, child: kind.child } : undefined;
|
|
470
|
+
}
|
|
471
|
+
// True when a --source-prefix of one of `kinds` completes `source`. Each
|
|
472
|
+
// prefix tried is built from the parts as that kind reads them.
|
|
473
|
+
function completes(source, kinds) {
|
|
474
|
+
return kinds.some(kind => {
|
|
475
|
+
const parts = prefixParts(source, kind.form).map(p => readPart(kind.form, p));
|
|
476
|
+
const prefix = prefixFor(kind, parts);
|
|
477
|
+
return prefix !== undefined &&
|
|
478
|
+
qualifyTableSource(source, prefix) !== undefined;
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* When the output's flavor rejects `obj.datatype`, writes it in its canonical
|
|
483
|
+
* casing, such as `Integer` for `integer`, or deletes an `Opaque` in another
|
|
484
|
+
* casing. The change goes through `warnOrError` when the input's flavor
|
|
485
|
+
* rejects the value too.
|
|
486
|
+
*/
|
|
487
|
+
function normalizeDatatype(obj, label, doc) {
|
|
488
|
+
if (typeof obj.datatype !== 'string' ||
|
|
489
|
+
(0, ir_1.normalizeDataType)(obj.datatype, doc.targetVersion).ok) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const res = (0, ir_1.normalizeDataType)(obj.datatype, loader_1.GOOGLE_VERSION);
|
|
493
|
+
if (!res.ok)
|
|
494
|
+
return;
|
|
495
|
+
if (!(0, ir_1.normalizeDataType)(obj.datatype, doc.inputVersion).ok) {
|
|
496
|
+
doc.warnOrError(res.type === undefined ?
|
|
497
|
+
`${label}: removed datatype '${obj.datatype}'.` :
|
|
498
|
+
`${label}: rewrote datatype '${obj.datatype}' to '${res.type}'.`);
|
|
499
|
+
}
|
|
500
|
+
if (res.type === undefined) {
|
|
501
|
+
delete obj.datatype;
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
obj.datatype = res.type;
|
|
505
|
+
}
|
|
506
|
+
doc.modified = true;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Maps dialect aliases onto the 9 allowed SQL dialects and drops unsupported
|
|
510
|
+
* non-SQL dialects on a field or metric, returning `'empty'` if all dialect
|
|
511
|
+
* entries were dropped.
|
|
512
|
+
*/
|
|
513
|
+
function normalizeExpressionDialects(obj, label, doc) {
|
|
514
|
+
if (!(0, loader_1.isRecord)(obj.expression) || !Array.isArray(obj.expression.dialects) ||
|
|
515
|
+
obj.expression.dialects.length === 0) {
|
|
516
|
+
return 'ok';
|
|
517
|
+
}
|
|
518
|
+
const kept = [];
|
|
519
|
+
for (const entry of obj.expression.dialects) {
|
|
520
|
+
if (!(0, loader_1.isRecord)(entry) || typeof entry.dialect !== 'string') {
|
|
521
|
+
kept.push(entry);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const upper = entry.dialect.trim().toUpperCase();
|
|
525
|
+
const mapped = DIALECT_ALIASES[upper] ??
|
|
526
|
+
(ALLOWED_DIALECT_SET.has(upper) ? upper : undefined);
|
|
527
|
+
if (mapped !== undefined) {
|
|
528
|
+
if (entry.dialect !== mapped) {
|
|
529
|
+
doc.warnOrError(`${label}: rewrote dialect '${entry.dialect}' to '${mapped}'.`);
|
|
530
|
+
entry.dialect = mapped;
|
|
531
|
+
doc.modified = true;
|
|
532
|
+
}
|
|
533
|
+
kept.push(entry);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
doc.warnOrError(`${label}: dropped unsupported dialect '${entry.dialect}'.`);
|
|
537
|
+
doc.modified = true;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
obj.expression.dialects = kept;
|
|
541
|
+
return kept.length === 0 ? 'empty' : 'ok';
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Returns why an unanchored metric `mt` is rejected by push's
|
|
545
|
+
* `validateExpressionReferences` (`'reaches no declared entity'` or
|
|
546
|
+
* `'reaches multiple entities (...)'`), or `undefined` when anchored or valid.
|
|
547
|
+
*/
|
|
548
|
+
function unanchoredMetricProblem(mt, entityNames, impliedDialect) {
|
|
549
|
+
// 1. Skip metrics that already declare an `entity` anchor natively or inside
|
|
550
|
+
// a `GOOGLE` `custom_extensions` block.
|
|
551
|
+
if (mt.entity !== undefined ||
|
|
552
|
+
(0, loader_1.hasGoogleBlock)(asRecords(mt.custom_extensions))) {
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
// 2. Collect all SQL dialect expressions on the metric; if none are present
|
|
556
|
+
// or any entry holds no SQL, leave the metric for `loadModels` to diagnose.
|
|
557
|
+
const sources = typeof mt.expression === 'string' ?
|
|
558
|
+
[{ text: mt.expression, dialect: 'ANSI_SQL' }] :
|
|
559
|
+
asRecords(mt.expression?.dialects)
|
|
560
|
+
.filter(d => typeof d.dialect === 'string' &&
|
|
561
|
+
typeof d.expression === 'string')
|
|
562
|
+
.map(d => ({ text: d.expression, dialect: d.dialect }));
|
|
563
|
+
if (!sources.length ||
|
|
564
|
+
sources.some(s => (0, loader_1.noSqlReason)(s.text, s.dialect) !== undefined)) {
|
|
565
|
+
return undefined;
|
|
566
|
+
}
|
|
567
|
+
const parsed = sources.map(s => (0, sql_expr_utils_1.columnsForSource)(s.text, s.dialect, impliedDialect));
|
|
568
|
+
const allColumns = parsed.flatMap(c => c ?? []);
|
|
569
|
+
// 3. Flag the unanchored metric if its parsed expressions reference zero
|
|
570
|
+
// columns (e.g. `COUNT(*)`) or span multiple declared entities.
|
|
571
|
+
const reached = (0, sql_expr_utils_1.entitiesReached)(allColumns, entityNames);
|
|
572
|
+
if (parsed.every(c => c !== undefined) && allColumns.length === 0) {
|
|
573
|
+
return 'reaches no declared entity';
|
|
574
|
+
}
|
|
575
|
+
if (reached.length > 1) {
|
|
576
|
+
return `reaches multiple entities (${reached.join(', ')})`;
|
|
577
|
+
}
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Truncates `obj.description`, without its surrounding whitespace, to at most
|
|
582
|
+
* `MAX_ENTRY_DESCRIPTION` characters when it exceeds the limit, and records a
|
|
583
|
+
* warning (or error in `strict` mode).
|
|
584
|
+
*/
|
|
585
|
+
function truncateDescription(obj, label, doc) {
|
|
586
|
+
// Push counts the description without its surrounding whitespace.
|
|
587
|
+
const text = typeof obj.description === 'string' ? obj.description.trim() : '';
|
|
588
|
+
if (text.length > names_1.MAX_ENTRY_DESCRIPTION) {
|
|
589
|
+
// The cut never ends on the first half of a surrogate pair.
|
|
590
|
+
const cut = text.slice(0, names_1.MAX_ENTRY_DESCRIPTION).replace(/[\uD800-\uDBFF]$/, '');
|
|
591
|
+
doc.warnOrError(`${label}: truncated 'description' from ${text.length} ` +
|
|
592
|
+
`to ${cut.length} characters.`);
|
|
593
|
+
obj.description = cut;
|
|
594
|
+
doc.modified = true;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Normalizes a field's datatype and SQL dialect list, removing `f.expression`
|
|
599
|
+
* with a warning in the Google version (or recording an error in the vanilla
|
|
600
|
+
* version) if no supported SQL dialect remains.
|
|
601
|
+
*/
|
|
602
|
+
function repairField(f, dsName, doc) {
|
|
603
|
+
const fLabel = `field '${dsName}.${f.name}'`;
|
|
604
|
+
normalizeDatatype(f, fLabel, doc);
|
|
605
|
+
if (normalizeExpressionDialects(f, fLabel, doc) === 'empty') {
|
|
606
|
+
if (doc.targetVersion === loader_1.OSSIE_VERSION) {
|
|
607
|
+
doc.errors.push(`${fLabel}: no supported SQL dialect remains in 'expression', ` +
|
|
608
|
+
`and version '${loader_1.OSSIE_VERSION}' requires an 'expression' on ` +
|
|
609
|
+
`every field.`);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
delete f.expression;
|
|
613
|
+
doc.modified = true;
|
|
614
|
+
doc.warnOrError(`${fLabel}: no supported SQL dialect remains; removed ` +
|
|
615
|
+
`'expression' and left the field unbound.`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Repairs a dataset's `source` (removing query-valued sources, qualifying bare
|
|
621
|
+
* table names against `doc.sourcePrefix`, and warning on unaccepted sources),
|
|
622
|
+
* truncates its `description`, and repairs each of its `fields`.
|
|
623
|
+
*/
|
|
624
|
+
function repairDataset(ds, doc) {
|
|
625
|
+
// 1. Repair or diagnose `ds.source`: remove query-valued sources (in the
|
|
626
|
+
// Google version), qualify bare table names against `doc.sourcePrefix`, or
|
|
627
|
+
// warn on unaccepted source formats.
|
|
628
|
+
const dsLabel = `dataset '${ds.name}'`;
|
|
629
|
+
if (typeof ds.source === 'string') {
|
|
630
|
+
const trimmed = ds.source.trim();
|
|
631
|
+
const qualified = doc.sourcePrefix ?
|
|
632
|
+
qualifyTableSource(trimmed, doc.sourcePrefix) :
|
|
633
|
+
undefined;
|
|
634
|
+
if ((0, loader_1.hasUnquotedWhitespace)(trimmed)) {
|
|
635
|
+
if (doc.targetVersion === loader_1.OSSIE_VERSION) {
|
|
636
|
+
doc.errors.push(`${dsLabel}: query-valued source '${trimmed}' is not supported, ` +
|
|
637
|
+
`and version '${loader_1.OSSIE_VERSION}' requires a table 'source' on ` +
|
|
638
|
+
`every dataset.`);
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
doc.warnOrError(`${dsLabel}: removed query-valued source '${trimmed}'; ` +
|
|
642
|
+
`dataset is now unbound.`);
|
|
643
|
+
delete ds.source;
|
|
644
|
+
doc.modified = true;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
else if (qualified !== undefined) {
|
|
648
|
+
// The author asked for this rewrite with --source-prefix, so it is a
|
|
649
|
+
// warning in strict mode too.
|
|
650
|
+
doc.warnings.push(`${dsLabel}: rewrote bare source '${trimmed}' to '${qualified}'.`);
|
|
651
|
+
ds.source = qualified;
|
|
652
|
+
doc.modified = true;
|
|
653
|
+
}
|
|
654
|
+
else if (!((0, loader_1.oldSourceForm)(trimmed) === undefined &&
|
|
655
|
+
(0, loader_1.isAcceptedSource)(trimmed))) {
|
|
656
|
+
// Why a prefix that nearly fits the name cannot complete it.
|
|
657
|
+
const p = doc.sourcePrefix;
|
|
658
|
+
const caseOnly = !!p && qualifyTableSource(trimmed, p, true) !== undefined;
|
|
659
|
+
const folded = p && caseOnly ? foldedPart(trimmed, p) : undefined;
|
|
660
|
+
let why;
|
|
661
|
+
if (folded) {
|
|
662
|
+
const { kind, name, read } = folded;
|
|
663
|
+
why = `names ${kind} '${read}', since PostgreSQL reads an unquoted ` +
|
|
664
|
+
`name in lower case. '--source-prefix' names ${kind} '${name}', ` +
|
|
665
|
+
`so import cannot complete the source. If the ${kind} is ` +
|
|
666
|
+
`'${name}', write it as '"${name}"' in the source; if it is ` +
|
|
667
|
+
`'${read}', write '${read}' in '--source-prefix'.`;
|
|
668
|
+
}
|
|
669
|
+
else if (caseOnly) {
|
|
670
|
+
why = `differs from '--source-prefix' only in letter case, so ` +
|
|
671
|
+
`import cannot complete it; correct the letter case of the ` +
|
|
672
|
+
`source or of '--source-prefix'.`;
|
|
673
|
+
}
|
|
674
|
+
else if (p?.child && sourceParts(trimmed, p)?.length === 1 &&
|
|
675
|
+
completes(trimmed, prefixKinds(p))) {
|
|
676
|
+
// The container it lacks: a dataset, namespace or schema.
|
|
677
|
+
const lacks = p.child.slice(0, -1);
|
|
678
|
+
why = `names no ${lacks}, and neither does '--source-prefix', so ` +
|
|
679
|
+
`import cannot complete it; add the ${lacks} to the source or to ` +
|
|
680
|
+
`'--source-prefix'.`;
|
|
681
|
+
}
|
|
682
|
+
doc.warnOrError(why ? `${dsLabel}: source '${trimmed}' ${why}` :
|
|
683
|
+
sourceWarning(dsLabel, trimmed, doc.sourcePrefix));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// 2. Cap the dataset's `description` at 2,000 characters and repair each of
|
|
687
|
+
// its `fields`.
|
|
688
|
+
truncateDescription(ds, dsLabel, doc);
|
|
689
|
+
for (const f of asRecords(ds.fields)) {
|
|
690
|
+
repairField(f, String(ds.name ?? ''), doc);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Repairs a metric's datatype, SQL dialect list, and description, returning
|
|
695
|
+
* `false` when the metric is unanchored and reaches 0 or >1 entities so the
|
|
696
|
+
* caller prunes it from `doc.metrics`.
|
|
697
|
+
*/
|
|
698
|
+
function repairMetric(mt, entityNames, impliedDialect, doc) {
|
|
699
|
+
const mtLabel = `metric '${mt.name}'`;
|
|
700
|
+
normalizeDatatype(mt, mtLabel, doc);
|
|
701
|
+
if (normalizeExpressionDialects(mt, mtLabel, doc) === 'empty') {
|
|
702
|
+
doc.errors.push(`${mtLabel}: no supported SQL dialect remains in 'expression'.`);
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
const problem = unanchoredMetricProblem(mt, entityNames, impliedDialect);
|
|
706
|
+
if (problem) {
|
|
707
|
+
// In strict mode nothing is written, so the author edits the input.
|
|
708
|
+
const flavor = doc.strict ? doc.inputVersion : doc.targetVersion;
|
|
709
|
+
const how = flavor === loader_1.OSSIE_VERSION ?
|
|
710
|
+
`with 'entity' in a ${loader_1.GOOGLE_VENDOR} custom_extensions block on the ` +
|
|
711
|
+
`metric (or use version '${loader_1.GOOGLE_VERSION}' and set 'entity' on ` +
|
|
712
|
+
`the metric)` :
|
|
713
|
+
`with an 'entity' anchor`;
|
|
714
|
+
doc.warnOrError(`${mtLabel}: removed unanchored metric whose expression ` +
|
|
715
|
+
`${problem}; add the metric back ${how}.`);
|
|
716
|
+
doc.modified = true;
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
truncateDescription(mt, mtLabel, doc);
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Phase 3 (Repair): Repairs third-party constructs in-place on `doc` (pruning
|
|
724
|
+
* extra models, repairing datasets and their fields, pruning unanchored
|
|
725
|
+
* metrics, and truncating descriptions > 2,000 chars).
|
|
726
|
+
*/
|
|
727
|
+
function repairDocument(doc) {
|
|
728
|
+
// 1. Prune extra models after `semantic_model[0]` because `kcmd` allows only
|
|
729
|
+
// a single model per document, and cap the model's `description` at 2,000
|
|
730
|
+
// characters (`MAX_ENTRY_DESCRIPTION`).
|
|
731
|
+
if (doc.root.semantic_model.length > 1) {
|
|
732
|
+
for (const extra of doc.root.semantic_model.slice(1)) {
|
|
733
|
+
const name = (0, loader_1.isRecord)(extra) && typeof extra.name === 'string' ? extra.name : '';
|
|
734
|
+
doc.warnOrError(`Removed extra model${name ? ` '${name}'` : ''} from ` +
|
|
735
|
+
`'semantic_model'; the format allows one model per document.`);
|
|
736
|
+
}
|
|
737
|
+
doc.root.semantic_model = [doc.root.semantic_model[0]];
|
|
738
|
+
doc.modified = true;
|
|
739
|
+
}
|
|
740
|
+
truncateDescription(doc.model, `model '${doc.model.name}'`, doc);
|
|
741
|
+
// 2. Repair each dataset (its table `source`, `description`, and `fields`).
|
|
742
|
+
for (const ds of doc.datasets) {
|
|
743
|
+
repairDataset(ds, doc);
|
|
744
|
+
}
|
|
745
|
+
// 3. Infer the model's source dialect from its repaired datasets via
|
|
746
|
+
// `sourcesDialect`, and repair or prune each metric (`repairMetric`).
|
|
747
|
+
const entityNames = doc.datasets.map(ds => ds.name)
|
|
748
|
+
.filter((n) => typeof n === 'string');
|
|
749
|
+
const impliedDialect = (0, resolve_profiles_1.sourcesDialect)({
|
|
750
|
+
entities: doc.datasets.map(ds => ({
|
|
751
|
+
abstract: Boolean(ds.abstract),
|
|
752
|
+
authoredSource: typeof ds.source === 'string' ? ds.source : undefined,
|
|
753
|
+
dataSource: '',
|
|
754
|
+
})),
|
|
755
|
+
});
|
|
756
|
+
doc.metrics = doc.metrics.filter(mt => repairMetric(mt, entityNames, impliedDialect, doc));
|
|
757
|
+
if (Array.isArray(doc.model.metrics) && doc.model.metrics.length) {
|
|
758
|
+
doc.model.metrics = doc.metrics.length ? doc.metrics : undefined;
|
|
759
|
+
}
|
|
760
|
+
// 4. Cap each relationship's `description`. A `GOOGLE` block comes only in
|
|
761
|
+
// strict mode, which writes nothing, so its description is only reported.
|
|
762
|
+
for (const r of doc.relationships) {
|
|
763
|
+
const rLabel = `relationship '${r.name}'`;
|
|
764
|
+
truncateDescription(r, rLabel, doc);
|
|
765
|
+
for (const b of parseGoogleBlocks(r.custom_extensions)) {
|
|
766
|
+
truncateDescription(b, rLabel, doc);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Sets `version` to `doc.targetVersion`, and names the model's entity list as
|
|
772
|
+
* that flavor does: `entities:` in the Google flavor, `datasets:` in vanilla.
|
|
773
|
+
* The renamed key keeps its place among the model's keys.
|
|
774
|
+
*/
|
|
775
|
+
function setTargetVersion(doc) {
|
|
776
|
+
const [from, to] = doc.targetVersion === loader_1.GOOGLE_VERSION ?
|
|
777
|
+
['datasets', 'entities'] :
|
|
778
|
+
['entities', 'datasets'];
|
|
779
|
+
doc.root.version = doc.targetVersion;
|
|
780
|
+
doc.modified = true;
|
|
781
|
+
if (doc.model[from] !== undefined && doc.model[to] === undefined) {
|
|
782
|
+
const entries = Object.entries(doc.model);
|
|
783
|
+
for (const k of Object.keys(doc.model))
|
|
784
|
+
delete doc.model[k];
|
|
785
|
+
for (const [k, v] of entries)
|
|
786
|
+
doc.model[k === from ? to : k] = v;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Converts a plain vanilla (`0.2.0.dev0`) document to the Google version
|
|
791
|
+
* (`0.2.0.dev0/google`) by updating `version`, renaming `datasets:` to
|
|
792
|
+
* `entities:`, and moving custom `ai_context` sibling properties under
|
|
793
|
+
* `ai_context.custom`.
|
|
794
|
+
*/
|
|
795
|
+
function adaptToGoogleVersion(doc) {
|
|
796
|
+
setTargetVersion(doc);
|
|
797
|
+
// Move any non-standard sibling keys on `ai_context` objects (at model,
|
|
798
|
+
// entity, field, relationship, or metric scope) under `ai_context.custom`,
|
|
799
|
+
// since `0.2.0.dev0/google` uses strict schema validation on `ai_context`.
|
|
800
|
+
for (const { ai } of (0, loader_1.modelAiContexts)(doc.root)) {
|
|
801
|
+
adaptAiContext(ai);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Adapts `doc` to the vanilla (`0.2.0.dev0`) version: converts a Google file's
|
|
806
|
+
* version, entity list and custom `ai_context` members, wraps short-form
|
|
807
|
+
* string expressions into a single-entry `ANSI_SQL` dialect list, and moves
|
|
808
|
+
* plain relationship `description`s into a `GOOGLE` `custom_extensions` block.
|
|
809
|
+
* The vanilla flavor rejects the last two forms, so in a vanilla file each of
|
|
810
|
+
* those changes is a repair and goes through `warnOrError`.
|
|
811
|
+
*/
|
|
812
|
+
function adaptToOssieVersion(doc) {
|
|
813
|
+
// 0. A Google file takes the vanilla version and entity list, and each
|
|
814
|
+
// member of an `ai_context`'s `custom` mapping goes beside the standard
|
|
815
|
+
// members, where `custom` was. An `ai_context` that held only an empty
|
|
816
|
+
// `custom` is dropped.
|
|
817
|
+
if (doc.inputVersion === loader_1.GOOGLE_VERSION) {
|
|
818
|
+
setTargetVersion(doc);
|
|
819
|
+
for (const owner of [
|
|
820
|
+
doc.model, ...doc.datasets,
|
|
821
|
+
...doc.datasets.flatMap(ds => asRecords(ds.fields)),
|
|
822
|
+
...doc.relationships, ...doc.metrics
|
|
823
|
+
]) {
|
|
824
|
+
const ai = owner.ai_context;
|
|
825
|
+
if (!(0, loader_1.isRecord)(ai) || !(0, loader_1.isRecord)(ai.custom))
|
|
826
|
+
continue;
|
|
827
|
+
const entries = Object.entries(ai);
|
|
828
|
+
for (const k of Object.keys(ai))
|
|
829
|
+
delete ai[k];
|
|
830
|
+
for (const [k, v] of entries) {
|
|
831
|
+
Object.assign(ai, k === 'custom' ? v : { [k]: v });
|
|
832
|
+
}
|
|
833
|
+
if (Object.keys(ai).length === 0)
|
|
834
|
+
delete owner.ai_context;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// 1. Wrap any short-form string `expression: "<sql>"` on a field or metric
|
|
838
|
+
// into `{dialects: [{dialect: 'ANSI_SQL', expression: "<sql>"}]}`, since
|
|
839
|
+
// `0.2.0.dev0` requires the `expression.dialects` object form.
|
|
840
|
+
const wrapShortForm = (obj, label) => {
|
|
841
|
+
if (typeof obj.expression !== 'string')
|
|
842
|
+
return;
|
|
843
|
+
obj.expression = {
|
|
844
|
+
dialects: [{ dialect: 'ANSI_SQL', expression: obj.expression }],
|
|
845
|
+
};
|
|
846
|
+
doc.modified = true;
|
|
847
|
+
if (doc.inputVersion === loader_1.OSSIE_VERSION) {
|
|
848
|
+
doc.warnOrError(`${label}: wrote the string expression as an 'ANSI_SQL' ` +
|
|
849
|
+
`dialects entry.`);
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
for (const ds of doc.datasets) {
|
|
853
|
+
for (const f of asRecords(ds.fields)) {
|
|
854
|
+
wrapShortForm(f, `field '${ds.name}.${f.name}'`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
for (const mt of doc.metrics)
|
|
858
|
+
wrapShortForm(mt, `metric '${mt.name}'`);
|
|
859
|
+
// 2. Relocate any top-level `description` on a relationship into a `GOOGLE`
|
|
860
|
+
// `custom_extensions` block. The vanilla `0.2.0.dev0` schema does not allow
|
|
861
|
+
// `description` directly on a relationship, but `kcmd` reads and preserves
|
|
862
|
+
// relationship descriptions from a `{vendor_name: 'GOOGLE', data: ...}`
|
|
863
|
+
// extension entry (`FORMAT_VERSION_PLACEMENT['0.2.0.dev0'].relationship`).
|
|
864
|
+
// A conversion from Google has moved them already (`moveConstructs`).
|
|
865
|
+
for (const r of doc.relationships) {
|
|
866
|
+
if (typeof r.description !== 'string')
|
|
867
|
+
continue;
|
|
868
|
+
putGoogleBlock(r, { description: r.description });
|
|
869
|
+
delete r.description;
|
|
870
|
+
doc.modified = true;
|
|
871
|
+
doc.warnOrError(`relationship '${r.name}': moved 'description' into a ` +
|
|
872
|
+
`${loader_1.GOOGLE_VENDOR} custom_extensions block for '${loader_1.OSSIE_VERSION}'.`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
// Where the Google flavor writes a key that a GOOGLE block carries in vanilla:
|
|
876
|
+
// right after the last of these keys the object has, as `kcmd pull` does.
|
|
877
|
+
const NATIVE_KEY_AFTER = {
|
|
878
|
+
deployments: ['name', 'description', 'ai_context'],
|
|
879
|
+
extends: ['name', 'source'],
|
|
880
|
+
description: ['name', 'from', 'to', 'from_columns', 'to_columns'],
|
|
881
|
+
entity: ['name'],
|
|
882
|
+
};
|
|
883
|
+
/**
|
|
884
|
+
* Sets `obj[key]` to `value` right after the last of `after` that `obj` has,
|
|
885
|
+
* or first. `obj` stays the same object, so the writer keeps its text.
|
|
886
|
+
*/
|
|
887
|
+
function setAfter(obj, after, key, value) {
|
|
888
|
+
const entries = Object.entries(obj);
|
|
889
|
+
const at = Math.max(-1, ...after.map(a => entries.findIndex(([k]) => k === a)));
|
|
890
|
+
entries.splice(at + 1, 0, [key, value]);
|
|
891
|
+
for (const k of Object.keys(obj))
|
|
892
|
+
delete obj[k];
|
|
893
|
+
for (const [k, v] of entries)
|
|
894
|
+
obj[k] = v;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Removes the `GOOGLE` blocks from `obj.custom_extensions`, and the list when
|
|
898
|
+
* no block is left in it, and returns the values the blocks carry.
|
|
899
|
+
*/
|
|
900
|
+
function takeGoogleBlocks(obj) {
|
|
901
|
+
const values = Object.assign({}, ...parseGoogleBlocks(obj.custom_extensions));
|
|
902
|
+
if (!(0, loader_1.hasGoogleBlock)(asRecords(obj.custom_extensions)))
|
|
903
|
+
return values;
|
|
904
|
+
const kept = obj.custom_extensions.filter((b) => !(0, loader_1.isRecord)(b) || !(0, loader_1.isGoogleVendor)(b.vendor_name));
|
|
905
|
+
if (kept.length) {
|
|
906
|
+
obj.custom_extensions = kept;
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
delete obj.custom_extensions;
|
|
910
|
+
}
|
|
911
|
+
return values;
|
|
912
|
+
}
|
|
913
|
+
/** True for an empty string, list or mapping. */
|
|
914
|
+
function isEmpty(value) {
|
|
915
|
+
return value === '' ||
|
|
916
|
+
(((0, loader_1.isRecord)(value) || Array.isArray(value)) &&
|
|
917
|
+
Object.keys(value).length === 0);
|
|
918
|
+
}
|
|
919
|
+
/** Appends a `GOOGLE` block that carries `values` to `obj.custom_extensions`. */
|
|
920
|
+
function putGoogleBlock(obj, values) {
|
|
921
|
+
if (Object.keys(values).length === 0)
|
|
922
|
+
return;
|
|
923
|
+
obj.custom_extensions = [
|
|
924
|
+
...(Array.isArray(obj.custom_extensions) ? obj.custom_extensions : []),
|
|
925
|
+
{ vendor_name: loader_1.GOOGLE_VENDOR, data: JSON.stringify(values) },
|
|
926
|
+
];
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Moves each construct that `doc`'s flavor and its target flavor place
|
|
930
|
+
* differently (`FORMAT_VERSION_PLACEMENT`) between a native key, a `GOOGLE`
|
|
931
|
+
* block and the profile files. A key the target flavor has no place for is
|
|
932
|
+
* dropped, and so is an empty value bound for a `GOOGLE` block. Returns the
|
|
933
|
+
* profile files the Google flavor writes.
|
|
934
|
+
*/
|
|
935
|
+
function moveConstructs(doc, profileFiles) {
|
|
936
|
+
const levels = {
|
|
937
|
+
model: [doc.model],
|
|
938
|
+
dataset: doc.datasets,
|
|
939
|
+
relationship: doc.relationships,
|
|
940
|
+
metric: doc.metrics,
|
|
941
|
+
};
|
|
942
|
+
let files = [];
|
|
943
|
+
for (const [level, objects] of Object.entries(levels)) {
|
|
944
|
+
const placement = (version) => loader_1.FORMAT_VERSION_PLACEMENT[version][level];
|
|
945
|
+
const was = placement(doc.inputVersion);
|
|
946
|
+
for (const obj of objects) {
|
|
947
|
+
const block = takeGoogleBlocks(obj);
|
|
948
|
+
const moved = {};
|
|
949
|
+
for (const [key, place] of Object.entries(placement(doc.targetVersion))) {
|
|
950
|
+
// A key the input's flavor has no place for stays as written.
|
|
951
|
+
if (was[key] === place || was[key] === 'none')
|
|
952
|
+
continue;
|
|
953
|
+
const value = was[key] === 'block' ? block[key] :
|
|
954
|
+
was[key] === 'file' ?
|
|
955
|
+
profileFiles.map(pf => yaml.parse(pf.content, yaml_options_1.YAML_OPTIONS)) :
|
|
956
|
+
obj[key];
|
|
957
|
+
// A third-party file can write natively a key its flavor keeps in a
|
|
958
|
+
// GOOGLE block. That key stays as written.
|
|
959
|
+
if (was[key] === 'native')
|
|
960
|
+
delete obj[key];
|
|
961
|
+
if (value === undefined)
|
|
962
|
+
continue;
|
|
963
|
+
if (place === 'native') {
|
|
964
|
+
setAfter(obj, NATIVE_KEY_AFTER[key], key, value);
|
|
965
|
+
}
|
|
966
|
+
else if (place === 'file') {
|
|
967
|
+
files = asRecords(value).map(p => ({
|
|
968
|
+
fileName: `${doc.model.name}.profile.${p.name}.yaml`,
|
|
969
|
+
content: yaml.stringify(p, {
|
|
970
|
+
aliasDuplicateObjects: false,
|
|
971
|
+
compat: 'yaml-1.1',
|
|
972
|
+
lineWidth: 0,
|
|
973
|
+
}),
|
|
974
|
+
}));
|
|
975
|
+
}
|
|
976
|
+
else if (place === 'block' && !isEmpty(value)) {
|
|
977
|
+
moved[key] = value;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
putGoogleBlock(obj, moved);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return files;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Loads `doc.root` into the `SemanticModel` IR, runs `transpileModel` to
|
|
987
|
+
* translate vendor or double-quoted `ANSI_SQL` expressions to `BIGQUERY`, and
|
|
988
|
+
* writes any newly added `BIGQUERY` dialect entries back onto `doc`.
|
|
989
|
+
*/
|
|
990
|
+
async function transpileDocument(doc) {
|
|
991
|
+
let preLoaded;
|
|
992
|
+
try {
|
|
993
|
+
preLoaded = (0, loader_1.fromDocument)(doc.root, { bindingOptional: true, warnUnacceptedSource: true });
|
|
994
|
+
}
|
|
995
|
+
catch (err) {
|
|
996
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
997
|
+
throw err;
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
// Writes `irDialects` back onto `rawNode.expression` when `transpileModel`
|
|
1001
|
+
// appended a new dialect entry (e.g. `BIGQUERY`).
|
|
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
|
+
};
|
|
1010
|
+
doc.modified = true;
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
for (const m of preLoaded.models) {
|
|
1014
|
+
const transpiled = await (0, transpile_1.transpileModel)(m);
|
|
1015
|
+
doc.warnings.push(...transpiled.warnings);
|
|
1016
|
+
for (const ds of doc.datasets) {
|
|
1017
|
+
const irEntity = transpiled.model.entities.find(e => e.name === ds.name);
|
|
1018
|
+
for (const f of asRecords(ds.fields)) {
|
|
1019
|
+
syncNode(f, irEntity?.fields.find(x => x.name === f.name)?.dialects);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
for (const mt of doc.metrics) {
|
|
1023
|
+
syncNode(mt, transpiled.model.metrics.find(x => x.name === mt.name)?.dialects);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Validates every deployment target on `model` using `googleDeploymentTargets`
|
|
1029
|
+
* and `targetProblem` so invalid targets fail import with `kcmd push`'s exact
|
|
1030
|
+
* message.
|
|
1031
|
+
*/
|
|
1032
|
+
function validateDeploymentTargets(model, doc) {
|
|
1033
|
+
for (const t of (0, deployment_target_1.googleDeploymentTargets)(model).uris) {
|
|
1034
|
+
if ((0, deployment_target_1.targetProblem)('', t)) {
|
|
1035
|
+
const d = model.deployments?.find(x => x.target === t);
|
|
1036
|
+
doc.errors.push(d ? (0, deployment_target_1.deploymentTargetError)(model, d) :
|
|
1037
|
+
(0, deployment_target_1.googleBlockTargetError)(model, t));
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Loads each sibling `<model>.profile.<name>.yaml` entry in `profileFiles` via
|
|
1043
|
+
* `loadProfileFile` and attaches the resulting `ProfileSpec` list to
|
|
1044
|
+
* `model.profiles`, recording any reserved or duplicate profile name or
|
|
1045
|
+
* profile load error in `doc.errors`.
|
|
1046
|
+
*/
|
|
1047
|
+
function attachProfileFiles(model, profileFiles, doc) {
|
|
1048
|
+
if (!profileFiles?.length)
|
|
1049
|
+
return;
|
|
1050
|
+
const profiles = [];
|
|
1051
|
+
const seen = new Set();
|
|
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);
|
|
1069
|
+
try {
|
|
1070
|
+
profiles.push((0, resolve_profiles_1.loadProfileFile)(pf.content, profileName));
|
|
1071
|
+
}
|
|
1072
|
+
catch (err) {
|
|
1073
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
1074
|
+
throw err;
|
|
1075
|
+
doc.errors.push(err.message);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (profiles.length > 0) {
|
|
1079
|
+
model.profiles = profiles;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Verifies that `model` can be converted to the vanilla (`0.2.0.dev0`) version:
|
|
1084
|
+
* it must declare no `actions`, no `constraints`, and no `abstract` entities,
|
|
1085
|
+
* and every entity must have a `source`, every field an `expression`, and
|
|
1086
|
+
* every relationship its join columns (`from_columns` and `to_columns`).
|
|
1087
|
+
* Appends every gap to `doc.errors`.
|
|
1088
|
+
*/
|
|
1089
|
+
function validateOssieConversion(model, doc) {
|
|
1090
|
+
const prefix = `Cannot convert '${model.name}' to version '${loader_1.OSSIE_VERSION}'`;
|
|
1091
|
+
// 1. Reject actions and constraints, which have no representation in
|
|
1092
|
+
// `0.2.0.dev0`.
|
|
1093
|
+
for (const a of model.actions ?? []) {
|
|
1094
|
+
doc.errors.push(`${prefix}: action '${a.name}' is not supported in version ` +
|
|
1095
|
+
`'${loader_1.OSSIE_VERSION}'.`);
|
|
1096
|
+
}
|
|
1097
|
+
for (const c of model.constraints ?? []) {
|
|
1098
|
+
doc.errors.push(`${prefix}: constraint '${c.name}' is not supported in version ` +
|
|
1099
|
+
`'${loader_1.OSSIE_VERSION}'.`);
|
|
1100
|
+
}
|
|
1101
|
+
// 2. Reject abstract entities and require every concrete entity and field to
|
|
1102
|
+
// be bound.
|
|
1103
|
+
for (const e of model.entities) {
|
|
1104
|
+
if (e.abstract) {
|
|
1105
|
+
doc.errors.push(`${prefix}: entity '${e.name}' is 'abstract', which version ` +
|
|
1106
|
+
`'${loader_1.OSSIE_VERSION}' does not support.`);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
if (!e.authoredSource && !e.dataSource) {
|
|
1110
|
+
doc.errors.push(`${prefix}: entity '${e.name}' has no 'source'.`);
|
|
1111
|
+
}
|
|
1112
|
+
for (const f of e.fields) {
|
|
1113
|
+
if (!(0, ir_1.isFieldBound)(f)) {
|
|
1114
|
+
doc.errors.push(`${prefix}: field '${e.name}.${f.name}' has no 'expression'.`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// 3. Require every relationship to declare its join columns.
|
|
1119
|
+
for (const r of model.relationships) {
|
|
1120
|
+
if (!r.source.columns.length || !r.destination.columns.length) {
|
|
1121
|
+
doc.errors.push(`${prefix}: relationship '${r.name}' has no join columns ` +
|
|
1122
|
+
`('from_columns' and 'to_columns').`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Loads `doc.root` into the `SemanticModel` IR for cross-version conversion,
|
|
1128
|
+
* recording any `SemanticModelError` on `doc.errors`.
|
|
1129
|
+
*/
|
|
1130
|
+
function loadSemanticModel(doc) {
|
|
1131
|
+
try {
|
|
1132
|
+
const loaded = (0, loader_1.fromDocument)(doc.root, { bindingOptional: true, warnUnacceptedSource: true });
|
|
1133
|
+
return loaded.models[0];
|
|
1134
|
+
}
|
|
1135
|
+
catch (err) {
|
|
1136
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
1137
|
+
throw err;
|
|
1138
|
+
doc.errors.push(err.message);
|
|
1139
|
+
return undefined;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Phase 4 (Convert): Checks with the `SemanticModel` IR that `doc` converts to
|
|
1144
|
+
* its target version (`0.2.0.dev0` <-> `0.2.0.dev0/google`), attaching profile
|
|
1145
|
+
* files and checking conversion gaps when targeting `0.2.0.dev0`, then edits
|
|
1146
|
+
* `doc` into that version with `generateAdaptedFiles`.
|
|
1147
|
+
*/
|
|
1148
|
+
async function generateConvertedFiles(doc, opts) {
|
|
1149
|
+
if (doc.errors.length > 0)
|
|
1150
|
+
return [];
|
|
1151
|
+
const model = loadSemanticModel(doc);
|
|
1152
|
+
if (!model)
|
|
1153
|
+
return [];
|
|
1154
|
+
if (doc.targetVersion === loader_1.OSSIE_VERSION) {
|
|
1155
|
+
attachProfileFiles(model, opts.profileFiles, doc);
|
|
1156
|
+
validateOssieConversion(model, doc);
|
|
1157
|
+
if (doc.errors.length > 0)
|
|
1158
|
+
return [];
|
|
1159
|
+
}
|
|
1160
|
+
return generateAdaptedFiles(doc, !!opts.transpile, opts.profileFiles);
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Phase 4 (Adapt): Adapts `doc` in place to its target version
|
|
1164
|
+
* (`moveConstructs`, then `adaptToGoogleVersion` or `adaptToOssieVersion`),
|
|
1165
|
+
* optionally transpiles expressions via `transpileDocument`, and returns the
|
|
1166
|
+
* output files. Every value these steps do not change keeps its text.
|
|
1167
|
+
*/
|
|
1168
|
+
async function generateAdaptedFiles(doc, transpile, profileFiles = []) {
|
|
1169
|
+
// 1. Adapt `doc` in place to the target output version (`0.2.0.dev0/google`
|
|
1170
|
+
// or `0.2.0.dev0`).
|
|
1171
|
+
const profiles = doc.inputVersion === doc.targetVersion ?
|
|
1172
|
+
[] :
|
|
1173
|
+
moveConstructs(doc, profileFiles);
|
|
1174
|
+
if (doc.inputVersion === loader_1.OSSIE_VERSION &&
|
|
1175
|
+
doc.targetVersion === loader_1.GOOGLE_VERSION) {
|
|
1176
|
+
adaptToGoogleVersion(doc);
|
|
1177
|
+
}
|
|
1178
|
+
else if (doc.targetVersion === loader_1.OSSIE_VERSION) {
|
|
1179
|
+
adaptToOssieVersion(doc);
|
|
1180
|
+
}
|
|
1181
|
+
// 2. When `transpile` is enabled and no prior errors occurred, translate
|
|
1182
|
+
// vendor or double-quoted `ANSI_SQL` expressions to `BIGQUERY`.
|
|
1183
|
+
if (transpile && doc.errors.length === 0) {
|
|
1184
|
+
await transpileDocument(doc);
|
|
1185
|
+
}
|
|
1186
|
+
// 3. Write `doc.root` into the input's text when modified or preserve the
|
|
1187
|
+
// text verbatim when unmodified.
|
|
1188
|
+
const content = doc.modified ? writeDocument(doc, doc.root) : doc.text;
|
|
1189
|
+
return [{ fileName: `${doc.model.name}.yaml`, content }, ...profiles];
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Returns the input's text changed to hold `root`. What survives from the
|
|
1193
|
+
* input keeps its text, and long SQL stays on one line.
|
|
1194
|
+
*/
|
|
1195
|
+
function writeDocument(doc, root) {
|
|
1196
|
+
// The vanilla flavor lists a model's entities under `datasets:` and the
|
|
1197
|
+
// Google flavor under `entities:`. Renaming the key keeps the comments of
|
|
1198
|
+
// the list and of every entity in it.
|
|
1199
|
+
const node = doc.document.getIn(['semantic_model', 0]);
|
|
1200
|
+
const model = asRecords(root.semantic_model)[0] ?? {};
|
|
1201
|
+
for (const pair of yaml.isMap(node) ? node.items : []) {
|
|
1202
|
+
if (!yaml.isScalar(pair.key))
|
|
1203
|
+
continue;
|
|
1204
|
+
const from = String(pair.key.value);
|
|
1205
|
+
const to = from === 'datasets' ? 'entities' :
|
|
1206
|
+
from === 'entities' ? 'datasets' :
|
|
1207
|
+
undefined;
|
|
1208
|
+
if (to && to in model && !(from in model))
|
|
1209
|
+
pair.key.value = to;
|
|
1210
|
+
}
|
|
1211
|
+
return (0, yaml_update_1.updateText)(doc.text, doc.document, root, doc.links);
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Phase 5 (Post-validation): Validates the serialized output YAML with
|
|
1215
|
+
* `loadModels` (`warnUnacceptedSource: !doc.strict`) and checks deployment
|
|
1216
|
+
* targets.
|
|
1217
|
+
*/
|
|
1218
|
+
function validateOutputDocument(content, doc) {
|
|
1219
|
+
try {
|
|
1220
|
+
// 1. Load the serialized output YAML with `loadModels`
|
|
1221
|
+
// (`warnUnacceptedSource: !doc.strict`) to run full schema and semantic
|
|
1222
|
+
// validation while turning unaccepted table sources into warnings in
|
|
1223
|
+
// non-strict mode, deduplicating warnings already recorded.
|
|
1224
|
+
const loaded = (0, loader_1.loadModels)(content, { bindingOptional: true, warnUnacceptedSource: !doc.strict });
|
|
1225
|
+
for (const w of loaded.warnings) {
|
|
1226
|
+
// The repair step has reported each source the loader warns about.
|
|
1227
|
+
if (LOADER_SOURCE_WARNING.test(w))
|
|
1228
|
+
continue;
|
|
1229
|
+
if (!doc.warnings.includes(w) && !doc.errors.includes(w)) {
|
|
1230
|
+
doc.warnings.push(w);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
// 2. Validate every deployment target on the loaded model using
|
|
1234
|
+
// `validateDeploymentTargets`.
|
|
1235
|
+
for (const m of loaded.models) {
|
|
1236
|
+
validateDeploymentTargets(m, doc);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
catch (err) {
|
|
1240
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
1241
|
+
throw err;
|
|
1242
|
+
doc.errors.push(err.message);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Repairs, converts, optionally transpiles, and validates a semantic model YAML
|
|
1247
|
+
* document, returning the normalized output file(s) along with any warnings or
|
|
1248
|
+
* errors.
|
|
1249
|
+
*/
|
|
1250
|
+
async function importSemanticModelYaml(rawYaml, opts = {}) {
|
|
1251
|
+
// 1. Initialize the SQL parser and parse/pre-validate the input document.
|
|
1252
|
+
await (0, sql_parser_1.loadSqlEngine)();
|
|
1253
|
+
const doc = parseImportDocument(rawYaml, opts);
|
|
1254
|
+
if (typeof doc === 'string') {
|
|
1255
|
+
return { ok: false, files: [], warnings: [], errors: [doc] };
|
|
1256
|
+
}
|
|
1257
|
+
validateInputDocument(doc, opts.profileFiles);
|
|
1258
|
+
// 2. Run third-party repairs (which record errors in `strict` mode).
|
|
1259
|
+
repairDocument(doc);
|
|
1260
|
+
// 3. Generate the converted or adapted output file(s).
|
|
1261
|
+
const shouldConvert = doc.inputVersion !== doc.targetVersion && doc.strict;
|
|
1262
|
+
const files = shouldConvert ?
|
|
1263
|
+
await generateConvertedFiles(doc, opts) :
|
|
1264
|
+
await generateAdaptedFiles(doc, !!opts.transpile);
|
|
1265
|
+
// 4. Validate the primary output document and return the result.
|
|
1266
|
+
if (files.length > 0 && doc.errors.length === 0) {
|
|
1267
|
+
validateOutputDocument(files[0].content, doc);
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
ok: doc.errors.length === 0,
|
|
1271
|
+
files: doc.errors.length === 0 ? files : [],
|
|
1272
|
+
warnings: doc.warnings,
|
|
1273
|
+
errors: doc.errors,
|
|
1274
|
+
};
|
|
1275
|
+
}
|