@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.
Files changed (150) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +266 -2
  3. package/build/ts/kcmd/cli.js +19578 -0
  4. package/build/ts/kcmd/gcp/alloydb.d.ts +12 -0
  5. package/build/ts/kcmd/gcp/alloydb.js +70 -0
  6. package/build/ts/kcmd/gcp/api.d.ts +29 -0
  7. package/build/ts/kcmd/gcp/api.js +162 -0
  8. package/build/ts/kcmd/gcp/biglake.d.ts +16 -0
  9. package/build/ts/kcmd/gcp/biglake.js +50 -0
  10. package/build/ts/kcmd/gcp/bigquery.d.ts +57 -0
  11. package/build/ts/kcmd/gcp/bigquery.js +134 -0
  12. package/build/ts/kcmd/gcp/context.d.ts +10 -0
  13. package/build/ts/kcmd/gcp/context.js +74 -0
  14. package/build/ts/kcmd/gcp/crm.d.ts +13 -0
  15. package/build/ts/kcmd/gcp/crm.js +91 -0
  16. package/build/ts/kcmd/gcp/dataplex.d.ts +147 -0
  17. package/build/ts/kcmd/gcp/dataplex.js +406 -0
  18. package/build/ts/kcmd/gcp/index.d.ts +3 -0
  19. package/build/ts/kcmd/gcp/index.js +21 -0
  20. package/build/ts/kcmd/gcp/spanner.d.ts +63 -0
  21. package/build/ts/kcmd/gcp/spanner.js +123 -0
  22. package/build/ts/kcmd/index.d.ts +7 -0
  23. package/build/ts/kcmd/index.js +46 -0
  24. package/build/ts/kcmd/layout.d.ts +16 -0
  25. package/build/ts/kcmd/layout.js +31 -0
  26. package/build/ts/kcmd/layouts/documents.d.ts +24 -0
  27. package/build/ts/kcmd/layouts/documents.js +264 -0
  28. package/build/ts/kcmd/layouts/okf.d.ts +31 -0
  29. package/build/ts/kcmd/layouts/okf.js +344 -0
  30. package/build/ts/kcmd/layouts/semantic-model.d.ts +61 -0
  31. package/build/ts/kcmd/layouts/semantic-model.js +542 -0
  32. package/build/ts/kcmd/layouts/standard.d.ts +13 -0
  33. package/build/ts/kcmd/layouts/standard.js +108 -0
  34. package/build/ts/kcmd/manifest.d.ts +34 -0
  35. package/build/ts/kcmd/manifest.js +262 -0
  36. package/build/ts/kcmd/metadata.d.ts +25 -0
  37. package/build/ts/kcmd/metadata.js +8 -0
  38. package/build/ts/kcmd/okf/catalog_residue.d.ts +14 -0
  39. package/build/ts/kcmd/okf/catalog_residue.js +124 -0
  40. package/build/ts/kcmd/okf/concept.d.ts +14 -0
  41. package/build/ts/kcmd/okf/concept.js +104 -0
  42. package/build/ts/kcmd/okf/concept_to_entry.d.ts +8 -0
  43. package/build/ts/kcmd/okf/concept_to_entry.js +254 -0
  44. package/build/ts/kcmd/okf/init_okf_workspace.d.ts +15 -0
  45. package/build/ts/kcmd/okf/init_okf_workspace.js +115 -0
  46. package/build/ts/kcmd/okf/links.d.ts +23 -0
  47. package/build/ts/kcmd/okf/links.js +468 -0
  48. package/build/ts/kcmd/okf/okf_metadata.d.ts +12 -0
  49. package/build/ts/kcmd/okf/okf_metadata.js +82 -0
  50. package/build/ts/kcmd/semantic/bigquery.d.ts +22 -0
  51. package/build/ts/kcmd/semantic/bigquery.js +525 -0
  52. package/build/ts/kcmd/semantic/binding.d.ts +3 -0
  53. package/build/ts/kcmd/semantic/binding.js +97 -0
  54. package/build/ts/kcmd/semantic/catalog_entry.d.ts +24 -0
  55. package/build/ts/kcmd/semantic/catalog_entry.js +35 -0
  56. package/build/ts/kcmd/semantic/converters/owl/convert.d.ts +26 -0
  57. package/build/ts/kcmd/semantic/converters/owl/convert.js +41 -0
  58. package/build/ts/kcmd/semantic/converters/owl/model.d.ts +98 -0
  59. package/build/ts/kcmd/semantic/converters/owl/model.js +30 -0
  60. package/build/ts/kcmd/semantic/converters/owl/parse.d.ts +11 -0
  61. package/build/ts/kcmd/semantic/converters/owl/parse.js +634 -0
  62. package/build/ts/kcmd/semantic/converters/owl/to_ir.d.ts +19 -0
  63. package/build/ts/kcmd/semantic/converters/owl/to_ir.js +390 -0
  64. package/build/ts/kcmd/semantic/deploy_bigquery.d.ts +20 -0
  65. package/build/ts/kcmd/semantic/deploy_bigquery.js +226 -0
  66. package/build/ts/kcmd/semantic/deploy_knowledge_catalog.d.ts +129 -0
  67. package/build/ts/kcmd/semantic/deploy_knowledge_catalog.js +1396 -0
  68. package/build/ts/kcmd/semantic/deploy_spanner.d.ts +15 -0
  69. package/build/ts/kcmd/semantic/deploy_spanner.js +150 -0
  70. package/build/ts/kcmd/semantic/deployment_target.d.ts +93 -0
  71. package/build/ts/kcmd/semantic/deployment_target.js +379 -0
  72. package/build/ts/kcmd/semantic/graph_ddl.d.ts +34 -0
  73. package/build/ts/kcmd/semantic/graph_ddl.js +563 -0
  74. package/build/ts/kcmd/semantic/import_model.d.ts +50 -0
  75. package/build/ts/kcmd/semantic/import_model.js +1275 -0
  76. package/build/ts/kcmd/semantic/ir.d.ts +589 -0
  77. package/build/ts/kcmd/semantic/ir.js +247 -0
  78. package/build/ts/kcmd/semantic/kc_actions.d.ts +45 -0
  79. package/build/ts/kcmd/semantic/kc_actions.js +531 -0
  80. package/build/ts/kcmd/semantic/kc_constraints.d.ts +44 -0
  81. package/build/ts/kcmd/semantic/kc_constraints.js +279 -0
  82. package/build/ts/kcmd/semantic/kc_converter.d.ts +31 -0
  83. package/build/ts/kcmd/semantic/kc_converter.js +824 -0
  84. package/build/ts/kcmd/semantic/kc_custom_types.d.ts +70 -0
  85. package/build/ts/kcmd/semantic/kc_custom_types.js +828 -0
  86. package/build/ts/kcmd/semantic/kc_entries.d.ts +155 -0
  87. package/build/ts/kcmd/semantic/kc_entries.js +284 -0
  88. package/build/ts/kcmd/semantic/kc_ids.d.ts +103 -0
  89. package/build/ts/kcmd/semantic/kc_ids.js +191 -0
  90. package/build/ts/kcmd/semantic/knowledge_catalog.d.ts +32 -0
  91. package/build/ts/kcmd/semantic/knowledge_catalog.js +1043 -0
  92. package/build/ts/kcmd/semantic/loader.d.ts +262 -0
  93. package/build/ts/kcmd/semantic/loader.js +3440 -0
  94. package/build/ts/kcmd/semantic/names.d.ts +10 -0
  95. package/build/ts/kcmd/semantic/names.js +85 -0
  96. package/build/ts/kcmd/semantic/osi_converter.d.ts +60 -0
  97. package/build/ts/kcmd/semantic/osi_converter.js +911 -0
  98. package/build/ts/kcmd/semantic/parameters.d.ts +18 -0
  99. package/build/ts/kcmd/semantic/parameters.js +152 -0
  100. package/build/ts/kcmd/semantic/pull_kc.d.ts +14 -0
  101. package/build/ts/kcmd/semantic/pull_kc.js +346 -0
  102. package/build/ts/kcmd/semantic/resolve_inheritance.d.ts +54 -0
  103. package/build/ts/kcmd/semantic/resolve_inheritance.js +330 -0
  104. package/build/ts/kcmd/semantic/resolve_profiles.d.ts +250 -0
  105. package/build/ts/kcmd/semantic/resolve_profiles.js +1822 -0
  106. package/build/ts/kcmd/semantic/runtime/agent_tools.d.ts +93 -0
  107. package/build/ts/kcmd/semantic/runtime/agent_tools.js +243 -0
  108. package/build/ts/kcmd/semantic/runtime/dialect.d.ts +13 -0
  109. package/build/ts/kcmd/semantic/runtime/dialect.js +39 -0
  110. package/build/ts/kcmd/semantic/runtime/runtime.d.ts +94 -0
  111. package/build/ts/kcmd/semantic/runtime/runtime.js +318 -0
  112. package/build/ts/kcmd/semantic/runtime/store.d.ts +112 -0
  113. package/build/ts/kcmd/semantic/runtime/store.js +344 -0
  114. package/build/ts/kcmd/semantic/skills.d.ts +110 -0
  115. package/build/ts/kcmd/semantic/skills.js +697 -0
  116. package/build/ts/kcmd/semantic/source_dialect.d.ts +10 -0
  117. package/build/ts/kcmd/semantic/source_dialect.js +261 -0
  118. package/build/ts/kcmd/semantic/source_entries.d.ts +12 -0
  119. package/build/ts/kcmd/semantic/source_entries.js +282 -0
  120. package/build/ts/kcmd/semantic/spanner.d.ts +18 -0
  121. package/build/ts/kcmd/semantic/spanner.js +60 -0
  122. package/build/ts/kcmd/semantic/sql_expr_utils.d.ts +17 -0
  123. package/build/ts/kcmd/semantic/sql_expr_utils.js +221 -0
  124. package/build/ts/kcmd/semantic/sql_identifiers.d.ts +6 -0
  125. package/build/ts/kcmd/semantic/sql_identifiers.js +267 -0
  126. package/build/ts/kcmd/semantic/sql_parser.d.ts +44 -0
  127. package/build/ts/kcmd/semantic/sql_parser.js +378 -0
  128. package/build/ts/kcmd/semantic/transpile.d.ts +74 -0
  129. package/build/ts/kcmd/semantic/transpile.js +563 -0
  130. package/build/ts/kcmd/semantic/validate.d.ts +35 -0
  131. package/build/ts/kcmd/semantic/validate.js +1899 -0
  132. package/build/ts/kcmd/semantic/yaml_options.d.ts +6 -0
  133. package/build/ts/kcmd/semantic/yaml_options.js +58 -0
  134. package/build/ts/kcmd/semantic/yaml_update.d.ts +26 -0
  135. package/build/ts/kcmd/semantic/yaml_update.js +782 -0
  136. package/build/ts/kcmd/snapshot.d.ts +33 -0
  137. package/build/ts/kcmd/snapshot.js +379 -0
  138. package/build/ts/kcmd/source.d.ts +31 -0
  139. package/build/ts/kcmd/source.js +119 -0
  140. package/build/ts/kcmd/sources/bq-dataset.d.ts +17 -0
  141. package/build/ts/kcmd/sources/bq-dataset.js +131 -0
  142. package/build/ts/kcmd/sources/entrygroup.d.ts +21 -0
  143. package/build/ts/kcmd/sources/entrygroup.js +83 -0
  144. package/build/ts/kcmd/sources/kb.d.ts +21 -0
  145. package/build/ts/kcmd/sources/kb.js +82 -0
  146. package/build/ts/kcmd/sources/semantic-model.d.ts +18 -0
  147. package/build/ts/kcmd/sources/semantic-model.js +48 -0
  148. package/build/ts/kcmd/sync.d.ts +35 -0
  149. package/build/ts/kcmd/sync.js +697 -0
  150. 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
+ }