@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,3440 @@
1
+ "use strict";
2
+ // Loads an open, vendor-neutral AI-first semantics format (YAML/JSON) into the
3
+ // Semantic Model IR (./ir).
4
+ //
5
+ // The format describes a semantic model as datasets (entities), foreign-key
6
+ // relationships, and model-level metrics, with entity-qualified SQL expressions
7
+ // (`Entity.column`) supplied per SQL dialect. This module reads the subset of
8
+ // that logical layer needed to normalize a model into the IR, so the rest of
9
+ // the toolbox (e.g. the BigQuery property-graph generator) can consume models
10
+ // authored in it. Fields outside the supported subset are accepted and ignored.
11
+ //
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.THIRD_PARTY_SOURCE = exports.ENTRY_NAME_FORM = exports.DIALECT_OF_FQN_PREFIX = exports.DIALECT_OF_ENTRY_TYPE = exports.TABLE_FORMS = exports.NO_SPACE = exports.GOOGLE_VENDOR = exports.FORMAT_VERSION_PLACEMENT = exports.GOOGLE_VERSION = exports.OSSIE_VERSION = exports.DEFAULT_DIALECT = exports.HALF_BOUND_JOIN_PROBLEM = exports.SemanticModelError = void 0;
47
+ exports.joinLengthProblem = joinLengthProblem;
48
+ exports.joinLengthMessage = joinLengthMessage;
49
+ exports.undefinedEndpointProblem = undefinedEndpointProblem;
50
+ exports.isFormatVersion = isFormatVersion;
51
+ exports.noSqlReason = noSqlReason;
52
+ exports.listed = listed;
53
+ exports.expressionDocOf = expressionDocOf;
54
+ exports.noSqlProblemOf = noSqlProblemOf;
55
+ exports.inheritedNoSqlProblems = inheritedNoSqlProblems;
56
+ exports.isGoogleVendor = isGoogleVendor;
57
+ exports.hasGoogleBlock = hasGoogleBlock;
58
+ exports.setsAKeyTwice = setsAKeyTwice;
59
+ exports.googleBlockProfiles = googleBlockProfiles;
60
+ exports.modelDocumentVersion = modelDocumentVersion;
61
+ exports.isRecord = isRecord;
62
+ exports.modelAiContexts = modelAiContexts;
63
+ exports.loadModels = loadModels;
64
+ exports.fromDocument = fromDocument;
65
+ exports.inferMetricEntities = inferMetricEntities;
66
+ exports.entityAndMetricWarnings = entityAndMetricWarnings;
67
+ exports.parseFieldExpression = parseFieldExpression;
68
+ exports.readFieldExpression = readFieldExpression;
69
+ exports.parseFieldBinding = parseFieldBinding;
70
+ exports.parseExecutor = parseExecutor;
71
+ exports.cloudSqlTableOf = cloudSqlTableOf;
72
+ exports.sourceTableOf = sourceTableOf;
73
+ exports.asResourceUri = asResourceUri;
74
+ exports.asResourceName = asResourceName;
75
+ exports.resourceUriFix = resourceUriFix;
76
+ exports.oldSourceForm = oldSourceForm;
77
+ exports.fillForm = fillForm;
78
+ exports.dottedPartProblem = dottedPartProblem;
79
+ exports.catalogNameSegments = catalogNameSegments;
80
+ exports.hasUnquotedWhitespace = hasUnquotedWhitespace;
81
+ exports.databaseOf = databaseOf;
82
+ exports.isAcceptedSource = isAcceptedSource;
83
+ exports.writtenSource = writtenSource;
84
+ exports.sourceToDataSource = sourceToDataSource;
85
+ exports.thirdPartyEntryOf = thirdPartyEntryOf;
86
+ exports.bindsThirdPartyTable = bindsThirdPartyTable;
87
+ exports.dialectLookupKey = dialectLookupKey;
88
+ exports.isGoogleCloudForm = isGoogleCloudForm;
89
+ exports.parseSource = parseSource;
90
+ exports.unquote = unquote;
91
+ exports.bareNameSegments = bareNameSegments;
92
+ exports.bareNameParts = bareNameParts;
93
+ exports.loadSemanticModels = loadSemanticModels;
94
+ const yaml = __importStar(require("yaml"));
95
+ const z = __importStar(require("zod"));
96
+ const ir_1 = require("./ir");
97
+ const names_1 = require("./names");
98
+ const resolve_inheritance_1 = require("./resolve_inheritance");
99
+ const resolve_profiles_1 = require("./resolve_profiles");
100
+ const sql_expr_utils_1 = require("./sql_expr_utils");
101
+ const sql_parser_1 = require("./sql_parser");
102
+ const transpile_1 = require("./transpile");
103
+ const yaml_options_1 = require("./yaml_options");
104
+ // The error the loader throws for a mistake in a document it reads, and that
105
+ // code reading a profile throws for a mistake in a profile. A caller that
106
+ // reports such an error to the author catches this class only, so an internal
107
+ // bug still surfaces as one.
108
+ class SemanticModelError extends Error {
109
+ }
110
+ exports.SemanticModelError = SemanticModelError;
111
+ // Message texts the loader gives, shared with code that checks the same rules
112
+ // on a model already loaded. The texts that end in Problem leave out the
113
+ // relationship, so a caller can name it in its own way.
114
+ exports.HALF_BOUND_JOIN_PROBLEM = `from_columns and to_columns must be ` +
115
+ `given together (both bind the edge) or both omitted (a logical edge); ` +
116
+ `one without the other is a half-bound join.`;
117
+ function halfBoundJoinMessage(relationship) {
118
+ return `relationship '${relationship}': ${exports.HALF_BOUND_JOIN_PROBLEM}`;
119
+ }
120
+ function joinLengthProblem(from, to) {
121
+ return `from_columns (${from}) and to_columns (${to}) have different ` +
122
+ `lengths; the join keys are mismatched`;
123
+ }
124
+ function joinLengthMessage(relationship, from, to) {
125
+ return `relationship '${relationship}': ${joinLengthProblem(from, to)}`;
126
+ }
127
+ function undefinedEndpointProblem(side, dataset) {
128
+ return `'${side}' dataset '${dataset}' is not defined in the model`;
129
+ }
130
+ function sourceRequiredMessage(dataset) {
131
+ return `dataset '${dataset}': a non-abstract dataset requires a ` +
132
+ `source; set 'source', or mark it 'abstract: true' if it has no table`;
133
+ }
134
+ function noPrimaryKeyWarning(dataset) {
135
+ return `dataset '${dataset}': no primary_key; the entity's KEY will be empty (invalid for graph generation)`;
136
+ }
137
+ function ansiFallbackNote(preferred) {
138
+ return `note: no '${preferred}' dialect for one or more expressions; using the portable ` +
139
+ `'${FALLBACK_DIALECT}' dialect verbatim ('${preferred}' accepts the ANSI core subset — ` +
140
+ `supply '${preferred}' variants only for ${preferred}-specific SQL)`;
141
+ }
142
+ exports.DEFAULT_DIALECT = 'BIGQUERY';
143
+ const FALLBACK_DIALECT = 'ANSI_SQL';
144
+ // The two accepted format versions. Every document MUST declare one at the top
145
+ // level (a missing or unrecognized `version` is a load error). The value
146
+ // selects which extension surface is legal:
147
+ // - OSSIE_VERSION: vanilla Apache Ossie. kcmd's extensions ride ONLY in
148
+ // Ossie's `custom_extensions` carrier; the native extension keys
149
+ // (`entities` alias, `extends`, `abstract`, `deployments`, `actions`,
150
+ // `constraints`) are not accepted.
151
+ // - GOOGLE_VERSION: kcmd's extended profile. The native extension keys are
152
+ // first-class. Ossie's `custom_extensions` carrier is accepted for
153
+ // third-party vendors only; a GOOGLE block is rejected, since everything
154
+ // it would carry is a native key here.
155
+ // Both parse into the same IR, so a downstream leg never sees the difference.
156
+ exports.OSSIE_VERSION = '0.2.0.dev0';
157
+ exports.GOOGLE_VERSION = '0.2.0.dev0/google';
158
+ // How a vanilla author declares deployments: in a GOOGLE block on the model,
159
+ // or under `deployments:` after a switch to the Google flavor.
160
+ const DEPLOYMENTS_VERSION_HINT = `list 'deployments' in a GOOGLE custom_extensions block on the model, or ` +
161
+ `set version: '${exports.GOOGLE_VERSION}' and use 'deployments:'`;
162
+ const ACCEPTED_VERSIONS = [
163
+ exports.OSSIE_VERSION,
164
+ exports.GOOGLE_VERSION,
165
+ ];
166
+ function isFormatVersion(v) {
167
+ return ACCEPTED_VERSIONS.includes(v);
168
+ }
169
+ /**
170
+ * Where each format version places each Google extension construct. Other
171
+ * differences between the versions, such as required bindings and datatype
172
+ * casing, are checked where they are read.
173
+ */
174
+ exports.FORMAT_VERSION_PLACEMENT = {
175
+ [exports.GOOGLE_VERSION]: {
176
+ model: {
177
+ profiles: 'file',
178
+ deployments: 'native',
179
+ actions: 'native',
180
+ constraints: 'native',
181
+ },
182
+ dataset: {
183
+ extends: 'native',
184
+ abstract: 'native',
185
+ },
186
+ relationship: {
187
+ description: 'native',
188
+ },
189
+ metric: {
190
+ entity: 'native',
191
+ },
192
+ },
193
+ [exports.OSSIE_VERSION]: {
194
+ model: {
195
+ profiles: 'block',
196
+ deployments: 'block',
197
+ actions: 'none',
198
+ constraints: 'none',
199
+ },
200
+ dataset: {
201
+ extends: 'block',
202
+ abstract: 'none',
203
+ },
204
+ relationship: {
205
+ description: 'block',
206
+ },
207
+ metric: {
208
+ entity: 'block',
209
+ },
210
+ },
211
+ };
212
+ // An expression is supplied as one or more per-dialect variants, or (in the
213
+ // Google flavor only) as a bare SQL string shorthand for a single ANSI_SQL
214
+ // entry. Every dialect is validated against ALLOWED_DIALECTS (exact casing in
215
+ // both flavors), and a repeated dialect inside one list is rejected.
216
+ const expressionDialectEntrySchema = z.object({
217
+ dialect: z.string().superRefine((d, ctx) => {
218
+ if (!ir_1.ALLOWED_DIALECTS.includes(d)) {
219
+ ctx.addIssue({
220
+ code: z.ZodIssueCode.custom,
221
+ message: `Unknown dialect '${d}' in expression dialects. ` +
222
+ `Expected one of ${ir_1.ALLOWED_DIALECTS.join(', ')}.`,
223
+ });
224
+ }
225
+ }),
226
+ expression: z.string(),
227
+ }).strict();
228
+ const expressionObjectSchema = z.object({
229
+ dialects: z.array(expressionDialectEntrySchema)
230
+ .min(1)
231
+ .superRefine((entries, ctx) => {
232
+ const seen = new Set();
233
+ for (let i = 0; i < entries.length; i++) {
234
+ const d = entries[i].dialect;
235
+ if (seen.has(d)) {
236
+ ctx.addIssue({
237
+ code: z.ZodIssueCode.custom,
238
+ path: [i, 'dialect'],
239
+ message: `Duplicate dialect '${d}' in expression dialects.`,
240
+ });
241
+ }
242
+ seen.add(d);
243
+ }
244
+ }),
245
+ }).strict();
246
+ const expressionSchema = z.union([
247
+ z.string(),
248
+ expressionObjectSchema,
249
+ ]);
250
+ // Why `text`, an expression written for `dialect`, holds no SQL: it "is
251
+ // blank", "is only a comment", or, for a field of `entity`, is only that
252
+ // entity's qualifier, which the graph generators remove. Undefined when it
253
+ // holds SQL. The SQL parser decides what is a comment. It reads an ANSI_SQL
254
+ // text as generic SQL and as BigQuery SQL, since the graph generators read
255
+ // that text as BigQuery SQL, where `#` also starts a comment.
256
+ function noSqlReason(text, dialect, entity) {
257
+ if (text.trim() === '')
258
+ return 'is blank';
259
+ const readers = dialect === 'ANSI_SQL' ? ['ANSI_SQL', 'BIGQUERY'] : [dialect];
260
+ if (readers.some(d => (0, sql_parser_1.holdsNoSql)(text, d)))
261
+ return 'is only a comment';
262
+ if (entity === undefined)
263
+ return undefined;
264
+ const onlyQualifier = (d) => (0, sql_expr_utils_1.stripQualifier)((0, sql_parser_1.withoutComments)(text, d), entity).trim() === '';
265
+ return readers.some(onlyQualifier) ? `is only the qualifier '${entity}.'` :
266
+ undefined;
267
+ }
268
+ // Why an expression cannot load, or undefined. It cannot load when its string
269
+ // form, or any of its dialect entries, holds no SQL. The problem names each
270
+ // such entry. No expression at all passes. `entity` is the entity a field
271
+ // expression belongs to, and is absent for a metric.
272
+ function noSqlProblem(raw, entity) {
273
+ if (typeof raw === 'string') {
274
+ const reason = noSqlReason(raw, 'ANSI_SQL', entity);
275
+ return reason && `'expression' ${reason}`;
276
+ }
277
+ return listed((raw?.dialects ?? []).flatMap(d => {
278
+ const reason = noSqlReason(d.expression, d.dialect, entity);
279
+ return reason ? [`the ${d.dialect} entry of 'expression' ${reason}`] : [];
280
+ }));
281
+ }
282
+ // `problems` as one list, "a, b and c", or undefined when there are none.
283
+ function listed(problems) {
284
+ if (problems.length < 2)
285
+ return problems[0];
286
+ return `${problems.slice(0, -1).join(', ')} and ${problems[problems.length - 1]}`;
287
+ }
288
+ // Whether reading `doc` for a field of `entity` names `text`, or finds every
289
+ // problem that reading `text` in `dialect` finds. The string form and an
290
+ // ANSI_SQL entry are read both as ANSI_SQL and as BigQuery.
291
+ function docReads(doc, text, dialect, entity) {
292
+ const covers = (read) => read === dialect ||
293
+ read === 'ANSI_SQL' && dialect === 'BIGQUERY' ||
294
+ noSqlReason(text, read, entity) !== undefined;
295
+ if (typeof doc === 'string')
296
+ return doc === text && covers('ANSI_SQL');
297
+ return (doc?.dialects ?? [])
298
+ .some(d => d.expression === text && covers(d.dialect));
299
+ }
300
+ // `f`'s expression as a document writes it: its string form or its dialect
301
+ // list. A binding built in memory with no dialect list gives its imported
302
+ // text as an entry of its imported dialect. When it also has a target text,
303
+ // that text is a BIGQUERY entry beside it, as serializeModel writes the two. A
304
+ // target text alone is the string form. So is a target text beside an
305
+ // imported text that a dialect list cannot hold: one with no imported
306
+ // dialect, or one marked BIGQUERY, since a list names each dialect once. An
307
+ // imported text with no imported dialect and no target text is the string
308
+ // form too. Undefined when `f` carries no text.
309
+ function expressionDocOf(f) {
310
+ if (f.dialects?.length) {
311
+ return f.stringForm ? f.dialects[0].expression : { dialects: f.dialects };
312
+ }
313
+ if (f.importedExpression === undefined || !f.importedDialect ||
314
+ f.expression !== undefined && f.importedDialect === 'BIGQUERY') {
315
+ return f.expression ?? f.importedExpression;
316
+ }
317
+ const imported = {
318
+ dialect: f.importedDialect,
319
+ expression: f.importedExpression,
320
+ };
321
+ return {
322
+ dialects: f.expression === undefined ?
323
+ [imported] :
324
+ [{ dialect: 'BIGQUERY', expression: f.expression }, imported],
325
+ };
326
+ }
327
+ // Why a field or metric holds no SQL, or undefined. Its expression is read as
328
+ // a document writes it, as expressionDocOf gives it. Then its target text is
329
+ // read as BigQuery, and its imported text in its imported dialect, or as
330
+ // BigQuery with none, unless that reading already read or named it.
331
+ // serializeModel writes both. Either text can be outside the dialect list
332
+ // when the field or metric was built in memory. The problem names each text
333
+ // that holds no SQL. `entity` is the entity a field belongs to, and is absent
334
+ // for a metric.
335
+ function noSqlProblemOf(x, entity) {
336
+ const doc = expressionDocOf(x);
337
+ const problems = [noSqlProblem(doc, entity)];
338
+ const texts = [
339
+ ['expression', x.expression, 'BIGQUERY'],
340
+ ['importedExpression', x.importedExpression, x.importedDialect ?? 'BIGQUERY'],
341
+ ];
342
+ for (const [key, text, dialect] of texts) {
343
+ if (text === undefined || docReads(doc, text, dialect, entity))
344
+ continue;
345
+ const reason = noSqlReason(text, dialect, entity);
346
+ problems.push(reason && `'${key}' ${reason}`);
347
+ }
348
+ return listed(problems.filter((p) => p !== undefined));
349
+ }
350
+ // Why each field an entity inherits holds no SQL on that entity, one message
351
+ // per field. `concepts` gives each entity's fields with inheritance resolved,
352
+ // so an inherited text has lost its ancestor's qualifier. That can leave only
353
+ // the entity's own qualifier, as `base.orders.` does on `orders`, which the
354
+ // graph generators also remove. A field the entity binds itself keeps its own
355
+ // text, which is checked where it is declared, so it is left out. So is
356
+ // every field of an entity that extends no other. A problem that names no
357
+ // entity, such as a `-- todo` text, is reported where the field is declared,
358
+ // so a field is reported on the entity only when the entity adds to it.
359
+ // `problemOf` says why one field holds no SQL, on one entity when one is
360
+ // named. Unless given, it reads the field as a document writes it, each
361
+ // dialect entry in its own dialect, whatever dialect picked the target text.
362
+ function inheritedNoSqlProblems(entities, concepts, problemOf = (f, entity) => noSqlProblem(expressionDocOf(f), entity)) {
363
+ const problems = [];
364
+ for (const e of entities) {
365
+ if (!e.extends?.length)
366
+ continue;
367
+ const own = new Set((e.fields ?? []).filter(ir_1.isFieldBound).map(f => f.name));
368
+ for (const f of concepts.get(e.name)?.fields.values() ?? []) {
369
+ if (own.has(f.name))
370
+ continue;
371
+ const problem = problemOf(f, e.name);
372
+ if (problem && problem !== problemOf(f)) {
373
+ problems.push(`entity '${e.name}' inherits field '${f.name}': read on '${e.name}', ${problem}; write the field's SQL`);
374
+ }
375
+ }
376
+ }
377
+ return problems;
378
+ }
379
+ // The format's AI-first annotation. It appears at every level (model, dataset,
380
+ // field, relationship, metric) and is either a bare instructions string or a
381
+ // structured object (Mapping Addendum 4).
382
+ //
383
+ // Three members are CLOSED, as in Ossie's schema: `instructions` is a string,
384
+ // and `synonyms` and `examples` are lists of strings (a non-string example is
385
+ // rejected, not dropped). Any other member is a CUSTOM member: opaque, any
386
+ // value, carried verbatim into AiContext.additionalProperties. Member names are
387
+ // case-sensitive, so `Synonyms` is a custom member, not a misspelled synonym.
388
+ // The flavors write custom members differently:
389
+ // - Google: inside a `custom` mapping. Every other unknown key stays an error,
390
+ // so a misspelled `synonyms` is still caught.
391
+ // - Vanilla: as siblings of the three, as Ossie's open object allows. A
392
+ // sibling named `custom` is a custom member like any other.
393
+ // The bare-string shorthand stands for `instructions` and has no custom
394
+ // members. Both object forms parse to the same shape (AiContextObjectDoc), so
395
+ // nothing downstream needs to know the flavor.
396
+ const AI_CONTEXT_MEMBERS = ['instructions', 'synonyms', 'examples'];
397
+ const aiContextMembers = {
398
+ instructions: z.string().optional(),
399
+ synonyms: z.array(z.string()).optional(),
400
+ examples: z.array(z.string()).optional(),
401
+ };
402
+ // Keeps the closed members and attaches `custom` members only when there are
403
+ // any, so an object with none parses to exactly the closed shape.
404
+ function aiContextObject(closed, custom) {
405
+ const out = {};
406
+ if (closed.instructions !== undefined)
407
+ out.instructions = closed.instructions;
408
+ if (closed.synonyms !== undefined)
409
+ out.synonyms = closed.synonyms;
410
+ if (closed.examples !== undefined)
411
+ out.examples = closed.examples;
412
+ if (Object.keys(custom).length)
413
+ out.additionalProperties = custom;
414
+ return out;
415
+ }
416
+ const googleAiContextSchema = z.union([
417
+ z.string(),
418
+ z.object({
419
+ ...aiContextMembers,
420
+ custom: z.record(z.string(), z.unknown(), {
421
+ error: `ai_context 'custom' must be a mapping of custom ` +
422
+ `member names to values`,
423
+ }).optional(),
424
+ })
425
+ .strict()
426
+ .superRefine((ai, ctx) => {
427
+ // A closed member written inside `custom` is one vanilla could not
428
+ // express, and almost always a misplaced line; name where it goes.
429
+ for (const member of AI_CONTEXT_MEMBERS) {
430
+ if (ai.custom && Object.hasOwn(ai.custom, member)) {
431
+ ctx.addIssue({
432
+ code: z.ZodIssueCode.custom,
433
+ path: ['custom', member],
434
+ message: `ai_context 'custom' cannot hold '${member}'; it is ` +
435
+ `a standard member, so write it beside 'custom', not ` +
436
+ `inside it.`,
437
+ });
438
+ }
439
+ }
440
+ })
441
+ .transform(({ custom, ...closed }) => aiContextObject(closed, custom ?? {})),
442
+ ]);
443
+ const vanillaAiContextSchema = z.union([
444
+ z.string(),
445
+ z.object(aiContextMembers).passthrough().transform(ai => {
446
+ const custom = {};
447
+ for (const [key, value] of Object.entries(ai)) {
448
+ if (!AI_CONTEXT_MEMBERS.includes(key)) {
449
+ custom[key] = value;
450
+ }
451
+ }
452
+ return aiContextObject(ai, custom);
453
+ }),
454
+ ]);
455
+ // The schema for one flavor. Actions, parameters and constraints exist only in
456
+ // the Google flavor, so they always take the Google form (aiContextSchema).
457
+ function aiContextSchemaFor(extended) {
458
+ return extended ? googleAiContextSchema : vanillaAiContextSchema;
459
+ }
460
+ const aiContextSchema = googleAiContextSchema;
461
+ // A vendor-scoped extension block: opaque `data` (a JSON string) tagged by
462
+ // `vendor_name`. The spec allows these at every level (model, dataset, field,
463
+ // relationship, metric). A vanilla document's GOOGLE blocks on a model,
464
+ // dataset, relationship or metric are read into the IR (see readGoogleBlocks).
465
+ // rejectGoogleBlock says where a GOOGLE block is rejected. Every other block is
466
+ // kept verbatim.
467
+ const customExtensionSchema = z.object({
468
+ vendor_name: z.string(),
469
+ data: z.string(),
470
+ }).strict();
471
+ // A field's dimension metadata; only the time flag is read today.
472
+ const dimensionSchema = z.object({
473
+ is_time: z.boolean().optional(),
474
+ }).strict();
475
+ // The canonical (superset) field shape, for TYPE inference only. The actual
476
+ // validation schemas are rebuilt per-load by buildDocumentSchema, which is
477
+ // strict, gates the native extension keys on the version, and applies the
478
+ // source-completeness refinement. A field is BOUND when it names a physical
479
+ // column via `expression`; a field with no `expression` is UNBOUND (no column
480
+ // under this binding). A field is never required to be bound -- an unbound
481
+ // field loads and the availability pass prunes it before generation.
482
+ const fieldBase = z.object({
483
+ name: z.string(),
484
+ expression: expressionSchema.optional(),
485
+ // A plain string here; whether it is valid, and its canonical spelling,
486
+ // depend on the flavor and are decided in convertField (normalizeDataType).
487
+ datatype: z.string().optional(),
488
+ description: z.string().optional(),
489
+ label: z.string().optional(),
490
+ dimension: dimensionSchema.optional(),
491
+ ai_context: aiContextSchema.optional(),
492
+ custom_extensions: z.array(customExtensionSchema).optional(),
493
+ });
494
+ // The dataset object shape, WITHOUT the source-required refinement (applied
495
+ // per-load by makeDocumentSchema). DatasetDoc is inferred from this base.
496
+ const datasetBase = z.object({
497
+ name: z.string(),
498
+ // A concrete dataset is backed by a physical `source` table; an `abstract`
499
+ // one has no table. Whether a non-abstract dataset must name a `source` is
500
+ // decided per-load by makeDocumentSchema (a KC-only push accepts a logical
501
+ // dataset with none); the abstract+source contradiction is always rejected.
502
+ source: z.string().optional(),
503
+ primary_key: z.array(z.string()).optional(),
504
+ unique_keys: z.array(z.array(z.string())).optional(),
505
+ // Supertype entity names (Ossie `extends`) -- entity-level inheritance. Only
506
+ // datasets carry it; relationships have no `extends`.
507
+ extends: z.array(z.string()).optional(),
508
+ // Marks a conceptual entity with no physical table (see Entity.abstract): it
509
+ // forms no node table and survives only as a label on its concrete
510
+ // descendants. Distinct from an unbound `source` placeholder, which must fail
511
+ // loudly rather than be silently treated as table-less.
512
+ abstract: z.boolean().optional(),
513
+ description: z.string().optional(),
514
+ ai_context: aiContextSchema.optional(),
515
+ fields: z.array(fieldBase).optional(),
516
+ custom_extensions: z.array(customExtensionSchema).optional(),
517
+ });
518
+ const relationshipSchema = z.object({
519
+ name: z.string(),
520
+ from: z.string(),
521
+ to: z.string(),
522
+ // Join columns are the physical binding of the
523
+ // edge and are OPTIONAL, so a purely logical
524
+ // relationship (an ontology edge, direction only)
525
+ // loads. When present they must be non-empty;
526
+ // either both endpoints are bound or neither is
527
+ // (a half-bound edge is a malformed join, caught
528
+ // below). A graph push still requires both (see
529
+ // validatePushRequirements).
530
+ from_columns: z.array(z.string()).min(1).optional(),
531
+ to_columns: z.array(z.string()).min(1).optional(),
532
+ description: z.string().optional(),
533
+ ai_context: aiContextSchema.optional(),
534
+ custom_extensions: z.array(customExtensionSchema).optional(),
535
+ }).superRefine((r, ctx) => {
536
+ if ((r.from_columns === undefined) !== (r.to_columns === undefined)) {
537
+ ctx.addIssue({
538
+ code: z.ZodIssueCode.custom,
539
+ message: halfBoundJoinMessage(r.name),
540
+ });
541
+ }
542
+ });
543
+ const metricSchema = z.object({
544
+ name: z.string(),
545
+ // The entity the metric belongs to, as the author wrote it. Google flavor
546
+ // only (buildDocumentSchema rejects it in vanilla).
547
+ entity: z.string().optional(),
548
+ expression: expressionSchema,
549
+ // A plain string; validated and canonicalized in convertMetric
550
+ // (normalizeDataType).
551
+ datatype: z.string().optional(),
552
+ description: z.string().optional(),
553
+ ai_context: aiContextSchema.optional(),
554
+ custom_extensions: z.array(customExtensionSchema).optional(),
555
+ });
556
+ // An action executor: exactly one kind. The open format expresses it as an
557
+ // object with a single kind key (mcp/rest/grpc/sql); we accept the union and
558
+ // enforce the "exactly one" rule in a refinement so the message names the
559
+ // violation.
560
+ //
561
+ // `sql` carries the write itself rather than a pointer to whoever performs it,
562
+ // so the schema only checks its shape here. What makes it safe -- one DML verb
563
+ // per statement, and every `@parameter` declared by the action -- is checked in
564
+ // validate.ts, where the action's parameter list is in scope. validate.ts also
565
+ // holds the live pre-flight that plans each statement against the bound store,
566
+ // which is where a name the database does not have is caught. See SqlExecutor.
567
+ const EXECUTOR_KINDS = ['mcp', 'rest', 'grpc', 'sql'];
568
+ const executorSchema = z.object({
569
+ mcp: z.object({ server: z.string(), tool: z.string() }).strict().optional(),
570
+ rest: z.object({ endpoint: z.string(), method: z.string() })
571
+ .strict()
572
+ .optional(),
573
+ grpc: z.object({ service: z.string(), method: z.string() })
574
+ .strict()
575
+ .optional(),
576
+ sql: z.object({ statements: z.array(z.string()).min(1) })
577
+ .strict()
578
+ .optional(),
579
+ })
580
+ .strict()
581
+ .superRefine((ex, ctx) => {
582
+ const kinds = EXECUTOR_KINDS.filter(k => ex[k] !== undefined);
583
+ if (kinds.length !== 1) {
584
+ ctx.addIssue({
585
+ code: z.ZodIssueCode.custom,
586
+ message: kinds.length === 0 ?
587
+ `executor requires exactly one kind (${EXECUTOR_KINDS.join(', ')}); none given` :
588
+ `executor requires exactly one kind, but ${kinds.length} given ` +
589
+ `(${kinds.join(', ')})`,
590
+ });
591
+ }
592
+ });
593
+ // An action parameter, in either of its two authored forms (see
594
+ // ir.ActionParameter). DERIVED states `concept` and `field` and no `type`;
595
+ // STANDALONE states a `type` and neither. Every key here is optional because
596
+ // which combination is legal depends on the other keys, and a zod union would
597
+ // report a document that got it wrong as a list of failed alternatives naming a
598
+ // position rather than the mistake. convertParameter decides instead, where the
599
+ // model is in scope and the message can name the fix.
600
+ const parameterSchema = z.object({
601
+ // Optional: a derived parameter takes the field's name unless it renames it.
602
+ name: z.string().optional(),
603
+ // A scalar DataType, and only on a standalone parameter. Resolved against
604
+ // the model in convertParameter, not here, so the schema stays a plain
605
+ // string. `datatype` is accepted as an alias for `type` so parameters can be
606
+ // written with the same key as entity fields and metrics.
607
+ type: z.string().optional(),
608
+ datatype: z.string().optional(),
609
+ concept: z.string().optional(),
610
+ field: z.string().optional(),
611
+ description: z.string().optional(),
612
+ label: z.string().optional(),
613
+ ai_context: aiContextSchema.optional(),
614
+ required: z.boolean().optional(),
615
+ default: z.unknown().optional(),
616
+ });
617
+ // One thing an action changes. Two authored shapes: a bare name, which is the
618
+ // coarse blast radius (`affects: [Order, OrderedAs]`), or a record naming the
619
+ // `concept` plus how it is changed. One key covers an entity and a
620
+ // relationship alike, because the same three operations apply to either. The
621
+ // bare name is not a lesser form -- it is the same entry with the operation
622
+ // left unspecified -- so the two mix freely in one list.
623
+ //
624
+ // The name is resolved against the model in toAffectedConcept, not here, for
625
+ // the same reason a parameter type is: the schema sees one action, and only
626
+ // the model knows what `Order` is.
627
+ const affectedConceptSchema = z.union([
628
+ z.string(),
629
+ z.object({
630
+ concept: z.string(),
631
+ operation: z.enum(ir_1.CONCEPT_OPERATIONS).optional(),
632
+ fields: z.array(z.string()).optional(),
633
+ }).strict(),
634
+ ]);
635
+ const actionSchema = z.object({
636
+ name: z.string(),
637
+ description: z.string().optional(),
638
+ // Optional because it is a physical binding: a profile may supply it, and a
639
+ // purely logical model declares actions it cannot perform. See Action.
640
+ // `null` reads the same as absent, so `executor:` with the body commented out
641
+ // means here what `executor: null` means in a profile: performed by nothing.
642
+ executor: executorSchema.nullish(),
643
+ parameters: z.array(parameterSchema).optional(),
644
+ // Names of the constraints that gate this action. Kept as plain strings: they
645
+ // are resolved against the model's own constraints in validate.ts, which sees
646
+ // the whole model, whereas the schema sees one action.
647
+ guards: z.array(z.string()).optional(),
648
+ // What the call changes. See affectedConceptSchema.
649
+ affects: z.array(affectedConceptSchema).optional(),
650
+ ai_context: aiContextSchema.optional(),
651
+ custom_extensions: z.array(customExtensionSchema).optional(),
652
+ });
653
+ // A constraint: a named invariant over the ontology, stated in words.
654
+ //
655
+ // `judgment` is the rule as a sentence. It is a plain string -- validate
656
+ // resolves the `Entity.field` tokens it mentions, and nothing else reads into
657
+ // it. Optional here and required in fact, which `validate` enforces so the
658
+ // author gets one message naming the constraint rather than a schema union
659
+ // error naming a position in the document.
660
+ //
661
+ // `expression` is RESERVED, not supported. It named the removed second body, a
662
+ // boolean in the model's own language, and it is still accepted by the schema
663
+ // for one reason: a model written against the older shape should be told to
664
+ // restate the rule as a judgment, not handed an unrecognized-key error that
665
+ // says only that the word is unknown. `rejectExpressionBody` refuses it. See
666
+ // Constraint in ir.ts.
667
+ const constraintSchema = z.object({
668
+ name: z.string(),
669
+ expression: z.string().optional(),
670
+ judgment: z.string().optional(),
671
+ description: z.string().optional(),
672
+ // What the engine does; absent means `reject`. See VIOLATION_EFFECTS.
673
+ on_violation: z.enum(ir_1.VIOLATION_EFFECTS).optional(),
674
+ // How grave it is; no default, and nothing reads it yet. See
675
+ // CONSTRAINT_SEVERITIES.
676
+ severity: z.enum(ir_1.CONSTRAINT_SEVERITIES).optional(),
677
+ ai_context: aiContextSchema.optional(),
678
+ // No `custom_extensions`: it is a vanilla-Ossie surface, and
679
+ // `constraints` is an extended-profile-only key, so the two never
680
+ // co-occur. See Constraint.
681
+ }).superRefine(rejectExpressionBody);
682
+ // Refuses the removed `expression` body, naming the constraint and saying where
683
+ // the rule goes instead. A hard error rather than a dropped key: a rule the
684
+ // author stated and the loader silently ignored is a guard that stops guarding.
685
+ function rejectExpressionBody(c, ctx) {
686
+ if (c.expression === undefined)
687
+ return;
688
+ ctx.addIssue({
689
+ code: z.ZodIssueCode.custom,
690
+ path: ['expression'],
691
+ message: `constraint '${c.name}' states an expression. A constraint ` +
692
+ `states its rule as a judgment, in words, and nothing evaluates an ` +
693
+ `expression. Restate the rule under 'judgment' and give it an ` +
694
+ `'on_violation'.`,
695
+ });
696
+ }
697
+ const deploymentSchema = z.object({
698
+ name: z.string(),
699
+ target: z.string().trim().min(1),
700
+ profile: z.string().min(1).optional(),
701
+ }).strict();
702
+ const modelBase = z.object({
703
+ name: z.string(),
704
+ description: z.string().optional(),
705
+ ai_context: aiContextSchema.optional(),
706
+ custom_extensions: z.array(customExtensionSchema).optional(),
707
+ datasets: z.array(datasetBase).min(1),
708
+ relationships: z.array(relationshipSchema).optional(),
709
+ metrics: z.array(metricSchema).optional(),
710
+ // A native deployment-target key (GOOGLE_VERSION only, under
711
+ // allowLegacyBareSource). Folded into a GOOGLE `custom_extensions` block on
712
+ // the IR after validation (see convertModel).
713
+ deployment_target: z.string().optional(),
714
+ // Named deployment targets (GOOGLE_VERSION only).
715
+ deployments: z.array(deploymentSchema).optional(),
716
+ // Model-level write operations, also GOOGLE_VERSION only (see actionSchema).
717
+ actions: z.array(actionSchema).optional(),
718
+ constraints: z.array(constraintSchema).optional(),
719
+ });
720
+ // The vendor-scoped `custom_extensions` field, accepted in BOTH flavors at the
721
+ // five levels the format defines it on: model, dataset, field, relationship and
722
+ // metric. Which vendor blocks are legal where is a flavor rule, checked
723
+ // separately. Actions and constraints are extended-profile constructs with no
724
+ // `custom_extensions` encoding, so they do not take it.
725
+ const customExtensionsKey = { custom_extensions: z.array(customExtensionSchema).optional() };
726
+ // Builds the document schema for one (bindingOptional, extended) combination.
727
+ // Every object is `.strict()`, so an unknown key is a hard error rather than
728
+ // silently dropped. Two axes shape it:
729
+ // - `extended` selects the version's extension surface: under the extended
730
+ // profile the native keys (`extends`, `abstract`, `deployments`,
731
+ // metric `entity`) are accepted; under vanilla Ossie they are not.
732
+ // (`entities` is folded to `datasets` before validation, so it is never a
733
+ // schema key -- see normalizeDocumentSugars.)
734
+ // - `bindingOptional` relaxes the source rule: when false (any push with a
735
+ // graph leg) each concrete dataset must name a `source`; when true (a
736
+ // Knowledge-Catalog-only push) it is optional, so a purely logical model
737
+ // loads. The abstract+source contradiction is rejected either way. Fields
738
+ // are never required to carry an `expression`: a field with no `expression`
739
+ // is simply unbound (there is no separate flag), and the availability pass
740
+ // prunes it before generation -- so it is not a load error under either
741
+ // `bindingOptional`.
742
+ function buildDocumentSchema(bindingOptional, extended) {
743
+ const placement = exports.FORMAT_VERSION_PLACEMENT[extended ? exports.GOOGLE_VERSION : exports.OSSIE_VERSION];
744
+ // The five data levels take this flavor's ai_context; actions and
745
+ // constraints are Google-only and keep the Google form.
746
+ const aiContext = aiContextSchemaFor(extended);
747
+ const field = z.object({
748
+ name: z.string(),
749
+ expression: expressionSchema.optional(),
750
+ // Validated per flavor in convertField (normalizeDataType).
751
+ datatype: z.string().optional(),
752
+ description: z.string().optional(),
753
+ label: z.string().optional(),
754
+ dimension: dimensionSchema.optional(),
755
+ ai_context: aiContext.optional(),
756
+ ...customExtensionsKey,
757
+ }).strict();
758
+ const dataset = z.object({
759
+ name: z.string(),
760
+ source: z.string().optional(),
761
+ primary_key: z.array(z.string()).optional(),
762
+ unique_keys: z.array(z.array(z.string())).optional(),
763
+ description: z.string().optional(),
764
+ ai_context: aiContext.optional(),
765
+ fields: z.array(field).optional(),
766
+ ...customExtensionsKey,
767
+ // Inheritance (`extends`, `abstract`) is a native extension in the
768
+ // extended profile only. Vanilla still declares both keys so the
769
+ // refinement below can reject each with a message that names the
770
+ // dataset and the fix, not a bare "unrecognized key".
771
+ extends: placement.dataset.extends === 'native' ?
772
+ z.array(z.string()).optional() :
773
+ z.unknown().optional(),
774
+ abstract: placement.dataset.abstract === 'native' ?
775
+ z.boolean().optional() :
776
+ z.unknown().optional(),
777
+ })
778
+ .strict()
779
+ .superRefine((ds, ctx) => {
780
+ if (placement.dataset.extends !== 'native' &&
781
+ ds.extends !== undefined) {
782
+ ctx.addIssue({
783
+ code: 'custom',
784
+ path: ['extends'],
785
+ message: `dataset '${ds.name}': 'extends' is a ` +
786
+ `'${exports.GOOGLE_VERSION}' extension. Set 'extends' in a ` +
787
+ `${exports.GOOGLE_VENDOR} custom_extensions block on the ` +
788
+ `dataset, or set the document version to ` +
789
+ `'${exports.GOOGLE_VERSION}'.`,
790
+ });
791
+ }
792
+ if (placement.dataset.abstract !== 'native' &&
793
+ ds.abstract !== undefined) {
794
+ ctx.addIssue({
795
+ code: 'custom',
796
+ path: ['abstract'],
797
+ message: `dataset '${ds.name}': 'abstract' is a ` +
798
+ `'${exports.GOOGLE_VERSION}' extension. An abstract dataset has ` +
799
+ `no table, and every '${exports.OSSIE_VERSION}' dataset needs a ` +
800
+ `'source'. A model that needs an abstract entity uses ` +
801
+ `'${exports.GOOGLE_VERSION}'.`,
802
+ });
803
+ }
804
+ const abstract = placement.dataset.abstract === 'native' && ds.abstract === true;
805
+ // In the Google flavor, a concrete (non-abstract) dataset must name
806
+ // its backing table; only an abstract one may omit `source`. Relaxed
807
+ // under bindingOptional. Vanilla has no `abstract` and requires
808
+ // `source` unconditionally (see requireVanillaBindings).
809
+ if (extended && !bindingOptional && !abstract &&
810
+ ds.source === undefined) {
811
+ ctx.addIssue({
812
+ code: z.ZodIssueCode.custom,
813
+ path: ['source'],
814
+ message: sourceRequiredMessage(ds.name),
815
+ });
816
+ }
817
+ // The converse is always contradictory: an abstract dataset has no
818
+ // physical table, so a `source` on it would be silently ignored.
819
+ // Reject it always.
820
+ if (abstract && ds.source !== undefined) {
821
+ ctx.addIssue({
822
+ code: z.ZodIssueCode.custom,
823
+ path: ['source'],
824
+ message: `dataset '${ds.name}': an abstract dataset has no table; ` +
825
+ `remove 'source', or drop 'abstract: true' to bind it to that table`,
826
+ });
827
+ }
828
+ // A field with no `expression` is unbound -- there is no separate
829
+ // flag. One logical model serves several stores from different
830
+ // profiles, so it is not a load error under either
831
+ // `bindingOptional`. validateDeployments rejects one on a concrete
832
+ // leaf entity when a graph deployment uses the model file's own
833
+ // bindings. An `expression` with no SQL is an
834
+ // error: it binds the field to nothing. A vanilla document's string
835
+ // form is reported by convertExpression instead, since that form is
836
+ // the first thing to fix.
837
+ (ds.fields ?? []).forEach((f, i) => {
838
+ if (!extended && typeof f.expression === 'string')
839
+ return;
840
+ const problem = noSqlProblem(f.expression, ds.name);
841
+ if (problem) {
842
+ ctx.addIssue({
843
+ code: z.ZodIssueCode.custom,
844
+ path: ['fields', i, 'expression'],
845
+ message: `field '${ds.name}.${f.name}': ${problem}; write ` +
846
+ `the field's SQL, or remove 'expression' to leave the ` +
847
+ `field unbound`,
848
+ });
849
+ }
850
+ });
851
+ });
852
+ const relationship = z.object({
853
+ name: z.string(),
854
+ from: z.string(),
855
+ to: z.string(),
856
+ from_columns: z.array(z.string()).min(1).optional(),
857
+ to_columns: z.array(z.string()).min(1).optional(),
858
+ description: z.string().optional(),
859
+ ai_context: aiContext.optional(),
860
+ ...customExtensionsKey,
861
+ })
862
+ .strict()
863
+ .superRefine((r, ctx) => {
864
+ if ((r.from_columns === undefined) !==
865
+ (r.to_columns === undefined)) {
866
+ ctx.addIssue({
867
+ code: z.ZodIssueCode.custom,
868
+ message: halfBoundJoinMessage(r.name),
869
+ });
870
+ }
871
+ });
872
+ const metric = z.object({
873
+ name: z.string(),
874
+ expression: expressionSchema,
875
+ // Validated per flavor in convertMetric (normalizeDataType).
876
+ datatype: z.string().optional(),
877
+ description: z.string().optional(),
878
+ ai_context: aiContext.optional(),
879
+ ...customExtensionsKey,
880
+ // An authored anchor is a native extension: extended profile only.
881
+ // Vanilla still declares the key so the refinement below can reject it
882
+ // with a message that names the fix, not a bare "unrecognized key".
883
+ entity: placement.metric.entity === 'native' ? z.string().optional() :
884
+ z.unknown().optional(),
885
+ })
886
+ .strict()
887
+ .superRefine((mt, ctx) => {
888
+ // A metric's `expression` is required, so one with no SQL is an
889
+ // error. A vanilla string form is reported as for a field.
890
+ const problem = !extended && typeof mt.expression === 'string' ?
891
+ undefined :
892
+ noSqlProblem(mt.expression);
893
+ if (problem) {
894
+ ctx.addIssue({
895
+ code: z.ZodIssueCode.custom,
896
+ path: ['expression'],
897
+ message: `metric '${mt.name}': ${problem}; write the ` +
898
+ `metric's SQL`,
899
+ });
900
+ }
901
+ if (placement.metric.entity === 'native' ||
902
+ mt.entity === undefined) {
903
+ return;
904
+ }
905
+ ctx.addIssue({
906
+ code: z.ZodIssueCode.custom,
907
+ path: ['entity'],
908
+ message: `metric '${mt.name}': 'entity' is a ` +
909
+ `'${exports.GOOGLE_VERSION}' extension. A '${exports.OSSIE_VERSION}' ` +
910
+ `metric takes its entity from the fields it reads, as in ` +
911
+ `'COUNT(orders.id)'. To anchor it explicitly, set 'entity' ` +
912
+ `in a ${exports.GOOGLE_VENDOR} custom_extensions block on the ` +
913
+ `metric, or set the document version to ` +
914
+ `'${exports.GOOGLE_VERSION}'.`,
915
+ });
916
+ });
917
+ // The same shape the superset uses, closed to unknown keys. Restating it
918
+ // here is what let the two disagree: a key added to one and not the other
919
+ // parses in the loader's own type and is rejected by the document schema,
920
+ // which reports it as an unrecognized key on a line the author just wrote
921
+ // out of the guide.
922
+ const parameter = parameterSchema.strict();
923
+ const action = z.object({
924
+ name: z.string(),
925
+ description: z.string().optional(),
926
+ executor: executorSchema.nullish(),
927
+ parameters: z.array(parameter).optional(),
928
+ guards: z.array(z.string()).optional(),
929
+ affects: z.array(affectedConceptSchema).optional(),
930
+ ai_context: aiContextSchema.optional(),
931
+ }).strict();
932
+ // `judgment` is optional here and required in fact, so validateConstraints
933
+ // can name the constraint rather than a position in the document.
934
+ // `expression` is the reserved name of the removed body; see
935
+ // rejectExpressionBody, which is what turns it into a useful error.
936
+ const constraint = z.object({
937
+ name: z.string(),
938
+ expression: z.string().optional(),
939
+ judgment: z.string().optional(),
940
+ description: z.string().optional(),
941
+ // What a violation does; required. See
942
+ // VIOLATION_EFFECTS.
943
+ on_violation: z.enum(ir_1.VIOLATION_EFFECTS).optional(),
944
+ // How grave it is; no default. See
945
+ // CONSTRAINT_SEVERITIES.
946
+ severity: z.enum(ir_1.CONSTRAINT_SEVERITIES).optional(),
947
+ ai_context: aiContextSchema.optional(),
948
+ })
949
+ .strict()
950
+ .superRefine(rejectExpressionBody);
951
+ const modelExtensions = {
952
+ ...(placement.model.deployments === 'native' ? {
953
+ deployment_target: z.string().optional(),
954
+ deployments: z.array(deploymentSchema).optional(),
955
+ } :
956
+ {}),
957
+ ...(placement.model.actions === 'native' ?
958
+ { actions: z.array(action).optional() } :
959
+ {}),
960
+ ...(placement.model.constraints === 'native' ?
961
+ { constraints: z.array(constraint).optional() } :
962
+ {}),
963
+ };
964
+ const model = z.object({
965
+ name: z.string(),
966
+ description: z.string().optional(),
967
+ ai_context: aiContext.optional(),
968
+ datasets: z.array(dataset).min(1),
969
+ relationships: z.array(relationship).optional(),
970
+ metrics: z.array(metric).optional(),
971
+ ...customExtensionsKey,
972
+ // Native extension keys: extended profile only. `actions`
973
+ // and `constraints` are among them because vanilla Ossie has
974
+ // neither construct and no `custom_extensions` encoding for
975
+ // either, so under OSSIE_VERSION such a key is rejected as
976
+ // unknown rather than silently dropped.
977
+ ...modelExtensions,
978
+ }).strict();
979
+ return z
980
+ .object({
981
+ version: z.string(),
982
+ // The format allows exactly one model per document.
983
+ semantic_model: z.array(model).min(1).superRefine((models, ctx) => {
984
+ if (models.length > 1) {
985
+ ctx.addIssue({
986
+ code: z.ZodIssueCode.custom,
987
+ path: [1],
988
+ message: `a document declares one model; split '${models[1].name}' into its own file`,
989
+ });
990
+ }
991
+ }),
992
+ })
993
+ .strict();
994
+ }
995
+ // Four immutable schema shapes exist -- (bindingOptional) x (extended) -- so
996
+ // build all four once at module load and select between them rather than
997
+ // reconstructing the whole field/dataset/model/document graph (with fresh
998
+ // superRefine closures) on every fromDocument call.
999
+ const documentSchemas = {
1000
+ 'false-false': buildDocumentSchema(false, false),
1001
+ 'false-true': buildDocumentSchema(false, true),
1002
+ 'true-false': buildDocumentSchema(true, false),
1003
+ 'true-true': buildDocumentSchema(true, true),
1004
+ };
1005
+ // Returns z.ZodTypeAny so the four distinct strict object types collapse to one
1006
+ // static type here; the document is re-typed as DocumentDoc after a successful
1007
+ // parse (see fromDocument), which the convert functions consume.
1008
+ function makeDocumentSchema(bindingOptional, extended) {
1009
+ return documentSchemas[`${bindingOptional}-${extended}`];
1010
+ }
1011
+ // The AI-first `ai_context` normalized to the IR's common shape: a bare string
1012
+ // is read as `instructions`; a structured object keeps its parts, custom
1013
+ // members included. `examples` needs no filtering: the schema already rejected
1014
+ // any non-string example.
1015
+ function normalizeAiContext(ai) {
1016
+ if (ai === undefined)
1017
+ return {};
1018
+ if (typeof ai === 'string')
1019
+ return { instructions: ai };
1020
+ const out = {};
1021
+ if (ai.instructions)
1022
+ out.instructions = ai.instructions;
1023
+ if (ai.synonyms && ai.synonyms.length)
1024
+ out.synonyms = [...new Set(ai.synonyms)];
1025
+ if (ai.examples && ai.examples.length)
1026
+ out.examples = ai.examples;
1027
+ if (ai.additionalProperties) {
1028
+ out.additionalProperties = ai.additionalProperties;
1029
+ }
1030
+ return out;
1031
+ }
1032
+ // Normalizes `ai_context` and returns it only when it carries something, so the
1033
+ // IR omits empty aiContext objects. Custom members count: an `ai_context` that
1034
+ // carries only those is not blank.
1035
+ function aiContextOrUndefined(ai) {
1036
+ const ctx = normalizeAiContext(ai);
1037
+ return (ctx.instructions || ctx.synonyms || ctx.examples ||
1038
+ ctx.additionalProperties) ?
1039
+ ctx :
1040
+ undefined;
1041
+ }
1042
+ // Preserves vendor `custom_extensions` verbatim on the IR (`vendor_name` ->
1043
+ // `vendorName`; `data` kept as the opaque, vendor-serialized string) so nothing
1044
+ // is lost and a 1P round-trip stays lossless.
1045
+ function toCustomExtensions(exts) {
1046
+ if (!exts || !exts.length)
1047
+ return undefined;
1048
+ return exts.map(e => ({ vendorName: e.vendor_name, data: e.data }));
1049
+ }
1050
+ // Composes a single description string from ordered parts, dropping empties.
1051
+ // Parts are separated by blank lines so a base description and derived markers
1052
+ // read as distinct paragraphs in the emitted metadata. AI-first annotations
1053
+ // (instructions / synonyms / examples) are NOT folded in here — they are
1054
+ // carried structurally on the IR (aiContext) so an emitter can route them to
1055
+ // their own aspects.
1056
+ function composeDescription(...parts) {
1057
+ const kept = parts.map(p => (p === undefined ? undefined : p.trim()))
1058
+ .filter((p) => !!p);
1059
+ return kept.length ? kept.join('\n\n') : undefined;
1060
+ }
1061
+ // The vendor tag for Google-specific extension blocks (kept in sync with the
1062
+ // deploy leg's reader). A model-level `deployments:` list folds into one.
1063
+ exports.GOOGLE_VENDOR = 'GOOGLE';
1064
+ // Rewrites the author-friendly sugar forms into the canonical wire shape the
1065
+ // schema validates, and rejects deprecated or flavor-mismatched model keys up
1066
+ // front with actionable messages: the removed `deployment_target:` key,
1067
+ // inline `profiles:` in either flavor, and `deployments:` or
1068
+ // `entities:` in vanilla. Under the extended profile, `entities:` is rewritten
1069
+ // to `datasets:` and `deployments:` is left in place here and folded into a
1070
+ // GOOGLE `custom_extensions` block on the IR after validation (see
1071
+ // convertModel). Operates on the parsed document before validation.
1072
+ function normalizeDocumentSugars(doc, extended, allowLegacyBareSource = false) {
1073
+ if (!doc || typeof doc !== 'object')
1074
+ return doc;
1075
+ const cloned = structuredClone(doc);
1076
+ const models = cloned.semantic_model;
1077
+ if (!Array.isArray(models))
1078
+ return cloned;
1079
+ for (const m of models) {
1080
+ if (m && typeof m === 'object') {
1081
+ normalizeModelSugars(m, extended, allowLegacyBareSource);
1082
+ }
1083
+ }
1084
+ return cloned;
1085
+ }
1086
+ function normalizeModelSugars(m, extended, allowLegacyBareSource = false) {
1087
+ const label = typeof m.name === 'string' ? `model '${m.name}'` : 'model';
1088
+ const placement = exports.FORMAT_VERSION_PLACEMENT[extended ? exports.GOOGLE_VERSION : exports.OSSIE_VERSION];
1089
+ // Only a load that still accepts the legacy key can be left holding both.
1090
+ // Elsewhere the `deployment_target:` rejection below speaks to it. The open
1091
+ // format has neither key, so a vanilla document is told to switch flavor.
1092
+ if (allowLegacyBareSource && m.deployment_target !== undefined &&
1093
+ m.deployments !== undefined) {
1094
+ throw new SemanticModelError(extended ?
1095
+ `Semantic model load error: ${label}: set either 'deployments' ` +
1096
+ `or 'deployment_target', not both.` :
1097
+ `Semantic model load error: ${label}: neither 'deployments' nor ` +
1098
+ `'deployment_target' is in the open format ` +
1099
+ `('${exports.OSSIE_VERSION}'); set version: '${exports.GOOGLE_VERSION}', ` +
1100
+ `keep 'deployments' and remove 'deployment_target'.`);
1101
+ }
1102
+ // `deployment_target` has been replaced by the named `deployments:` list in
1103
+ // the extended profile (and was never in vanilla). Reject it unless
1104
+ // `allowLegacyBareSource` is set for legacy fixtures and profile files.
1105
+ if (m.deployment_target !== undefined && !allowLegacyBareSource) {
1106
+ if (!extended) {
1107
+ throw new SemanticModelError(`Semantic model load error: ${label}: 'deployment_target' is not ` +
1108
+ `in the open format ('${exports.OSSIE_VERSION}'); ` +
1109
+ `${DEPLOYMENTS_VERSION_HINT}`);
1110
+ }
1111
+ if (m.deployments !== undefined) {
1112
+ throw new SemanticModelError(`Semantic model load error: ${label}: 'deployment_target' is no ` +
1113
+ `longer supported; remove it, and list its target under ` +
1114
+ `'deployments' if it is not there already.`);
1115
+ }
1116
+ const target = typeof m.deployment_target === 'string' ?
1117
+ asResourceName(m.deployment_target) :
1118
+ '<target>';
1119
+ throw new SemanticModelError(`Semantic model load error: ${label}: 'deployment_target' is no ` +
1120
+ `longer supported; migrate to the named 'deployments' list:\n` +
1121
+ ` deployments:\n` +
1122
+ ` - name: default\n` +
1123
+ ` target: ${target}`);
1124
+ }
1125
+ if (placement.model.deployments !== 'native' && m.deployments !== undefined) {
1126
+ throw new SemanticModelError(`Semantic model load error: ${label}: 'deployments' is not in the ` +
1127
+ `open format ('${exports.OSSIE_VERSION}'); ${DEPLOYMENTS_VERSION_HINT}`);
1128
+ }
1129
+ // In the Google flavor, binding profiles live in sidecar files
1130
+ // (`<model>.profile.<name>.yaml`); in vanilla they live in a GOOGLE
1131
+ // custom_extensions block on the model. Neither flavor takes a native
1132
+ // `profiles:` key on the model.
1133
+ if (m.profiles !== undefined) {
1134
+ const modelFile = typeof m.name === 'string' ? m.name : '<model>';
1135
+ if (placement.model.profiles === 'file') {
1136
+ throw new SemanticModelError(`Semantic model load error: ${label}: inline 'profiles' are not ` +
1137
+ `supported in a '${exports.GOOGLE_VERSION}' document; write each binding ` +
1138
+ `profile in its own sidecar file ` +
1139
+ `'${modelFile}.profile.<name>.yaml'.`);
1140
+ }
1141
+ throw new SemanticModelError(`Semantic model load error: ${label}: 'profiles' is not in the ` +
1142
+ `open format ('${exports.OSSIE_VERSION}'); list 'profiles' in a ` +
1143
+ `${exports.GOOGLE_VENDOR} custom_extensions block on the model, or set ` +
1144
+ `version: '${exports.GOOGLE_VERSION}' and write each binding profile in its ` +
1145
+ `own sidecar file '${modelFile}.profile.<name>.yaml'.`);
1146
+ }
1147
+ // `entities` is a native alias for `datasets`, accepted only under the
1148
+ // extended profile. Under vanilla Ossie the key is unknown; surface a clear
1149
+ // message here rather than the strict schema's generic "unrecognized key".
1150
+ if (m.entities !== undefined) {
1151
+ if (!extended) {
1152
+ throw new SemanticModelError(`Semantic model load error: ${label}: 'entities' is a ` +
1153
+ `'${exports.GOOGLE_VERSION}' extension; use 'datasets', or set the document ` +
1154
+ `version to '${exports.GOOGLE_VERSION}'.`);
1155
+ }
1156
+ if (m.datasets !== undefined) {
1157
+ throw new SemanticModelError(`Semantic model load error: ${label}: set either ` +
1158
+ `'entities' or 'datasets', not both (they are the same key).`);
1159
+ }
1160
+ m.datasets = m.entities;
1161
+ delete m.entities;
1162
+ }
1163
+ }
1164
+ // Builds the GOOGLE custom-extension block that carries a model's deployment
1165
+ // target URIs on the IR (the form the deploy leg reads). Both the native
1166
+ // `deployments:` list and (under `allowLegacyBareSource`) the legacy
1167
+ // `deployment_target:` key fold into this block after validation (see
1168
+ // convertModel). The extended profile rejects any authored GOOGLE block (see
1169
+ // rejectGoogleBlock), so there is never a pre-existing one to reconcile with.
1170
+ function deploymentTargetsExtension(targets) {
1171
+ return {
1172
+ vendorName: exports.GOOGLE_VENDOR,
1173
+ data: JSON.stringify({ deploymentTargets: targets }),
1174
+ };
1175
+ }
1176
+ // True when `vendorName` names the GOOGLE vendor, in any letter case.
1177
+ function isGoogleVendor(vendorName) {
1178
+ return typeof vendorName === 'string' &&
1179
+ vendorName.toUpperCase() === exports.GOOGLE_VENDOR;
1180
+ }
1181
+ // True when a block list names the GOOGLE vendor.
1182
+ function hasGoogleBlock(exts) {
1183
+ return !!exts?.some(e => isGoogleVendor(e.vendor_name));
1184
+ }
1185
+ // Rejects a GOOGLE `custom_extensions` block where the flavor gives it no
1186
+ // meaning. In the Google flavor that is everywhere: each thing a GOOGLE block
1187
+ // would carry is a native key there. In vanilla it is only a field, the one
1188
+ // level the format defines no GOOGLE payload for. Third-party blocks are
1189
+ // accepted at every level in both flavors.
1190
+ function rejectGoogleBlock(exts, version, level, ctx) {
1191
+ if (!hasGoogleBlock(exts))
1192
+ return;
1193
+ if (version === exports.GOOGLE_VERSION && level === 'field') {
1194
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: a '${exports.GOOGLE_VENDOR}' ` +
1195
+ `custom_extensions block is not accepted on a field; the format ` +
1196
+ `defines no field-level ${exports.GOOGLE_VENDOR} extension.`);
1197
+ }
1198
+ if (version === exports.GOOGLE_VERSION) {
1199
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: a '${exports.GOOGLE_VENDOR}' ` +
1200
+ `custom_extensions block is not accepted in a '${exports.GOOGLE_VERSION}' ` +
1201
+ `document. Everything it would carry is a native key in this ` +
1202
+ `flavor; write it as one.`);
1203
+ }
1204
+ if (level === 'field') {
1205
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: a '${exports.OSSIE_VERSION}' document ` +
1206
+ `cannot carry a '${exports.GOOGLE_VENDOR}' custom_extensions block on a ` +
1207
+ `field; the format defines a ${exports.GOOGLE_VENDOR} payload only on a ` +
1208
+ `model, dataset, relationship or metric.`);
1209
+ }
1210
+ }
1211
+ // How the Google flavor validates each block key. A profile is read
1212
+ // as a profile file is instead (readBlockProfiles).
1213
+ const GOOGLE_BLOCK_SCHEMAS = {
1214
+ deployments: z.array(deploymentSchema),
1215
+ extends: z.array(z.string()),
1216
+ description: z.string(),
1217
+ entity: z.string(),
1218
+ };
1219
+ // Reads the GOOGLE blocks among `exts`, the `custom_extensions` of the object
1220
+ // `ctx` at `level`, and returns the values they carry and the blocks the object
1221
+ // keeps. Only a vanilla document gives a GOOGLE block meaning, and
1222
+ // rejectGoogleBlock rejects one in the Google flavor. Each key is validated as
1223
+ // the Google flavor validates its native key, apart from `profiles`, whose
1224
+ // entries are read as profile files are. A block that is read is not kept, so
1225
+ // an object keeps its third-party blocks. Throws, naming the object, on data
1226
+ // that is not a JSON object, a key one block sets twice, a key the level does
1227
+ // not take, `abstract` included, a key two blocks both set, a value the Google
1228
+ // flavor rejects, and a model's `deploymentTargets`, which `deployments`
1229
+ // replaces.
1230
+ function readGoogleBlocks(exts, version, level, ctx) {
1231
+ rejectGoogleBlock(exts, version, level, ctx);
1232
+ const error = (problem) => new SemanticModelError(`Semantic model load error: ${ctx}: ${problem}`);
1233
+ const block = `${exports.GOOGLE_VENDOR} custom_extensions block`;
1234
+ const levelPlacement = exports.FORMAT_VERSION_PLACEMENT[version][level];
1235
+ const values = {};
1236
+ const kept = [];
1237
+ for (const e of exts ?? []) {
1238
+ if (!isGoogleVendor(e.vendor_name)) {
1239
+ kept.push({ vendorName: e.vendor_name, data: e.data });
1240
+ continue;
1241
+ }
1242
+ let data;
1243
+ try {
1244
+ data = JSON.parse(e.data);
1245
+ }
1246
+ catch {
1247
+ // Reported below, as data that is not an object is.
1248
+ }
1249
+ if (!isRecord(data)) {
1250
+ throw error(`the 'data' of a ${block} must be a JSON object.`);
1251
+ }
1252
+ // JSON.parse keeps the last of a repeated key. The YAML a native key is
1253
+ // read from rejects a repeated key, so a block does too.
1254
+ if (setsAKeyTwice(e.data)) {
1255
+ throw error(`the 'data' of a ${block} sets a key twice; set each key once.`);
1256
+ }
1257
+ for (const [key, value] of Object.entries(data)) {
1258
+ if (level === 'model' && key === 'deploymentTargets') {
1259
+ throw error(`'${key}' is no longer supported; use 'deployments' inside the ` +
1260
+ `${block} instead, for example ` +
1261
+ `{"deployments": [{"name": "default", "target": "<target>"}]}.`);
1262
+ }
1263
+ if (level === 'dataset' && key === 'abstract') {
1264
+ throw error(`a ${block} cannot declare 'abstract' in a '${exports.OSSIE_VERSION}' ` +
1265
+ `document. An abstract dataset has no table, and every ` +
1266
+ `'${exports.OSSIE_VERSION}' dataset needs a 'source'. A model that needs ` +
1267
+ `an abstract entity uses '${exports.GOOGLE_VERSION}'.`);
1268
+ }
1269
+ const allowed = Object.keys(levelPlacement)
1270
+ .filter(k => levelPlacement[k] === 'block');
1271
+ if (!allowed.includes(key)) {
1272
+ throw error(`'${key}' is not a key a ${block} takes on a ${level}; it takes ` +
1273
+ `${allowed.map(k => `'${k}'`).join(' and ')}.`);
1274
+ }
1275
+ if (Object.hasOwn(values, key)) {
1276
+ throw error(`two ${block}s both set '${key}'; set it in one block` +
1277
+ (key === 'extends' ? `, listing every parent there.` : `.`));
1278
+ }
1279
+ if (key === 'profiles') {
1280
+ values.profiles = readBlockProfiles(value, error, ctx);
1281
+ continue;
1282
+ }
1283
+ const parsed = GOOGLE_BLOCK_SCHEMAS[key].safeParse(value);
1284
+ if (!parsed.success) {
1285
+ throw error(`'${key}' in a ${block}: ${issueList(parsed.error)}`);
1286
+ }
1287
+ values[key] = parsed.data;
1288
+ }
1289
+ }
1290
+ return {
1291
+ values: values,
1292
+ kept: kept.length ? kept : undefined,
1293
+ };
1294
+ }
1295
+ // Reads a GOOGLE block's `profiles`: a list of profiles, each read as a profile
1296
+ // file is. A name follows the rules the layout gives a profile file's name: it
1297
+ // passes the profile rule in names.ts, it is not `default` in any letter case,
1298
+ // and no two names are equal ignoring case.
1299
+ function readBlockProfiles(value, error, ctx) {
1300
+ const block = `${exports.GOOGLE_VENDOR} custom_extensions block`;
1301
+ if (!Array.isArray(value)) {
1302
+ throw error(`'profiles' in a ${block} must be a list.`);
1303
+ }
1304
+ const profiles = value.map(p => {
1305
+ if (!isRecord(p) || typeof p.name !== 'string') {
1306
+ throw error(`each profile in a ${block} must be a mapping with a 'name'.`);
1307
+ }
1308
+ if ((0, names_1.nameProblem)('profile', p.name)) {
1309
+ throw error(`profile name '${p.name}' in a ${block} is not valid; a profile ` +
1310
+ `name is a letter followed by up to 499 letters, digits, ` +
1311
+ `underscores and hyphens.`);
1312
+ }
1313
+ if (p.name.toLowerCase() === resolve_profiles_1.DEFAULT_PROFILE) {
1314
+ throw error(`a profile in a ${block} may not be named '${p.name}'; ` +
1315
+ `'${resolve_profiles_1.DEFAULT_PROFILE}' names the model's inline bindings.`);
1316
+ }
1317
+ // profileSpecOf rejects these two keys too, in words about a profile file.
1318
+ for (const key of ['version', 'semantic_model']) {
1319
+ if (p[key] !== undefined) {
1320
+ throw error(`profile '${p.name}' in a ${block} sets '${key}', which a ` +
1321
+ `profile may not set.`);
1322
+ }
1323
+ }
1324
+ try {
1325
+ return (0, resolve_profiles_1.profileSpecOf)(p, p.name);
1326
+ }
1327
+ catch (err) {
1328
+ if (!(err instanceof SemanticModelError))
1329
+ throw err;
1330
+ throw error(err.message);
1331
+ }
1332
+ });
1333
+ rejectDuplicateNames(profiles.map(p => p.name), 'profile name', ctx);
1334
+ const byLowerName = new Map();
1335
+ for (const { name } of profiles) {
1336
+ const twin = byLowerName.get(name.toLowerCase());
1337
+ if (twin !== undefined) {
1338
+ throw error(`profiles '${twin}' and '${name}' in a ${block} name the same ` +
1339
+ `profile; profile names are unique ignoring case.`);
1340
+ }
1341
+ byLowerName.set(name.toLowerCase(), name);
1342
+ }
1343
+ return profiles;
1344
+ }
1345
+ // Whether the JSON text `data` sets a key twice.
1346
+ function setsAKeyTwice(data) {
1347
+ return yaml.parseDocument(data, yaml_options_1.YAML_OPTIONS)
1348
+ .errors.some(err => err.code === 'DUPLICATE_KEY');
1349
+ }
1350
+ // Whether the JSON text `data` sets `profiles` twice, or sets a key twice
1351
+ // inside its profiles.
1352
+ function setsAProfilesKeyTwice(data) {
1353
+ const doc = yaml.parseDocument(data, yaml_options_1.YAML_OPTIONS);
1354
+ if (!yaml.isMap(doc.contents))
1355
+ return false;
1356
+ const pairs = doc.contents.items.filter(p => yaml.isScalar(p.key) && p.key.value === 'profiles');
1357
+ const range = yaml.isNode(pairs[0]?.value) ? pairs[0].value.range : undefined;
1358
+ return pairs.length > 1 ||
1359
+ (!!range && setsAKeyTwice(data.slice(range[0], range[1])));
1360
+ }
1361
+ // `v` as a list: a list as it is, one value as a list of it, and nothing as an
1362
+ // empty list.
1363
+ function asList(v) {
1364
+ return Array.isArray(v) ? v : v === undefined || v === null ? [] : [v];
1365
+ }
1366
+ // The profiles the GOOGLE block `e` lists, whatever its shape: in data written
1367
+ // as a JSON string or as a YAML mapping, or beside `vendor_name` with no data.
1368
+ // `json` is them as the data of a block that lists only them. 'none' when the
1369
+ // block lists no profiles. 'unread' when it has neither data nor profiles
1370
+ // beside `vendor_name`, when its data is not a JSON object, or when its
1371
+ // profiles have no JSON form, as with a recursive alias.
1372
+ function listedProfiles(e) {
1373
+ if (e.data == null && e.profiles === undefined)
1374
+ return 'unread';
1375
+ let data = e.data == null ? { profiles: e.profiles } : e.data;
1376
+ if (typeof data === 'string') {
1377
+ try {
1378
+ data = JSON.parse(data);
1379
+ }
1380
+ catch {
1381
+ return 'unread';
1382
+ }
1383
+ }
1384
+ if (!isRecord(data))
1385
+ return 'unread';
1386
+ if (data.profiles === undefined)
1387
+ return 'none';
1388
+ try {
1389
+ return { json: JSON.stringify({ profiles: data.profiles }) };
1390
+ }
1391
+ catch {
1392
+ return 'unread';
1393
+ }
1394
+ }
1395
+ /**
1396
+ * The profiles the model in a vanilla document carries in its GOOGLE blocks,
1397
+ * read as a load reads them, without loading the rest of the model. So a
1398
+ * caller finds a profile even in a model that does not load, as it finds a
1399
+ * profile file. Only the profiles in the model's GOOGLE blocks are read, and
1400
+ * the load reports a problem with any other key. Empty for a document that is
1401
+ * not a vanilla model.
1402
+ *
1403
+ * A profile's field binding that is not blank needs the SQL parser, so call
1404
+ * `loadSqlEngine()` first. Without it, this throws the parser's plain Error.
1405
+ *
1406
+ * A GOOGLE block whose shape the load rejects is read too, and the load
1407
+ * reports its shape. Its profiles may be in data written as a JSON string or
1408
+ * as a YAML mapping, or listed beside `vendor_name` with no data. Throws
1409
+ * SemanticModelError on profiles a load rejects, on data that sets `profiles`
1410
+ * twice or a key twice inside them, and on data that is not a JSON object in
1411
+ * a block whose shape the load accepts.
1412
+ *
1413
+ * Text that is not valid YAML is read as far as it parses. A document whose
1414
+ * root keys are all strings, and that sets `version` once, to a string other
1415
+ * than the vanilla one, gives none when each YAML error is a key set twice, an
1416
+ * alias whose anchor is not set, or more aliases than a load expands. Otherwise
1417
+ * its profiles are read as in text that parses, and only when that reading is
1418
+ * unambiguous. Each YAML error is a key set twice or an alias whose anchor is
1419
+ * not set, since any other error, such as a bad indent, can move the blocks,
1420
+ * cut them short or change the version. The document sets `version` once, to
1421
+ * the vanilla one, and `semantic_model` at most once. Its model sets
1422
+ * `custom_extensions` at most once, and each block that is a mapping sets
1423
+ * `vendor_name` once, to a string. A GOOGLE block sets `data` and `profiles` at
1424
+ * most once each, and holds no alias. Every key of a mapping on that path is a
1425
+ * string, so not an alias or a merge key, and no value on it is an alias. No
1426
+ * key is set twice inside `custom_extensions`. When any of these fails, when a
1427
+ * GOOGLE block cannot be read, or when a load rejects its profiles, it throws
1428
+ * the error a load reports for the text. So does a vanilla model whose text has
1429
+ * more aliases than a load expands, or a merge key whose value is not a
1430
+ * mapping.
1431
+ */
1432
+ function googleBlockProfiles(text) {
1433
+ let doc;
1434
+ try {
1435
+ doc = yaml.parse(text, yaml_options_1.YAML_OPTIONS);
1436
+ }
1437
+ catch (err) {
1438
+ return googleBlockProfilesPastYamlError(text, err?.message ?? String(err));
1439
+ }
1440
+ return blockProfilesOf(doc);
1441
+ }
1442
+ // The profiles in the GOOGLE blocks of the model in the parsed document `doc`,
1443
+ // read as googleBlockProfiles reads text that parses. When `unreadable` is
1444
+ // set, a GOOGLE block whose profiles cannot be read throws it.
1445
+ function blockProfilesOf(doc, unreadable) {
1446
+ if (!isRecord(doc) || doc.version !== exports.OSSIE_VERSION)
1447
+ return [];
1448
+ // The load rejects a `semantic_model` written as one mapping, and reports it.
1449
+ const m = asList(doc.semantic_model)[0];
1450
+ if (!isRecord(m))
1451
+ return [];
1452
+ const blocks = [];
1453
+ // The load rejects a `custom_extensions` written as one mapping, and reports
1454
+ // it. The mapping is read as one block.
1455
+ for (const e of asList(m.custom_extensions)) {
1456
+ if (!isRecord(e) || !isGoogleVendor(e.vendor_name))
1457
+ continue;
1458
+ const ext = customExtensionSchema.safeParse(e);
1459
+ const listed = listedProfiles(e);
1460
+ if (listed === 'unread' && unreadable)
1461
+ throw unreadable;
1462
+ if (typeof listed === 'object') {
1463
+ // Data that sets `profiles` twice, or a key twice inside them, is
1464
+ // rejected as for any block.
1465
+ const data = typeof e.data === 'string' && setsAProfilesKeyTwice(e.data) ?
1466
+ e.data :
1467
+ listed.json;
1468
+ blocks.push({ vendor_name: exports.GOOGLE_VENDOR, data });
1469
+ }
1470
+ else if (listed === 'unread' && ext.success) {
1471
+ // Data that is not a JSON object, rejected as for any block.
1472
+ blocks.push(ext.data);
1473
+ }
1474
+ }
1475
+ const label = typeof m.name === 'string' ? `model '${m.name}'` : 'model';
1476
+ return readGoogleBlocks(blocks, exports.OSSIE_VERSION, 'model', label)
1477
+ .values.profiles ??
1478
+ [];
1479
+ }
1480
+ // The profiles the model in a vanilla document carries in its GOOGLE blocks,
1481
+ // when its text is not valid YAML. `parseError` is the error a load reports
1482
+ // for the text. A document whose version is read past the error as another
1483
+ // version gives none (see versionPastYamlError). Otherwise the profiles are
1484
+ // read as in text that parses, when that reading is unambiguous (see
1485
+ // blocksReadOneWay). When it is not, when a GOOGLE block cannot be read, or
1486
+ // when a load rejects its profiles, throws the load's error for the text.
1487
+ function googleBlockProfilesPastYamlError(text, parseError) {
1488
+ const yamlError = new SemanticModelError(`Semantic model load error: could not parse input: ${parseError}`);
1489
+ // An alias whose anchor is not set stays an alias here, so the checks see
1490
+ // it.
1491
+ const parsed = yaml.parseDocument(text, { ...yaml_options_1.YAML_OPTIONS, uniqueKeys: false });
1492
+ const errors = yaml.parseDocument(text, yaml_options_1.YAML_OPTIONS).errors;
1493
+ const version = versionPastYamlError(parsed, errors);
1494
+ if (version !== undefined && version !== exports.OSSIE_VERSION)
1495
+ return [];
1496
+ if (version === undefined || !blocksReadOneWay(parsed, errors)) {
1497
+ throw yamlError;
1498
+ }
1499
+ let doc;
1500
+ try {
1501
+ doc = (0, yaml_options_1.parseLeniently)(text).toJS();
1502
+ }
1503
+ catch {
1504
+ // More aliases than a load expands, or a merge key whose value is not a
1505
+ // mapping.
1506
+ throw yamlError;
1507
+ }
1508
+ try {
1509
+ return blockProfilesOf(doc, yamlError);
1510
+ }
1511
+ catch (err) {
1512
+ if (err instanceof SemanticModelError)
1513
+ throw yamlError;
1514
+ throw err;
1515
+ }
1516
+ }
1517
+ /**
1518
+ * The `version` the model document `text` sets, as googleBlockProfiles reads
1519
+ * it. Valid YAML gives its version. Past a YAML error, the version is read
1520
+ * only when every root key is a string and the root sets `version` once, to a
1521
+ * string. Each error must be a key set twice, an alias whose anchor is not
1522
+ * set, or more aliases than a load expands. Undefined otherwise, and when the
1523
+ * text sets no string version.
1524
+ */
1525
+ function modelDocumentVersion(text) {
1526
+ let doc;
1527
+ try {
1528
+ doc = yaml.parse(text, yaml_options_1.YAML_OPTIONS);
1529
+ }
1530
+ catch {
1531
+ return versionPastYamlError(yaml.parseDocument(text, { ...yaml_options_1.YAML_OPTIONS, uniqueKeys: false }), yaml.parseDocument(text, yaml_options_1.YAML_OPTIONS).errors);
1532
+ }
1533
+ return isRecord(doc) && typeof doc.version === 'string' ? doc.version :
1534
+ undefined;
1535
+ }
1536
+ // The value of `key` in `node`, read past a YAML error: undefined when `node`
1537
+ // is not a mapping, does not set `key`, or writes `key` with no colon, as in
1538
+ // `{data}`, and null when the mapping has a key that is not a string, such as
1539
+ // an alias or a merge key, sets `key` twice, or gives it an alias.
1540
+ function onlyValue(node, key) {
1541
+ if (!yaml.isMap(node))
1542
+ return undefined;
1543
+ if (!node.items.every(p => yaml.isScalar(p.key) && typeof p.key.value === 'string')) {
1544
+ return null;
1545
+ }
1546
+ const pairs = node.items.filter(p => p.key.value === key);
1547
+ if (pairs.length > 1 || yaml.isAlias(pairs[0]?.value))
1548
+ return null;
1549
+ return pairs[0]?.value ?? undefined;
1550
+ }
1551
+ // The version a model document sets, read past a YAML error. `parsed` is the
1552
+ // text parsed with keys set twice allowed, and `errors` are the errors of the
1553
+ // strict parse. The version is read only when each error is a key set twice,
1554
+ // every root key is a string, and the root sets `version` once, to a string.
1555
+ // Any other YAML error can change the version or hide a second one.
1556
+ function versionPastYamlError(parsed, errors) {
1557
+ const version = onlyValue(parsed.contents, 'version');
1558
+ if (!yaml.isScalar(version) || typeof version.value !== 'string' ||
1559
+ errors.some(err => err.code !== 'DUPLICATE_KEY')) {
1560
+ return undefined;
1561
+ }
1562
+ return version.value;
1563
+ }
1564
+ // Whether a text that is not valid YAML gives one reading of its model's GOOGLE
1565
+ // blocks. `parsed` is the text parsed with keys set twice allowed, and `errors`
1566
+ // are the errors of the strict parse, each a key set twice. The document sets
1567
+ // `semantic_model` at most once, its first model sets `custom_extensions` at
1568
+ // most once, and each block that is a mapping sets `vendor_name` once, to a
1569
+ // string. A GOOGLE block sets `data` and `profiles` at most once each, and
1570
+ // holds no alias, since an alias may name an anchor set on a key set twice.
1571
+ // Every key of a mapping on that path is a string, so not an alias or a merge
1572
+ // key, and no value on it is an alias. No key is set twice inside
1573
+ // `custom_extensions`.
1574
+ function blocksReadOneWay(parsed, errors) {
1575
+ const models = onlyValue(parsed.contents, 'semantic_model');
1576
+ if (models === null)
1577
+ return false;
1578
+ const model = yaml.isSeq(models) ? models.items[0] : models;
1579
+ if (yaml.isAlias(model))
1580
+ return false;
1581
+ const exts = onlyValue(model, 'custom_extensions');
1582
+ if (exts === null)
1583
+ return false;
1584
+ if (!yaml.isNode(exts))
1585
+ return true;
1586
+ const [start, , end] = exts.range ?? [0, 0, 0];
1587
+ if (errors.some(err => err.pos[0] >= start && err.pos[0] < end)) {
1588
+ return false;
1589
+ }
1590
+ for (const block of yaml.isSeq(exts) ? exts.items : [exts]) {
1591
+ if (yaml.isAlias(block))
1592
+ return false;
1593
+ if (!yaml.isMap(block))
1594
+ continue;
1595
+ const vendor = onlyValue(block, 'vendor_name');
1596
+ if (!yaml.isScalar(vendor) || typeof vendor.value !== 'string') {
1597
+ return false;
1598
+ }
1599
+ if (!isGoogleVendor(vendor.value))
1600
+ continue;
1601
+ if (onlyValue(block, 'data') === null ||
1602
+ onlyValue(block, 'profiles') === null || holdsAlias(block)) {
1603
+ return false;
1604
+ }
1605
+ }
1606
+ return true;
1607
+ }
1608
+ // Whether `node` holds an alias, as a key or a value at any depth.
1609
+ function holdsAlias(node) {
1610
+ let found = false;
1611
+ yaml.visit(node, {
1612
+ Alias: () => {
1613
+ found = true;
1614
+ return yaml.visit.BREAK;
1615
+ },
1616
+ });
1617
+ return found;
1618
+ }
1619
+ // Ossie's Relationship has no `description` key and its objects are closed, so
1620
+ // a vanilla document that writes one is no longer valid Ossie. The description
1621
+ // is still expressible there, inside a GOOGLE block; say so, with the notation.
1622
+ function rejectVanillaRelationshipDescription(r, version) {
1623
+ if (exports.FORMAT_VERSION_PLACEMENT[version].relationship.description === 'native' ||
1624
+ r.description === undefined) {
1625
+ return;
1626
+ }
1627
+ throw new SemanticModelError(`Semantic model load error: relationship '${r.name}': a ` +
1628
+ `'${exports.OSSIE_VERSION}' document cannot carry a plain 'description' on a ` +
1629
+ `relationship, since Ossie's Relationship has no such key. The ` +
1630
+ `description is still available; carry it in a ${exports.GOOGLE_VENDOR} ` +
1631
+ `block on the relationship:\n` +
1632
+ ` custom_extensions:\n` +
1633
+ ` - vendor_name: ${exports.GOOGLE_VENDOR}\n` +
1634
+ ` data: '{"description": "..."}'\n` +
1635
+ `or set the document version to '${exports.GOOGLE_VERSION}'.`);
1636
+ }
1637
+ // Ossie's schema requires a dataset's `source`, a field's `expression`, and a
1638
+ // relationship's `from_columns` and `to_columns`. The Model Spec relaxes all
1639
+ // three in the Google flavor only, so a vanilla document must bind all three,
1640
+ // with or without `GOOGLE` blocks. `bindingOptional` does not relax this: it
1641
+ // decides what a push needs, not what the document must say to be valid Ossie.
1642
+ function requireVanillaBindings(m) {
1643
+ const missing = (kind, name, key) => {
1644
+ throw new SemanticModelError(`Semantic model load error: ${kind} '${name}': a ${exports.OSSIE_VERSION} ` +
1645
+ `document requires '${key}'. Only ${exports.GOOGLE_VERSION} lets a ${kind} ` +
1646
+ `leave it to a profile.`);
1647
+ };
1648
+ for (const ds of m.datasets) {
1649
+ if (ds.source === undefined)
1650
+ missing('dataset', ds.name, 'source');
1651
+ for (const f of ds.fields ?? []) {
1652
+ if (f.expression === undefined) {
1653
+ missing('field', `${ds.name}.${f.name}`, 'expression');
1654
+ }
1655
+ }
1656
+ }
1657
+ for (const r of m.relationships ?? []) {
1658
+ if (r.from_columns === undefined) {
1659
+ missing('relationship', r.name, 'from_columns');
1660
+ }
1661
+ if (r.to_columns === undefined) {
1662
+ missing('relationship', r.name, 'to_columns');
1663
+ }
1664
+ }
1665
+ }
1666
+ function isRecord(v) {
1667
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
1668
+ }
1669
+ // Each model, dataset, field, relationship and metric in a parsed document
1670
+ // that carries an `ai_context`, with the label errors use for it. Runs before
1671
+ // schema validation, so it skips anything not shaped as expected.
1672
+ function* modelAiContexts(doc) {
1673
+ const list = (v) => (Array.isArray(v) ? v : []);
1674
+ const named = (kind, name) => typeof name === 'string' ? `${kind} '${name}'` : kind;
1675
+ const models = isRecord(doc) ? list(doc.semantic_model) : [];
1676
+ for (const m of models) {
1677
+ if (!isRecord(m))
1678
+ continue;
1679
+ if (isRecord(m.ai_context)) {
1680
+ yield { ai: m.ai_context, label: named('model', m.name) };
1681
+ }
1682
+ for (const ds of [...list(m.datasets), ...list(m.entities)]) {
1683
+ if (!isRecord(ds))
1684
+ continue;
1685
+ if (isRecord(ds.ai_context)) {
1686
+ yield { ai: ds.ai_context, label: named('dataset', ds.name) };
1687
+ }
1688
+ for (const f of list(ds.fields)) {
1689
+ if (!isRecord(f) || !isRecord(f.ai_context))
1690
+ continue;
1691
+ const label = typeof f.name === 'string' && typeof ds.name === 'string' ?
1692
+ `field '${ds.name}.${f.name}'` :
1693
+ 'field';
1694
+ yield { ai: f.ai_context, label };
1695
+ }
1696
+ }
1697
+ for (const [items, kind] of [
1698
+ [m.relationships, 'relationship'], [m.metrics, 'metric'],
1699
+ ]) {
1700
+ for (const it of list(items)) {
1701
+ if (isRecord(it) && isRecord(it.ai_context)) {
1702
+ yield { ai: it.ai_context, label: named(kind, it.name) };
1703
+ }
1704
+ }
1705
+ }
1706
+ }
1707
+ }
1708
+ // Why a custom ai_context member named `__proto__` cannot load, or undefined.
1709
+ // Schema validation drops that key as a guard against prototype pollution, so
1710
+ // it is rejected here rather than lost without a word. Only member names are
1711
+ // checked: a member's value is opaque and keeps any key. In the Google flavor
1712
+ // a sibling of the standard members is an unrecognized key rather than a
1713
+ // custom member.
1714
+ function protoMemberError(doc, google) {
1715
+ const custom = (label) => `${label}: custom ai_context member '__proto__' is not supported; ` +
1716
+ `rename it.`;
1717
+ for (const { ai, label } of modelAiContexts(doc)) {
1718
+ if (google && isRecord(ai.custom) && Object.hasOwn(ai.custom, '__proto__')) {
1719
+ return custom(label);
1720
+ }
1721
+ if (Object.hasOwn(ai, '__proto__')) {
1722
+ return google ?
1723
+ `${label}: ai_context has an unrecognized key '__proto__'.` :
1724
+ custom(label);
1725
+ }
1726
+ }
1727
+ return undefined;
1728
+ }
1729
+ /**
1730
+ * Loads YAML or JSON text (a document in the AI-first semantics format) into
1731
+ * the Semantic Model IR. `yaml.parse` accepts JSON too, so both are supported.
1732
+ *
1733
+ * A document with a field or metric expression that is not blank needs the SQL
1734
+ * parser, so call `loadSqlEngine()` first. Without it, this throws a plain
1735
+ * Error instead of a SemanticModelError.
1736
+ */
1737
+ function loadModels(text, opts = {}) {
1738
+ let doc;
1739
+ try {
1740
+ doc = yaml.parse(text, yaml_options_1.YAML_OPTIONS);
1741
+ }
1742
+ catch (err) {
1743
+ throw new SemanticModelError(`Semantic model load error: could not parse input: ${err?.message ?? err}`);
1744
+ }
1745
+ return fromDocument(doc, opts);
1746
+ }
1747
+ /**
1748
+ * Converts an already-parsed document object into the Semantic Model IR. Throws
1749
+ * on a structurally invalid document; softer, lossy conversions are reported in
1750
+ * `warnings` rather than thrown.
1751
+ */
1752
+ function fromDocument(doc, opts = {}) {
1753
+ const version = readVersion(doc);
1754
+ const extended = version === exports.GOOGLE_VERSION;
1755
+ const proto = protoMemberError(doc, extended);
1756
+ if (proto) {
1757
+ throw new SemanticModelError(`Semantic model load error: ${proto}`);
1758
+ }
1759
+ const normalized = normalizeDocumentSugars(doc, extended, opts.allowLegacyBareSource);
1760
+ const result = makeDocumentSchema(opts.bindingOptional ?? false, extended)
1761
+ .safeParse(normalized);
1762
+ if (!result.success) {
1763
+ throw new SemanticModelError(`Semantic model load error: ${result.error.message}`);
1764
+ }
1765
+ const warnings = [];
1766
+ const parsed = result.data;
1767
+ const models = parsed.semantic_model.map(m => convertModel(m, version, opts, warnings));
1768
+ return { models, warnings: [...new Set(warnings)] };
1769
+ }
1770
+ // Whether a dialect list takes its ANSI_SQL text for want of one in
1771
+ // `preferred`. pickDialect picks by this and expressionWarning warns by it, so
1772
+ // the two cannot disagree.
1773
+ function fallsBackToAnsi(dialects, preferred) {
1774
+ return !dialects.some(d => d.dialect === preferred.toUpperCase()) &&
1775
+ dialects.some(d => d.dialect === FALLBACK_DIALECT);
1776
+ }
1777
+ // The table dialects whose database reads a double-quoted token as a string,
1778
+ // as hasDoubleQuotedColumn finds it, each with that database's name for a
1779
+ // message. Every other dialect reads it as a name.
1780
+ const DOUBLE_QUOTES_AS_TEXT = {
1781
+ BIGQUERY: 'BigQuery',
1782
+ SPANNER: 'Spanner',
1783
+ MYSQL: 'MySQL',
1784
+ DATABRICKS: 'Databricks',
1785
+ };
1786
+ // The warning `item` gives on a table of `dialect`, in capitals, or undefined
1787
+ // when it has text in that dialect. That is the double-quote warning on a
1788
+ // dialect in DOUBLE_QUOTES_AS_TEXT, and otherwise the transpilation warning or
1789
+ // the ANSI_SQL note, which only the `preferred` dialect gives, and never for
1790
+ // the string form.
1791
+ function expressionWarning(item, subject, dialect, preferred) {
1792
+ const { ctx } = subject;
1793
+ const dialects = item.dialects ?? [];
1794
+ if (!dialects.length)
1795
+ return undefined;
1796
+ if (dialects.some(d => d.dialect === dialect))
1797
+ return undefined;
1798
+ const ansi = dialects.find(d => d.dialect === FALLBACK_DIALECT);
1799
+ const database = DOUBLE_QUOTES_AS_TEXT[dialect];
1800
+ if (ansi && database && (0, transpile_1.hasDoubleQuotedColumn)(ansi.expression)) {
1801
+ const names = (0, transpile_1.doubleQuotedNames)(ansi.expression);
1802
+ const reads = `${ctx}: ${database} reads ${listed(names.map(n => `"${n}"`))} in its '${FALLBACK_DIALECT}' expression as text; `;
1803
+ if (subject.metric) {
1804
+ const several = names.length > 1;
1805
+ return reads + `if you meant ${several ? 'fields' : 'a field'}, give ` +
1806
+ `it a '${dialect}' expression that names ${several ? 'each' : 'it'} as '<entity>.<field>'`;
1807
+ }
1808
+ return reads + `if you meant a column or a field, give it a '${dialect}' expression that names a column alone, in backticks ` +
1809
+ `if it needs quoting, and a field of the entity whose fields list ` +
1810
+ `holds this expression as '<entity>.<field>'`;
1811
+ }
1812
+ if (item.stringForm || dialect !== preferred.toUpperCase())
1813
+ return undefined;
1814
+ if (fallsBackToAnsi(dialects, preferred))
1815
+ return ansiFallbackNote(preferred);
1816
+ const vendor = dialects.find(d => d.dialect !== FALLBACK_DIALECT);
1817
+ return `${ctx}: no '${preferred}' or '${FALLBACK_DIALECT}' dialect; keeping the '${vendor?.dialect}' ` +
1818
+ `expression as imported_expression (needs transpilation to '${preferred}')`;
1819
+ }
1820
+ // Whether `entity`'s table runs SQL in `dialect`. An abstract entity runs
1821
+ // none, and so does one whose table's dialect the load does not know.
1822
+ function runsDialect(entity, dialect) {
1823
+ return (0, resolve_profiles_1.sourcesDialect)({ entities: [entity] }) === dialect.toUpperCase();
1824
+ }
1825
+ // The warning `item` gives on `table`, the table of `entity`:
1826
+ // expressionWarning's when the load knows the table's dialect, or, unless
1827
+ // `profiled`, one saying the table has no text to run. On a table of the
1828
+ // `preferred` dialect, expressionWarning's transpilation warning already says
1829
+ // so.
1830
+ function tableWarning(item, subject, table, entity, preferred, profiled) {
1831
+ const w = table.unread ?
1832
+ undefined :
1833
+ expressionWarning(item, subject, table.name, preferred);
1834
+ if (w || profiled || !(0, resolve_profiles_1.noTextFor)(item, table.dialects))
1835
+ return w;
1836
+ const own = table.dialects.map(d => `'${d}'`).join(', ');
1837
+ return `${subject.ctx}: no ${own} or '${FALLBACK_DIALECT}' dialect, so the ${table.name} table of entity '${entity}' cannot run it`;
1838
+ }
1839
+ // The warning each field's expression gives on each entity whose table
1840
+ // tableDialects gives. An inherited field is judged on each entity that
1841
+ // inherits it, and named after that entity.
1842
+ function fieldWarnings(model, preferred, profiled) {
1843
+ return (0, resolve_profiles_1.fieldsOnTables)(model, true)
1844
+ .map(({ entity: e, field: f, table }) => tableWarning(f, { ctx: `field '${e.name}.${f.name}'`, metric: false }, table, e.name, preferred, profiled))
1845
+ .filter((w) => w !== undefined);
1846
+ }
1847
+ // The warning `metric`'s expression gives on the tables of the `reached`
1848
+ // entities, in that order, or on every table when it reaches none. On a table
1849
+ // of the `preferred` dialect it is expressionWarning's; otherwise it is the
1850
+ // first tableWarning a table gives.
1851
+ function metricWarning(metric, reached, entities, preferred, profiled) {
1852
+ const subject = { ctx: `metric '${metric.name}'`, metric: true };
1853
+ const named = entities.filter(e => reached.includes(e.name))
1854
+ .sort((a, b) => reached.indexOf(a.name) - reached.indexOf(b.name));
1855
+ const tables = named.length ? named : entities;
1856
+ if (tables.some(e => runsDialect(e, preferred))) {
1857
+ return expressionWarning(metric, subject, preferred.toUpperCase(), preferred);
1858
+ }
1859
+ for (const e of tables) {
1860
+ const table = (0, resolve_profiles_1.tableDialects)(e, true);
1861
+ const w = table &&
1862
+ tableWarning(metric, subject, table, e.name, preferred, profiled);
1863
+ if (w)
1864
+ return w;
1865
+ }
1866
+ return undefined;
1867
+ }
1868
+ // The warning a concrete entity with neither a primary key nor a unique key
1869
+ // gives, or undefined. A unique key is enough, as the graph keys the entity's
1870
+ // node by the first one.
1871
+ function keyWarning(entity) {
1872
+ return !(0, resolve_profiles_1.statesKey)(entity.keys, entity.uniqueKeys) && !entity.abstract ?
1873
+ noPrimaryKeyWarning(entity.name) :
1874
+ undefined;
1875
+ }
1876
+ // Infers the entities a metric's expressions reach across all dialect entries,
1877
+ // and returns the warning the metric gives when an expression could not be
1878
+ // parsed or when it states no `entity` and its expressions name none. An
1879
+ // authored anchor, which vanilla writes in a GOOGLE block, settles which entity
1880
+ // the metric belongs to, so a `COUNT(*)` with one is not warned about.
1881
+ function inferMetricEntities(metric, entityNames, preferred = exports.DEFAULT_DIALECT, impliedDialect, version) {
1882
+ const ctx = `metric '${metric.name}'`;
1883
+ const sources = (metric.dialects ?? [])
1884
+ .map(d => ({ text: d.expression, dialect: d.dialect }));
1885
+ if (!sources.length) {
1886
+ const text = metric.expression ?? metric.importedExpression ?? '';
1887
+ const dialect = metric.expression !== undefined ?
1888
+ preferred :
1889
+ (metric.importedDialect ?? preferred);
1890
+ if (text)
1891
+ sources.push({ text, dialect });
1892
+ }
1893
+ const allCols = [];
1894
+ let unparsed = false;
1895
+ for (const { text, dialect } of sources) {
1896
+ if (!text)
1897
+ continue;
1898
+ const cols = (0, sql_expr_utils_1.columnsForSource)(text, dialect, impliedDialect);
1899
+ if (cols === undefined) {
1900
+ unparsed = true;
1901
+ }
1902
+ else {
1903
+ allCols.push(...cols);
1904
+ }
1905
+ }
1906
+ const referenced = (0, sql_expr_utils_1.entitiesReached)(allCols, entityNames);
1907
+ if (metric.authoredEntity === undefined && !referenced.length) {
1908
+ const entityHint = version === exports.OSSIE_VERSION ?
1909
+ `set 'entity' in a ${exports.GOOGLE_VENDOR} custom_extensions block on the ` +
1910
+ `metric, or use version '${exports.GOOGLE_VERSION}' and set 'entity' on ` +
1911
+ `the metric` :
1912
+ `set 'entity' on the metric`;
1913
+ return {
1914
+ referenced,
1915
+ warning: unparsed ?
1916
+ `${ctx}: expression could not be parsed; entity inference was ` +
1917
+ `skipped (${entityHint}, or backtick-quote a reserved ` +
1918
+ `qualifier, such as \`Date\`.amount)` :
1919
+ `${ctx}: expression references no known entity; ` +
1920
+ `it may not be placeable downstream`,
1921
+ };
1922
+ }
1923
+ return { referenced };
1924
+ }
1925
+ // Every warning a load gives about `model`'s entities and metrics, in the
1926
+ // order a load gives them: each concrete entity's missing key, each field's
1927
+ // expression warning, then each metric's expression warning and its warning
1928
+ // when it names no entity. A metric a profile excludes gives none. With
1929
+ // `profiled`, the profile check reports a field or metric its tables cannot
1930
+ // run, so no warning repeats it. A profile changes only these; a load's other
1931
+ // warnings, about actions, come after them.
1932
+ function entityAndMetricWarnings(model, preferred = exports.DEFAULT_DIALECT, profiled = false) {
1933
+ const entities = model.entities ?? [];
1934
+ const out = entities.map(keyWarning).filter((w) => !!w);
1935
+ out.push(...fieldWarnings(model, preferred, profiled));
1936
+ const entityNames = entities.map(e => e.name);
1937
+ const impliedDialect = (0, resolve_profiles_1.sourcesDialect)(model);
1938
+ const excluded = new Set(model.excludedMetrics);
1939
+ for (const m of model.metrics ?? []) {
1940
+ if (excluded.has(m.name))
1941
+ continue;
1942
+ const { referenced, warning: mw } = inferMetricEntities(m, entityNames, preferred, impliedDialect, model.version);
1943
+ const w = metricWarning(m, [m.authoredEntity, ...referenced], entities, preferred, profiled);
1944
+ if (w)
1945
+ out.push(w);
1946
+ if (mw)
1947
+ out.push(mw);
1948
+ }
1949
+ return [...new Set(out)];
1950
+ }
1951
+ // Reads and validates the top-level `version`. It is REQUIRED and must be one
1952
+ // of the accepted values: the version selects which extension surface is legal,
1953
+ // so it cannot be guessed, and a missing or unrecognized version is a hard load
1954
+ // error rather than a warning (agreed with Dmitri).
1955
+ function readVersion(doc) {
1956
+ const version = (doc && typeof doc === 'object') ? doc.version : undefined;
1957
+ if (typeof version !== 'string' || version.length === 0) {
1958
+ throw new SemanticModelError(`Semantic model load error: missing 'version'; declare ` +
1959
+ `'${exports.OSSIE_VERSION}' (vanilla Ossie) or '${exports.GOOGLE_VERSION}' (the ` +
1960
+ `extended profile) at the top level.`);
1961
+ }
1962
+ if (!isFormatVersion(version)) {
1963
+ throw new SemanticModelError(`Semantic model load error: unknown version ` +
1964
+ `'${version}'; expected '${exports.OSSIE_VERSION}' or '${exports.GOOGLE_VERSION}'.`);
1965
+ }
1966
+ return version;
1967
+ }
1968
+ // Rejects names that appear more than once within a scope. Uniqueness is
1969
+ // required for a valid graph: a duplicate dataset, field, metric, or
1970
+ // relationship name makes the generated nodes, properties, or edges ambiguous,
1971
+ // so a duplicate is a hard load error (agreed with Dmitri) rather than a
1972
+ // warning. With `ignoreCase`, two names that differ only in case are rejected
1973
+ // too, naming the first-seen spelling.
1974
+ function rejectDuplicateNames(names, kind, scope, ignoreCase = false) {
1975
+ const seen = new Map();
1976
+ for (const n of names) {
1977
+ const key = ignoreCase ? n.toLowerCase() : n;
1978
+ const first = seen.get(key);
1979
+ if (first !== undefined) {
1980
+ const collision = first === n ? '' : ` (case-insensitive collision with '${first}')`;
1981
+ throw new SemanticModelError(`${scope}: duplicate ${kind} '${n}'${collision}; names must be unique.`);
1982
+ }
1983
+ seen.set(key, n);
1984
+ }
1985
+ }
1986
+ // Rejects a name that breaks its kind's pattern or length limit (see
1987
+ // names.ts), naming the scope it is in when one is given.
1988
+ function rejectInvalidName(kind, name, scope) {
1989
+ const problem = (0, names_1.nameProblem)(kind, name);
1990
+ if (problem) {
1991
+ throw new SemanticModelError(scope ? `${scope}: ${problem}` : problem);
1992
+ }
1993
+ }
1994
+ function convertModel(m, version, opts, warnings) {
1995
+ const dialect = opts.dialect ?? exports.DEFAULT_DIALECT;
1996
+ rejectInvalidName('model', m.name);
1997
+ const block = readGoogleBlocks(m.custom_extensions, version, 'model', `model '${m.name}'`);
1998
+ // A dataset's GOOGLE blocks are read before the bindings are checked, so a
1999
+ // dataset that declares `abstract` in one is told that vanilla has no
2000
+ // abstract dataset, even when it names no `source`.
2001
+ const datasetBlocks = m.datasets.map(ds => readGoogleBlocks(ds.custom_extensions, version, 'dataset', `dataset '${ds.name}'`));
2002
+ if (version === exports.OSSIE_VERSION) {
2003
+ requireVanillaBindings(m);
2004
+ }
2005
+ const entities = m.datasets.map((ds, i) => convertDataset(ds, datasetBlocks[i], version, opts, warnings, dialect));
2006
+ rejectDuplicateNames(entities.map(e => e.name), 'dataset name', `model '${m.name}'`, true);
2007
+ const entityNames = entities.map(e => e.name);
2008
+ const entityNameSet = new Set(entityNames);
2009
+ const relationships = (m.relationships ?? []).map(r => convertRelationship(r, version, entityNameSet));
2010
+ rejectDuplicateNames(relationships.map(r => r.name), 'relationship name', `model '${m.name}'`, true);
2011
+ // A field is judged on each entity that inherits it too, so its warning
2012
+ // waits until every entity is read.
2013
+ warnings.push(...fieldWarnings({ name: m.name, entities, relationships, metrics: [] }, dialect, !!opts.fieldTextChecked));
2014
+ const impliedDialect = (0, resolve_profiles_1.sourcesDialect)({ entities });
2015
+ const metrics = (m.metrics ?? []).map(mt => convertMetric(mt, version, entities, warnings, dialect, impliedDialect));
2016
+ rejectDuplicateNames(metrics.map(mt => mt.name), 'metric name', `model '${m.name}'`, true);
2017
+ // Actions name both entities and relationships -- as affected concepts, and
2018
+ // as the concept a parameter projects its definition from -- so they are
2019
+ // converted after each is known.
2020
+ //
2021
+ // The lookup is the one validate uses, so a name resolves the same way in
2022
+ // both places, inheritance included. Resolving THROWS on an `extends` naming
2023
+ // an entity the model does not declare, which this loader is not the place to
2024
+ // report: it accepts such a model deliberately, and validate names the
2025
+ // failure once per model. So an unresolved view stands in -- but it is a view
2026
+ // in which NO entity has an inherited field, because the fallback drops
2027
+ // `extends` model-wide rather than from the one entity that broke. Warning
2028
+ // off it would tell an author that `Savings` does not declare `balance` when
2029
+ // `Savings extends Account` and `Account` declares it, sending them to fix
2030
+ // a parameter that is correct. So the projection warnings are withheld while
2031
+ // the view is degraded, and validate's one accurate line stands alone.
2032
+ const relationshipNameSet = new Set(relationships.map(r => r.name));
2033
+ let concepts;
2034
+ let inheritanceResolved = true;
2035
+ try {
2036
+ concepts =
2037
+ (0, resolve_inheritance_1.declaredConceptFields)({ name: m.name, entities, relationships, metrics });
2038
+ }
2039
+ catch {
2040
+ inheritanceResolved = false;
2041
+ concepts = (0, resolve_inheritance_1.declaredConceptFields)({
2042
+ name: m.name,
2043
+ entities: entities.map(e => ({ ...e, extends: undefined })),
2044
+ relationships,
2045
+ metrics,
2046
+ });
2047
+ }
2048
+ const inheritedProblem = inheritanceResolved ?
2049
+ inheritedNoSqlProblems(entities, concepts)[0] :
2050
+ undefined;
2051
+ if (inheritedProblem) {
2052
+ throw new SemanticModelError(`Semantic model load error: model '${m.name}': ${inheritedProblem}`);
2053
+ }
2054
+ const actions = (m.actions ?? [])
2055
+ .map(a => convertAction(a, concepts, entityNameSet, relationshipNameSet, warnings, inheritanceResolved));
2056
+ rejectDuplicateNames(actions.map(a => a.name), 'action name', `model '${m.name}'`);
2057
+ const constraints = (m.constraints ?? []).map(convertConstraint);
2058
+ rejectDuplicateNames(constraints.map(c => c.name), 'constraint name', `model '${m.name}'`);
2059
+ const deploymentDocs = m.deployments ?? block.values.deployments ?? [];
2060
+ for (const d of deploymentDocs) {
2061
+ rejectInvalidName('deployment', d.name, `model '${m.name}'`);
2062
+ }
2063
+ const deployments = deploymentDocs.map(d => ({
2064
+ name: d.name,
2065
+ target: d.target,
2066
+ ...(d.profile !== undefined ? { profile: d.profile } : {}),
2067
+ }));
2068
+ rejectDuplicateNames(deployments.map(d => d.name), 'deployment name', `model '${m.name}'`, true);
2069
+ const seenTargets = new Map();
2070
+ for (const d of deployments) {
2071
+ const first = seenTargets.get(d.target);
2072
+ if (first !== undefined) {
2073
+ throw new SemanticModelError(`Semantic model load error: model '${m.name}': deployments '${first}' and '${d.name}' target the same resource '${d.target}'; each deployment must target a distinct resource`);
2074
+ }
2075
+ seenTargets.set(d.target, d.name);
2076
+ }
2077
+ // A blank `deployment_target` names no target, as push reads it.
2078
+ if (!deployments.length && m.deployment_target?.trim()) {
2079
+ deployments.push({ name: 'default', target: m.deployment_target.trim() });
2080
+ }
2081
+ const description = composeDescription(m.description);
2082
+ // `version` records the flavor the document was written in, so a consumer
2083
+ // (e.g. a pull) can return the model in that same flavor.
2084
+ const model = { name: m.name, version, entities, relationships, metrics };
2085
+ if (actions.length)
2086
+ model.actions = actions;
2087
+ if (constraints.length)
2088
+ model.constraints = constraints;
2089
+ if (deployments.length)
2090
+ model.deployments = deployments;
2091
+ if (block.values.profiles?.length)
2092
+ model.profiles = block.values.profiles;
2093
+ if (description)
2094
+ model.description = description;
2095
+ const ai = aiContextOrUndefined(m.ai_context);
2096
+ if (ai)
2097
+ model.aiContext = ai;
2098
+ // The blocks the model keeps are carried verbatim. Under the extended
2099
+ // profile the native `deployments` list (or legacy `deployment_target` key)
2100
+ // is folded into a GOOGLE block appended after them; no authored GOOGLE block
2101
+ // can collide with it, since that flavor rejects one (see rejectGoogleBlock).
2102
+ // Vanilla deployments, read from a GOOGLE block, are not folded.
2103
+ if (block.kept)
2104
+ model.customExtensions = block.kept;
2105
+ const foldedTargets = version === exports.GOOGLE_VERSION ? deployments.map(d => d.target) : [];
2106
+ if (foldedTargets.length) {
2107
+ model.customExtensions = [
2108
+ ...(model.customExtensions ?? []),
2109
+ deploymentTargetsExtension(foldedTargets),
2110
+ ];
2111
+ }
2112
+ return model;
2113
+ }
2114
+ // Converts the dataset `ds`, whose GOOGLE blocks convertModel has read into
2115
+ // `block`.
2116
+ function convertDataset(ds, block, version, opts, warnings, dialect) {
2117
+ rejectInvalidName('entity', ds.name);
2118
+ const ctxLabel = `dataset '${ds.name}'`;
2119
+ // An abstract entity has no physical table, so it carries no source (empty
2120
+ // dataSource) and no key -- both are meaningless for a class never
2121
+ // materialized. Only a concrete entity is parsed/warned for those.
2122
+ const parsedSource = ds.source !== undefined ?
2123
+ parseSource(ds.source, opts, ctxLabel, warnings, /* importHint= */ true) :
2124
+ undefined;
2125
+ const dataSource = parsedSource ? parsedSource.dataSource : '';
2126
+ const keys = ds.primary_key ?? [];
2127
+ const kw = keyWarning({ name: ds.name, keys, uniqueKeys: ds.unique_keys, abstract: ds.abstract });
2128
+ if (kw)
2129
+ warnings.push(kw);
2130
+ const fields = (ds.fields ?? []).map(f => convertField(f, ds.name, version, dialect));
2131
+ rejectDuplicateNames(fields.map(f => f.name), 'field name', `dataset '${ds.name}'`, true);
2132
+ const entity = { name: ds.name, dataSource, keys, fields };
2133
+ if (parsedSource)
2134
+ entity.authoredSource = parsedSource.authoredSource;
2135
+ if (parsedSource?.sourceDialect) {
2136
+ entity.sourceDialect = parsedSource.sourceDialect;
2137
+ }
2138
+ if (ds.unique_keys && ds.unique_keys.length)
2139
+ entity.uniqueKeys = ds.unique_keys;
2140
+ const parents = ds.extends ?? block.values.extends;
2141
+ if (parents && parents.length)
2142
+ entity.extends = parents;
2143
+ if (ds.abstract)
2144
+ entity.abstract = true;
2145
+ const description = composeDescription(ds.description);
2146
+ if (description)
2147
+ entity.description = description;
2148
+ const ai = aiContextOrUndefined(ds.ai_context);
2149
+ if (ai)
2150
+ entity.aiContext = ai;
2151
+ if (block.kept)
2152
+ entity.customExtensions = block.kept;
2153
+ return entity;
2154
+ }
2155
+ // Resolves an authored `datatype` against the document's flavor: vanilla takes
2156
+ // only the canonical spelling, the Google flavor any casing. An omitted
2157
+ // datatype and `Opaque` both mean "no type" and come back undefined. A value
2158
+ // the flavor does not accept is a load error naming the flavor in force, since
2159
+ // the same string can be valid in one flavor and not the other.
2160
+ function resolveDataType(raw, version, ctx) {
2161
+ const result = (0, ir_1.normalizeDataType)(raw, version);
2162
+ if (!result.ok) {
2163
+ const accepted = version === exports.GOOGLE_VERSION ?
2164
+ 'any casing of' :
2165
+ 'exactly one of';
2166
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: datatype '${raw}' is not valid ` +
2167
+ `in a '${version}' document; expected ${accepted} ` +
2168
+ `${ir_1.DATA_TYPES.join(', ')}.`);
2169
+ }
2170
+ return result.type;
2171
+ }
2172
+ function rejectVanillaShortFormExpression(expr, ctx) {
2173
+ if (typeof expr !== 'string')
2174
+ return;
2175
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: a '${exports.OSSIE_VERSION}' document ` +
2176
+ `does not support the bare-string 'expression' shorthand. Write the ` +
2177
+ `per-dialect form instead:\n` +
2178
+ ` expression:\n` +
2179
+ ` dialects:\n` +
2180
+ ` - dialect: ANSI_SQL\n` +
2181
+ ` expression: ${JSON.stringify(expr)}\n` +
2182
+ `or set the document version to '${exports.GOOGLE_VERSION}'.`);
2183
+ }
2184
+ function convertExpression(raw, version, dialect, ctx) {
2185
+ if (version === exports.OSSIE_VERSION) {
2186
+ rejectVanillaShortFormExpression(raw, ctx);
2187
+ }
2188
+ if (typeof raw === 'string') {
2189
+ return {
2190
+ dialects: [{ dialect: 'ANSI_SQL', expression: raw }],
2191
+ stringForm: true,
2192
+ picked: { expression: raw },
2193
+ };
2194
+ }
2195
+ return {
2196
+ dialects: raw.dialects.map(d => ({ dialect: d.dialect, expression: d.expression })),
2197
+ stringForm: false,
2198
+ picked: pickDialect(raw, dialect),
2199
+ };
2200
+ }
2201
+ // The binding a field takes from its expression: every dialect text, and the
2202
+ // texts picked for `dialect`.
2203
+ function fieldExpression(raw, version, dialect, ctx) {
2204
+ const { dialects, stringForm, picked } = convertExpression(raw, version, dialect, ctx);
2205
+ const out = { dialects, stringForm };
2206
+ if (picked.expression !== undefined)
2207
+ out.expression = picked.expression;
2208
+ if (picked.importedExpression !== undefined) {
2209
+ out.importedExpression = picked.importedExpression;
2210
+ out.importedDialect = picked.importedDialect;
2211
+ }
2212
+ return out;
2213
+ }
2214
+ // A field expression written outside a model document, such as in a profile
2215
+ // file, checked against the loader's schema and read as a load reads a field's
2216
+ // expression, with the texts picked for `dialect`. The string form means
2217
+ // ANSI_SQL in either flavor. Throws naming `where` and every problem the schema
2218
+ // finds, or naming `where` when the expression holds no SQL for a field of
2219
+ // `entity`.
2220
+ function parseFieldExpression(raw, where, dialect, entity) {
2221
+ return readFieldExpression(raw, where, dialect, doc => noSqlProblem(doc, entity));
2222
+ }
2223
+ // A field expression read as parseFieldExpression reads it, with `problemOf`
2224
+ // saying why one the schema accepts holds no SQL.
2225
+ function readFieldExpression(raw, where, dialect, problemOf) {
2226
+ if (typeof raw !== 'string' &&
2227
+ (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
2228
+ throw new SemanticModelError(`${where}: 'expression' must be a string or a list of dialects`);
2229
+ }
2230
+ const result = typeof raw === 'string' ?
2231
+ z.string().safeParse(raw) :
2232
+ expressionObjectSchema.safeParse(raw);
2233
+ if (!result.success) {
2234
+ throw new SemanticModelError(`${where}: 'expression': ${issueList(result.error)}`);
2235
+ }
2236
+ const problem = problemOf(result.data);
2237
+ if (problem) {
2238
+ throw new SemanticModelError(`${where}: ${problem}; write the field's SQL`);
2239
+ }
2240
+ return fieldExpression(result.data, exports.GOOGLE_VERSION, dialect, where);
2241
+ }
2242
+ // A profile's binding of a field, read as parseFieldExpression reads a
2243
+ // profile file's expression. A binding built in memory with no dialect list
2244
+ // is read as expressionDocOf gives it, and is checked for SQL as
2245
+ // noSqlProblemOf checks it, since a dialect list cannot hold an imported text
2246
+ // with no imported dialect, or one marked BIGQUERY beside a target text.
2247
+ // Throws as parseFieldExpression does.
2248
+ function parseFieldBinding(binding, where, dialect, entity) {
2249
+ const doc = expressionDocOf(binding);
2250
+ if (binding.dialects?.length) {
2251
+ return parseFieldExpression(doc, where, dialect, entity);
2252
+ }
2253
+ return readFieldExpression(doc, where, dialect, () => noSqlProblemOf(binding, entity));
2254
+ }
2255
+ function convertField(f, entityName, version, dialect) {
2256
+ rejectInvalidName('field', f.name, `dataset '${entityName}'`);
2257
+ // `label`, `dimension`, and AI-first annotations are carried structurally on
2258
+ // the IR (not folded into `description`) so an emitter can route each to its
2259
+ // own destination and a 1P round-trip stays lossless.
2260
+ const description = composeDescription(f.description);
2261
+ const ctx = `field '${entityName}.${f.name}'`;
2262
+ const field = { name: f.name };
2263
+ if (f.expression !== undefined) {
2264
+ Object.assign(field, fieldExpression(f.expression, version, dialect, ctx));
2265
+ }
2266
+ // else: an unbound field. It carries meaning (label, description, AI context)
2267
+ // but names no physical column. A field is unbound exactly when it has no
2268
+ // `expression` -- there is no separate flag. The availability pass
2269
+ // (pruneUnavailable) drops each unbound field, and whatever depends on it,
2270
+ // before generation, so one logical model can serve stores that bind
2271
+ // different subsets of columns. validateDeployments rejects one on a
2272
+ // concrete leaf entity when a graph deployment uses the model file's own
2273
+ // bindings.
2274
+ const type = resolveDataType(f.datatype, version, ctx);
2275
+ if (type)
2276
+ field.type = type;
2277
+ if (f.label)
2278
+ field.label = f.label;
2279
+ if (f.dimension) {
2280
+ field.dimension = {};
2281
+ if (f.dimension.is_time !== undefined)
2282
+ field.dimension.isTime = f.dimension.is_time;
2283
+ }
2284
+ if (description)
2285
+ field.description = description;
2286
+ const ai = aiContextOrUndefined(f.ai_context);
2287
+ if (ai)
2288
+ field.aiContext = ai;
2289
+ rejectGoogleBlock(f.custom_extensions, version, 'field', ctx);
2290
+ const ce = toCustomExtensions(f.custom_extensions);
2291
+ if (ce)
2292
+ field.customExtensions = ce;
2293
+ return field;
2294
+ }
2295
+ // Maps an OSI foreign-key relationship onto the IR edge. `source.columns` are
2296
+ // the FK columns on the `from` table (`from_columns`); `destination.columns`
2297
+ // are the referenced key columns on the `to` table (`to_columns`), paired
2298
+ // positionally. A logical relationship carries no columns (both endpoints
2299
+ // empty); a graph push requires them and rejects a column-less edge (see
2300
+ // validatePushRequirements). The source entity's own primary key is not
2301
+ // duplicated here -- downstream consumers look it up from the entity. A
2302
+ // malformed relationship (an endpoint not declared in the model, or mismatched
2303
+ // column arity) is a hard error, not a warning: the resulting edge would be
2304
+ // structurally invalid.
2305
+ function convertRelationship(r, version, entityNames) {
2306
+ rejectInvalidName('relationship', r.name);
2307
+ const ctx = `relationship '${r.name}'`;
2308
+ rejectVanillaRelationshipDescription(r, version);
2309
+ if (!entityNames.has(r.from)) {
2310
+ throw new SemanticModelError(`${ctx}: ${undefinedEndpointProblem('from', r.from)}`);
2311
+ }
2312
+ if (!entityNames.has(r.to)) {
2313
+ throw new SemanticModelError(`${ctx}: ${undefinedEndpointProblem('to', r.to)}`);
2314
+ }
2315
+ const fromColumns = r.from_columns ?? [];
2316
+ const toColumns = r.to_columns ?? [];
2317
+ if (fromColumns.length !== toColumns.length) {
2318
+ throw new SemanticModelError(joinLengthMessage(r.name, fromColumns.length, toColumns.length));
2319
+ }
2320
+ const relationship = {
2321
+ name: r.name,
2322
+ source: { entity: r.from, columns: fromColumns },
2323
+ destination: { entity: r.to, columns: toColumns },
2324
+ };
2325
+ const block = readGoogleBlocks(r.custom_extensions, version, 'relationship', ctx);
2326
+ const description = composeDescription(r.description ?? block.values.description);
2327
+ if (description)
2328
+ relationship.description = description;
2329
+ const ai = aiContextOrUndefined(r.ai_context);
2330
+ if (ai)
2331
+ relationship.aiContext = ai;
2332
+ if (block.kept)
2333
+ relationship.customExtensions = block.kept;
2334
+ return relationship;
2335
+ }
2336
+ function convertMetric(mt, version, entities, warnings, dialect, impliedDialect) {
2337
+ rejectInvalidName('metric', mt.name);
2338
+ const ctx = `metric '${mt.name}'`;
2339
+ const entityNames = entities.map(e => e.name);
2340
+ const block = readGoogleBlocks(mt.custom_extensions, version, 'metric', ctx);
2341
+ // The anchor the author wrote, natively or in a vanilla GOOGLE block.
2342
+ const authored = mt.entity ?? block.values.entity;
2343
+ const { dialects, stringForm, picked } = convertExpression(mt.expression, version, dialect, ctx);
2344
+ // Infer referenced entities across all dialect entries of the metric.
2345
+ const { referenced, warning: mw } = inferMetricEntities({
2346
+ name: mt.name,
2347
+ authoredEntity: authored,
2348
+ expression: picked.expression,
2349
+ importedExpression: picked.importedExpression,
2350
+ importedDialect: picked.importedDialect,
2351
+ dialects,
2352
+ }, entityNames, dialect, impliedDialect, version);
2353
+ const ew = metricWarning({ name: mt.name, dialects, stringForm }, [authored, ...referenced], entities, dialect, false);
2354
+ if (ew)
2355
+ warnings.push(ew);
2356
+ if (mw)
2357
+ warnings.push(mw);
2358
+ const metric = { name: mt.name, dialects, stringForm };
2359
+ if (authored !== undefined) {
2360
+ // Like a relationship endpoint, an anchor must name a dataset the model
2361
+ // declares; a dangling one would attach the metric to nothing downstream.
2362
+ if (!entityNames.includes(authored)) {
2363
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: 'entity' names '${authored}', ` +
2364
+ `which is not a dataset in this model.`);
2365
+ }
2366
+ // What the author wrote wins over inference. `authoredEntity` records that
2367
+ // it was written, so a pull can write it back rather than re-infer it.
2368
+ metric.authoredEntity = authored;
2369
+ metric.entity = authored;
2370
+ }
2371
+ else if (referenced.length === 1) {
2372
+ // Attach only when the reference is unambiguous; a cross-entity metric is
2373
+ // left unattached (its qualifiers stay inline in the expression for
2374
+ // consumers).
2375
+ metric.entity = referenced[0];
2376
+ }
2377
+ if (picked.expression !== undefined)
2378
+ metric.expression = picked.expression;
2379
+ if (picked.importedExpression !== undefined) {
2380
+ metric.importedExpression = picked.importedExpression;
2381
+ metric.importedDialect = picked.importedDialect;
2382
+ }
2383
+ const type = resolveDataType(mt.datatype, version, ctx);
2384
+ if (type)
2385
+ metric.type = type;
2386
+ const description = composeDescription(mt.description);
2387
+ if (description)
2388
+ metric.description = description;
2389
+ const ai = aiContextOrUndefined(mt.ai_context);
2390
+ if (ai)
2391
+ metric.aiContext = ai;
2392
+ if (block.kept)
2393
+ metric.customExtensions = block.kept;
2394
+ return metric;
2395
+ }
2396
+ // Converts a constraint document to the IR. The judgment is kept verbatim --
2397
+ // it is the text a judge is handed. Description and AI context round-trip like
2398
+ // everywhere else. A document that states `expression` never reaches here;
2399
+ // rejectExpressionBody fails the parse.
2400
+ function convertConstraint(c) {
2401
+ const constraint = { name: c.name };
2402
+ if (c.judgment !== undefined)
2403
+ constraint.judgment = c.judgment;
2404
+ if (c.on_violation)
2405
+ constraint.onViolation = c.on_violation;
2406
+ if (c.severity)
2407
+ constraint.severity = c.severity;
2408
+ const description = composeDescription(c.description);
2409
+ if (description)
2410
+ constraint.description = description;
2411
+ const ai = aiContextOrUndefined(c.ai_context);
2412
+ if (ai)
2413
+ constraint.aiContext = ai;
2414
+ return constraint;
2415
+ }
2416
+ // Maps an authored action onto the IR. Parameter types are resolved against the
2417
+ // model's entities; a type that is neither a known entity nor a scalar datatype
2418
+ // is kept verbatim and warned, so the loader stays lenient (a strict `validate`
2419
+ // gate can promote these later).
2420
+ function convertAction(a, concepts, entityNames, relationshipNames, warnings, inheritanceResolved = true) {
2421
+ const parameters = (a.parameters ?? [])
2422
+ .map(p => convertParameter(p, a.name, concepts, warnings, inheritanceResolved));
2423
+ // Parameter names address the inputs at dispatch, so a collision is as
2424
+ // ambiguous as a duplicate field or metric name -- reject it the same way.
2425
+ rejectDuplicateNames(parameters.map(p => p.name), 'parameter name', `action '${a.name}'`);
2426
+ const action = {
2427
+ name: a.name,
2428
+ parameters,
2429
+ };
2430
+ // Absent when no binding supplies one -- the action is declared but not
2431
+ // performable here.
2432
+ if (a.executor != null) {
2433
+ action.executor = convertExecutor(a.executor);
2434
+ }
2435
+ if (a.guards?.length) {
2436
+ // A repeated guard would check one constraint twice while reading as two
2437
+ // rules, so it is rejected like any other duplicate name.
2438
+ rejectDuplicateNames(a.guards, 'guard', `action '${a.name}'`);
2439
+ action.guards = [...a.guards];
2440
+ }
2441
+ if (a.affects?.length) {
2442
+ const affects = a.affects.map(e => toAffectedConcept(e, a.name, entityNames, relationshipNames, warnings));
2443
+ rejectDuplicateAffectedConcepts(affects, a.name);
2444
+ warnMixedAffectsPrecision(affects, a.name, warnings);
2445
+ action.affects = affects;
2446
+ }
2447
+ const description = composeDescription(a.description);
2448
+ if (description)
2449
+ action.description = description;
2450
+ const ai = aiContextOrUndefined(a.ai_context);
2451
+ if (ai)
2452
+ action.aiContext = ai;
2453
+ const ce = toCustomExtensions(a.custom_extensions);
2454
+ if (ce)
2455
+ action.customExtensions = ce;
2456
+ return action;
2457
+ }
2458
+ // Maps one authored entry onto the IR, normalizing the two shapes to one.
2459
+ //
2460
+ // A bare name and a record with no `operation` mean the same thing, so both
2461
+ // land as an entry whose operation is unset. Whether `Order` is an entity or
2462
+ // an edge is neither authored nor stored -- it is a fact about the model, and
2463
+ // nothing about the entry depends on it -- so the names are consulted only to
2464
+ // warn about one that matches nothing, the way an unresolvable parameter type
2465
+ // does. validate is where it becomes an error.
2466
+ function toAffectedConcept(e, actionName, entityNames, relationshipNames, warnings) {
2467
+ const concept = typeof e === 'string' ? e : e.concept;
2468
+ const affected = { concept };
2469
+ if (!entityNames.has(concept) && !relationshipNames.has(concept)) {
2470
+ warnings.push(`action '${actionName}': affects names '${concept}', which is ` +
2471
+ `neither an entity nor a relationship in this model.`);
2472
+ }
2473
+ if (typeof e !== 'string') {
2474
+ if (e.operation !== undefined)
2475
+ affected.operation = e.operation;
2476
+ if (e.fields?.length) {
2477
+ // Naming one field twice says nothing the single mention does not.
2478
+ rejectDuplicateNames(e.fields, 'affected field', `action '${actionName}' affects '${concept}'`);
2479
+ affected.fields = [...e.fields];
2480
+ }
2481
+ }
2482
+ return affected;
2483
+ }
2484
+ // Two entries on the same concept with the same operation are one entry
2485
+ // written twice. Rejected rather than warned, like a repeated guard: it states
2486
+ // no second fact, and leaving it in would put a duplicate record in the catalog.
2487
+ function rejectDuplicateAffectedConcepts(affects, actionName) {
2488
+ rejectDuplicateNames(affects.map(e => `${e.concept}/${e.operation ?? '(unspecified)'}`), 'affected concept', `action '${actionName}'`);
2489
+ }
2490
+ // An entry with no operation covers the whole concept, so pairing it with a
2491
+ // specific operation on that same concept says both "in some unstated way" and
2492
+ // "in this exact way". That is almost always a half-finished edit -- the author
2493
+ // added precision to one line and left the coarse one behind -- but it is not
2494
+ // contradictory, so it warns rather than fails.
2495
+ function warnMixedAffectsPrecision(affects, actionName, warnings) {
2496
+ const unspecified = new Set(affects.filter(e => !e.operation).map(e => e.concept));
2497
+ if (!unspecified.size)
2498
+ return;
2499
+ for (const concept of new Set(affects.filter(e => e.operation && unspecified.has(e.concept))
2500
+ .map(e => e.concept))) {
2501
+ warnings.push(`action '${actionName}': affects lists '${concept}' both with an ` +
2502
+ `operation and without one. The bare entry already covers every ` +
2503
+ `operation on '${concept}'; drop it, or give it an operation too.`);
2504
+ }
2505
+ }
2506
+ // Maps one authored parameter onto the IR, resolving a derived one against the
2507
+ // concept it projects from. See ir.ActionParameter for the two forms.
2508
+ //
2509
+ // What is a THROW here and what is a warning follows the split the rest of this
2510
+ // file keeps. A contradiction in the parameter's own shape -- a `type` beside a
2511
+ // `concept`, half a reference, no way to work out a name -- makes the document
2512
+ // unloadable, because there is no reading of it to carry forward and the
2513
+ // author's intent is not guessable. A reference that is well formed but does
2514
+ // not resolve is kept verbatim and warned, the way an unresolvable `affects`
2515
+ // concept is, and validate.ts promotes it to the error that stops a push.
2516
+ //
2517
+ // A referenced field that is UNBOUND -- no `expression`, so no column under
2518
+ // this binding -- resolves like any other. That is deliberate. A metric or a
2519
+ // relationship reading an unbound field is unavailable because it needs a
2520
+ // column to read; a parameter needs only the logical definition, which an
2521
+ // unbound field has in full. So a derived parameter never makes its action
2522
+ // unavailable, and a logical-only model with no bindings at all publishes
2523
+ // exactly as it would with them.
2524
+ function convertParameter(p, actionName, concepts, warnings, inheritanceResolved = true) {
2525
+ const where = `action '${actionName}'`;
2526
+ if (p.type !== undefined && p.datatype !== undefined) {
2527
+ throw new SemanticModelError(`${where}: parameter ${p.name ? `'${p.name}' ` : ''}states both ` +
2528
+ `'type' and 'datatype'; set one or the other (they are the same key).`);
2529
+ }
2530
+ const statedType = p.type ?? p.datatype;
2531
+ let conceptName = p.concept;
2532
+ let fieldName = p.field;
2533
+ // Support `field: Account.accountId` as a concise one-key shorthand for
2534
+ // `{concept: Account, field: accountId}`, matching the `Entity.field`
2535
+ // syntax used in metric expressions and constraint judgments.
2536
+ if (conceptName === undefined && fieldName !== undefined &&
2537
+ fieldName.includes('.')) {
2538
+ const parts = fieldName.split('.');
2539
+ if (parts.length === 2 && parts[0].length > 0 && parts[1].length > 0) {
2540
+ conceptName = parts[0];
2541
+ fieldName = parts[1];
2542
+ }
2543
+ else {
2544
+ throw new SemanticModelError(`${where}: parameter ${p.name ? `'${p.name}' ` : ''}states 'field: ${fieldName}', which is not a valid 'Concept.field' reference; ` +
2545
+ `expected two dot-separated parts such as 'field: Order.orderId'.`);
2546
+ }
2547
+ }
2548
+ else if (conceptName !== undefined && fieldName?.includes('.')) {
2549
+ throw new SemanticModelError(`${where}: parameter ${p.name ? `'${p.name}' ` : ''}states 'concept: ${conceptName}' alongside 'field: ${fieldName}', which already contains a concept prefix. ` +
2550
+ `Write either {concept: ${conceptName}, field: ${fieldName.split('.').pop()}} or {field: ${fieldName}}.`);
2551
+ }
2552
+ const hasConcept = conceptName !== undefined;
2553
+ const hasField = fieldName !== undefined;
2554
+ if (hasConcept !== hasField) {
2555
+ const given = hasConcept ? 'concept' : 'field';
2556
+ const missing = hasConcept ? 'field' : 'concept';
2557
+ throw new SemanticModelError(`${where}: parameter ${p.name ? `'${p.name}' ` : ''}states '${given}' without '${missing}'. A parameter projected from a field ` +
2558
+ `states both, as two separate keys: ` +
2559
+ `{concept: Order, field: orderId} (or 'field: Order.orderId').`);
2560
+ }
2561
+ if (hasConcept && statedType !== undefined) {
2562
+ const typeKey = p.datatype !== undefined ? 'datatype' : 'type';
2563
+ const projectionDesc = p.concept !== undefined ?
2564
+ `'concept: ${p.concept}' and 'field: ${p.field}'` :
2565
+ `'field: ${p.field}'`;
2566
+ throw new SemanticModelError(`${where}: parameter ${p.name ? `'${p.name}' ` : ''}states a '${typeKey}' alongside ${projectionDesc}. A ` +
2567
+ `projected parameter takes its type from the field, so stating one ` +
2568
+ `here is a second place for it to be wrong: if the type is wrong, ` +
2569
+ `fix the field; if the call needs a different one, cast in the DML.`);
2570
+ }
2571
+ const name = p.name ?? fieldName;
2572
+ if (name === undefined) {
2573
+ throw new SemanticModelError(`${where}: a parameter states neither a 'name' nor a 'field' to take ` +
2574
+ `one from.`);
2575
+ }
2576
+ const param = { name };
2577
+ if (hasConcept) {
2578
+ // Kept whether or not it resolves: it is what the author wrote, and a
2579
+ // round-trip that dropped it would turn a projected parameter into a
2580
+ // standalone one carrying a type nobody stated.
2581
+ param.concept = conceptName;
2582
+ param.field = fieldName;
2583
+ }
2584
+ const concept = hasConcept ? concepts.get(conceptName) : undefined;
2585
+ const field = concept?.fields.get(fieldName);
2586
+ if (hasConcept && !concept) {
2587
+ warnings.push(`${where}: parameter '${name}' projects from '${conceptName}', which ` +
2588
+ `is neither an entity nor a relationship in this model.`);
2589
+ }
2590
+ else if (hasConcept && !field && inheritanceResolved) {
2591
+ // Gated: with inheritance unresolved every entity looks like it declares
2592
+ // only its own fields, so this would fire on projections that are fine.
2593
+ warnings.push(`${where}: parameter '${name}' projects field '${fieldName}', which ${concept.kind} '${conceptName}' does not declare.`);
2594
+ }
2595
+ // The field's definition flows down; the parameter's own wording wins over
2596
+ // it. The type never does -- a projected parameter states none at all.
2597
+ if (field?.type !== undefined)
2598
+ param.type = field.type;
2599
+ if (statedType !== undefined)
2600
+ param.type = statedType;
2601
+ const description = p.description ?? field?.description;
2602
+ if (description !== undefined)
2603
+ param.description = description;
2604
+ const label = p.label ?? field?.label;
2605
+ if (label !== undefined)
2606
+ param.label = label;
2607
+ const ai = aiContextOrUndefined(p.ai_context) ?? field?.aiContext;
2608
+ if (ai)
2609
+ param.aiContext = ai;
2610
+ // Never inherited. A field says what a thing HAS; whether a CALL must supply
2611
+ // a value for it is the action's own business.
2612
+ if (p.required !== undefined)
2613
+ param.required = p.required;
2614
+ if (p.default !== undefined)
2615
+ param.default = p.default;
2616
+ if (param.type === undefined) {
2617
+ if (!hasConcept) {
2618
+ warnings.push(`${where}: parameter '${name}' states no 'type' and projects no ` +
2619
+ `field. A parameter with no field behind it declares its own ` +
2620
+ `scalar type (${ir_1.DATA_TYPES.join('/')}).`);
2621
+ }
2622
+ else if (field && inheritanceResolved) {
2623
+ // The projection resolved and the field is simply untyped, which is a
2624
+ // third case and not the one above: the author wrote the parameter
2625
+ // correctly, so the fix is on the field. A reference that did NOT
2626
+ // resolve was already warned about above and says why there is no type.
2627
+ warnings.push(`${where}: parameter '${name}' projects field '${conceptName}.${fieldName}', which declares no datatype, so the parameter has ` +
2628
+ `none either. Give that field a scalar type (${ir_1.DATA_TYPES.join('/')}).`);
2629
+ }
2630
+ }
2631
+ else if (!ir_1.DATA_TYPES.includes(param.type)) {
2632
+ // A type naming a concept is the old entity-reference spelling, which has
2633
+ // no meaning now: a parameter carries a value, so the way to take one FROM
2634
+ // an entity is to name the field it comes from.
2635
+ const named = concepts.get(param.type);
2636
+ warnings.push(named ?
2637
+ `${where}: parameter '${name}' is typed '${param.type}', which ` +
2638
+ `is ${named.kind === 'entity' ? 'an entity' :
2639
+ 'a relationship'} rather ` +
2640
+ `than a scalar datatype. A parameter carries a value; to ` +
2641
+ `take one from ${param.type}, project it with ` +
2642
+ `{concept: ${param.type}, field: <field>}.` :
2643
+ `${where}: parameter '${name}' type '${param.type}' is not a ` +
2644
+ `scalar datatype (${ir_1.DATA_TYPES.join('/')})`);
2645
+ }
2646
+ return param;
2647
+ }
2648
+ // A schema error's problems on one line, each led by its path.
2649
+ function issueList(error) {
2650
+ return error.issues
2651
+ .map(i => (i.path.length ? `${i.path.join('.')}: ` : '') + i.message)
2652
+ .join('; ');
2653
+ }
2654
+ // An executor written outside a model document, such as in a profile file,
2655
+ // checked against the loader's schema and converted as a load converts one.
2656
+ // Throws naming `where` and every problem the schema finds.
2657
+ function parseExecutor(raw, where) {
2658
+ const result = executorSchema.safeParse(raw);
2659
+ if (!result.success) {
2660
+ throw new SemanticModelError(`${where} 'executor': ${issueList(result.error)}`);
2661
+ }
2662
+ return convertExecutor(result.data);
2663
+ }
2664
+ // Normalizes the open format's single-key executor object to the IR's tagged
2665
+ // union. The schema already guaranteed exactly one kind is present.
2666
+ function convertExecutor(ex) {
2667
+ if (ex.mcp)
2668
+ return { kind: 'mcp', mcp: { ...ex.mcp } };
2669
+ if (ex.rest)
2670
+ return { kind: 'rest', rest: { ...ex.rest } };
2671
+ if (ex.sql) {
2672
+ return {
2673
+ kind: 'sql',
2674
+ sql: { statements: ex.sql.statements.map(t => t.trim()) },
2675
+ };
2676
+ }
2677
+ // The schema's refinement guarantees one of the four kinds is set.
2678
+ return { kind: 'grpc', grpc: { ...ex.grpc } };
2679
+ }
2680
+ function pickDialect(expr, preferred) {
2681
+ const preferredUpper = preferred.toUpperCase();
2682
+ const byName = (name) => expr.dialects.find(d => d.dialect === name.toUpperCase());
2683
+ // The original vendor variant, if any: the first dialect that is neither the
2684
+ // target nor the portable canonical. Kept as `importedExpression`.
2685
+ const vendor = expr.dialects.find(d => d.dialect !== preferredUpper && d.dialect !== FALLBACK_DIALECT);
2686
+ const out = {};
2687
+ if (vendor) {
2688
+ out.importedExpression = vendor.expression;
2689
+ out.importedDialect = vendor.dialect;
2690
+ }
2691
+ const exact = byName(preferred);
2692
+ if (exact) {
2693
+ out.expression = exact.expression;
2694
+ return out;
2695
+ }
2696
+ if (fallsBackToAnsi(expr.dialects, preferred)) {
2697
+ out.expression = byName(FALLBACK_DIALECT).expression;
2698
+ return out;
2699
+ }
2700
+ // Neither target nor canonical: keep only the imported vendor form; the
2701
+ // target `expression` awaits a transpile pass. expressionWarning says so.
2702
+ return out;
2703
+ }
2704
+ const MAX_CATALOG_NAME_LENGTH = 4000;
2705
+ const BIGQUERY_TABLE_URI = /^\/\/bigquery\.googleapis\.com\/projects\/([^/]+)\/datasets\/([^/.]+)\/tables\/([^/.]+)$/;
2706
+ const BIGLAKE_TABLE_URI = /^\/\/biglake\.googleapis\.com\/projects\/([^/]+)\/catalogs\/([^/.]+)\/namespaces\/([^/.]+)\/tables\/([^/.]+)$/;
2707
+ const SPANNER_TABLE_URI = /^\/\/spanner\.googleapis\.com\/projects\/([^/]+)\/instances\/([^/]+)\/databases\/([^/]+)\/tables\/[^/]+$/;
2708
+ const ALLOYDB_TABLE_URI = /^\/\/alloydb\.googleapis\.com\/projects\/([^/]+)\/locations\/([^/]+)\/clusters\/([^/]+)\/databases\/([^/]+)\/schemas\/[^/]+\/tables\/[^/]+$/;
2709
+ const LEGACY_ALLOYDB_INSTANCE_URI = /^\/\/alloydb\.googleapis\.com\/projects\/([^/]+)\/locations\/([^/]+)\/clusters\/([^/]+)\/instances\/[^/]+\/databases\/([^/]+)\/tables\/([^/]+)$/;
2710
+ // A Cloud SQL table. A MySQL table sits in a database; a PostgreSQL or SQL
2711
+ // Server table sits in a schema in a database. The shape tells MySQL from the
2712
+ // other two but not PostgreSQL from SQL Server, so the dialect comes from the
2713
+ // table's catalog entry, not from the source (see sourcesDialect).
2714
+ const CLOUDSQL_TABLE_URI = /^\/\/cloudsql\.googleapis\.com\/projects\/([^/]+)\/locations\/([^/]+)\/instances\/([^/]+)\/databases\/[^/]+\/tables\/[^/]+$/;
2715
+ const CLOUDSQL_SCHEMA_TABLE_URI = /^\/\/cloudsql\.googleapis\.com\/projects\/([^/]+)\/locations\/([^/]+)\/instances\/([^/]+)\/databases\/([^/]+)\/schemas\/[^/]+\/tables\/[^/]+$/;
2716
+ // A double quote or a backtick, which no resource name or entry name holds.
2717
+ const QUOTE = /["`]/;
2718
+ // The Cloud SQL table `source` names, or undefined when it names none. A name
2719
+ // with quotes names none, so push reports it without looking it up.
2720
+ function cloudSqlTableOf(source) {
2721
+ const uri = asResourceUri(source.trim());
2722
+ if (QUOTE.test(uri))
2723
+ return undefined;
2724
+ const mysql = uri.match(CLOUDSQL_TABLE_URI);
2725
+ const m = mysql ?? uri.match(CLOUDSQL_SCHEMA_TABLE_URI);
2726
+ if (!m)
2727
+ return undefined;
2728
+ return {
2729
+ project: m[1], location: m[2], instance: m[3], resourceName: uri.slice(2),
2730
+ inSchema: !mysql,
2731
+ };
2732
+ }
2733
+ // The table `source` names, or undefined when it names none of these.
2734
+ function sourceTableOf(source) {
2735
+ const uri = asResourceUri(source.trim());
2736
+ const resourceName = uri.slice(2);
2737
+ let m = uri.match(BIGQUERY_TABLE_URI);
2738
+ if (m) {
2739
+ return {
2740
+ system: 'bigquery', resourceName, project: m[1], dataset: m[2],
2741
+ table: m[3],
2742
+ };
2743
+ }
2744
+ m = uri.match(BIGLAKE_TABLE_URI);
2745
+ if (m)
2746
+ return { system: 'biglake', resourceName, project: m[1], catalog: m[2] };
2747
+ m = uri.match(SPANNER_TABLE_URI);
2748
+ if (m)
2749
+ return { system: 'spanner', resourceName, project: m[1], instance: m[2] };
2750
+ m = uri.match(ALLOYDB_TABLE_URI);
2751
+ if (m)
2752
+ return { system: 'alloydb', resourceName, project: m[1], location: m[2] };
2753
+ return undefined;
2754
+ }
2755
+ // A GCP resource name: a Google Cloud service host and a path, with no leading
2756
+ // `//`, such as `bigquery.googleapis.com/projects/p/datasets/d/tables/t`.
2757
+ const RESOURCE_NAME = /^[a-z][a-z0-9-]*\.googleapis\.com\//;
2758
+ // `value` as a resource URI: a GCP resource name gets the leading `//`, and
2759
+ // anything else is returned as it is. The IR and the checks read a source or a
2760
+ // target as its URI; the resource name the author wrote stays in
2761
+ // `authoredSource` for a source and in `uri` for a target.
2762
+ function asResourceUri(value) {
2763
+ return RESOURCE_NAME.test(value) ? `//${value}` : value;
2764
+ }
2765
+ // `value` as a GCP resource name: a resource URI loses its leading `//`, and
2766
+ // anything else is returned as it is. The inverse of `asResourceUri`, for
2767
+ // messages and output, which show the form an author writes.
2768
+ function asResourceName(value) {
2769
+ const name = value.replace(/^\/\//, '');
2770
+ return RESOURCE_NAME.test(name) ? name : value;
2771
+ }
2772
+ // What to do about a value written as a resource URI: write its GCP resource
2773
+ // name. The source and target messages share it, so they say it alike.
2774
+ function resourceUriFix(uri) {
2775
+ return `a resource URI; write its GCP resource name, without the leading ` +
2776
+ `'//': '${asResourceName(uri)}'`;
2777
+ }
2778
+ // The Knowledge Catalog name prefix of each first-party table, the number of
2779
+ // segments after it, and the GCP resource name those segments give. A
2780
+ // Spanner name's instance config has no place in the resource name.
2781
+ const CATALOG_NAME_FORMS = [
2782
+ ['bigquery:', 3,
2783
+ s => `bigquery.googleapis.com/projects/${s[0]}/datasets/${s[1]}/tables/${s[2]}`],
2784
+ ['biglake:table:', 4,
2785
+ s => `biglake.googleapis.com/projects/${s[0]}/catalogs/${s[1]}/namespaces/${s[2]}/tables/${s[3]}`],
2786
+ ['spanner:', 5,
2787
+ s => `spanner.googleapis.com/projects/${s[0]}/instances/${s[2]}/databases/${s[3]}/tables/${s[4]}`],
2788
+ ['alloydb:', 6,
2789
+ s => `alloydb.googleapis.com/projects/${s[0]}/locations/${s[1]}/clusters/${s[2]}/databases/${s[3]}/schemas/${s[4]}/tables/${s[5]}`],
2790
+ ['cloudsql_mysql:', 5,
2791
+ s => `cloudsql.googleapis.com/projects/${s[0]}/locations/${s[1]}/instances/${s[2]}/databases/${s[3]}/tables/${s[4]}`],
2792
+ ['cloudsql_postgresql:', 6, cloudSqlSchemaTable],
2793
+ ['cloudsql_sqlserver:', 6, cloudSqlSchemaTable],
2794
+ ];
2795
+ function cloudSqlSchemaTable(s) {
2796
+ return `cloudsql.googleapis.com/projects/${s[0]}/locations/${s[1]}/instances/${s[2]}/databases/${s[3]}/schemas/${s[4]}/tables/${s[5]}`;
2797
+ }
2798
+ // What a source written as a resource URI, as a Knowledge Catalog name of a
2799
+ // table kcmd reads, or with quotes in a resource name or in an entry name
2800
+ // outside a system entry group is, and the name to write instead when there
2801
+ // is one; undefined for any other source. The loader rejects such a source,
2802
+ // and pull warns for one an older push stored.
2803
+ function oldSourceForm(source) {
2804
+ const unquoted = unquotedName(source);
2805
+ if (unquoted !== undefined) {
2806
+ const quotes = 'a name with quotes in it; resource names and entry ' +
2807
+ 'names take no quotes';
2808
+ return !unquoted ? quotes :
2809
+ /\s/.test(unquoted) ? `${quotes}, and ${exports.NO_SPACE}` :
2810
+ `${quotes}, so write '${unquoted}'`;
2811
+ }
2812
+ const name = asResourceName(source);
2813
+ if (name !== source && isAcceptedSource(name))
2814
+ return resourceUriFix(source);
2815
+ const built = catalogNameResourceName(source);
2816
+ const plain = built === undefined ? undefined : unquotedName(built) ?? built;
2817
+ const write = "a Knowledge Catalog name; write the table's GCP resource name";
2818
+ if (plain && isAcceptedSource(plain))
2819
+ return `${write}: '${plain}'`;
2820
+ if (!/^[a-z_]+:/.test(source) ||
2821
+ !(isAcceptedSource(source) || isAcceptedSource(built ?? ''))) {
2822
+ return undefined;
2823
+ }
2824
+ return plain && /\s/.test(plain) ?
2825
+ `a Knowledge Catalog name, and ${exports.NO_SPACE}` :
2826
+ write;
2827
+ }
2828
+ // Why no name to write is given when that name would hold a space.
2829
+ exports.NO_SPACE = 'kcmd cannot name a table whose name holds a space';
2830
+ // The resource name of a table in each Google Cloud service a source can
2831
+ // name, with a placeholder for each name in it. A Cloud SQL table has one
2832
+ // form without a schema and one with.
2833
+ exports.TABLE_FORMS = {
2834
+ bigquery: 'bigquery.googleapis.com/projects/<project>/datasets/<dataset>/tables/<table>',
2835
+ spanner: 'spanner.googleapis.com/projects/<project>/instances/<instance>/databases/<database>/tables/<table>',
2836
+ alloydb: 'alloydb.googleapis.com/projects/<project>/locations/<location>/clusters/<cluster>/databases/<database>/schemas/<schema>/tables/<table>',
2837
+ cloudsql: 'cloudsql.googleapis.com/projects/<project>/locations/<location>/instances/<instance>/databases/<database>/tables/<table>',
2838
+ cloudsqlSchema: 'cloudsql.googleapis.com/projects/<project>/locations/<location>/instances/<instance>/databases/<database>/schemas/<schema>/tables/<table>',
2839
+ biglake: 'biglake.googleapis.com/projects/<project>/catalogs/<catalog>/namespaces/<namespace>/tables/<table>',
2840
+ };
2841
+ // The databases each Cloud SQL form in TABLE_FORMS is for.
2842
+ const CLOUD_SQL_DATABASES = {
2843
+ cloudsql: 'MySQL',
2844
+ cloudsqlSchema: 'PostgreSQL, SQL Server',
2845
+ };
2846
+ // `form` with its last placeholders replaced by `parts`, in order, or
2847
+ // undefined when `form` has fewer placeholders than `parts`.
2848
+ function fillForm(form, parts) {
2849
+ const skip = (form.match(/<\w+>/g) ?? []).length - parts.length;
2850
+ if (skip < 0)
2851
+ return undefined;
2852
+ let n = 0;
2853
+ return form.replace(/<\w+>/g, name => n++ < skip ? name : parts[n - 1 - skip]);
2854
+ }
2855
+ // The name of each service a table form starts with, as messages say it.
2856
+ const SERVICE_NAMES = {
2857
+ bigquery: 'BigQuery',
2858
+ biglake: 'BigLake',
2859
+ spanner: 'Spanner',
2860
+ alloydb: 'AlloyDB',
2861
+ cloudsql: 'Cloud SQL',
2862
+ };
2863
+ // Why `parts`, a bare name's parts read into the last placeholders of `form`,
2864
+ // name no table there only because a part holds a dot. Such a part was
2865
+ // quoted, so it is one name, and `form` takes no dot in its place. Undefined
2866
+ // for any other name. When `service` is false, the reason names neither the
2867
+ // part's role nor the service.
2868
+ function dottedPartProblem(parts, form, service = true) {
2869
+ const takes = (names) => {
2870
+ const name = fillForm(form, names);
2871
+ return name !== undefined && oldSourceForm(name) === undefined &&
2872
+ isAcceptedSource(name);
2873
+ };
2874
+ const plain = parts.map(p => p.replaceAll('.', '_'));
2875
+ if (takes(parts) || !takes(plain))
2876
+ return undefined;
2877
+ const i = parts.findIndex((p, j) => p !== plain[j] && !takes(plain.map((q, k) => k === j ? p : q)));
2878
+ if (i < 0)
2879
+ return undefined;
2880
+ if (!service)
2881
+ return `the quoted name '${parts[i]}' is one name, dot included`;
2882
+ const placeholders = form.match(/<\w+>/g);
2883
+ const role = placeholders[placeholders.length - parts.length + i].slice(1, -1);
2884
+ return `the quoted name '${parts[i]}' is one ${role} name, and a ${SERVICE_NAMES[form.split('.')[0]]} ${role} name cannot hold a dot`;
2885
+ }
2886
+ // `source` with its quotes removed, written as a resource name, when it is a
2887
+ // table's resource name, resource URI or entry name outside a system entry
2888
+ // group, holding them; '' when removing them leaves no such name; undefined
2889
+ // for any other source.
2890
+ function unquotedName(source) {
2891
+ if (!QUOTE.test(source))
2892
+ return undefined;
2893
+ const name = source.replace(/["`]/g, '');
2894
+ const named = (s) => !s.match(ENTRY_NAME)?.[1].startsWith('@') &&
2895
+ (ENTRY_NAME.test(s) ||
2896
+ [
2897
+ BIGQUERY_TABLE_URI, BIGLAKE_TABLE_URI, SPANNER_TABLE_URI,
2898
+ ALLOYDB_TABLE_URI, CLOUDSQL_TABLE_URI, CLOUDSQL_SCHEMA_TABLE_URI,
2899
+ ].some(re => re.test(asResourceUri(s))));
2900
+ return named(name) ? asResourceName(name) : named(source) ? '' : undefined;
2901
+ }
2902
+ // The GCP resource name a Knowledge Catalog name of a first-party table
2903
+ // builds, accepted or not, or undefined when `fqn` is in no such shape.
2904
+ function catalogNameResourceName(fqn) {
2905
+ const form = CATALOG_NAME_FORMS.find(([prefix]) => fqn.startsWith(prefix));
2906
+ if (!form || fqn.length > MAX_CATALOG_NAME_LENGTH)
2907
+ return undefined;
2908
+ const [prefix, count, resourceName] = form;
2909
+ const segs = catalogNameSegments(fqn.slice(prefix.length));
2910
+ if (segs.length !== count || segs.some(seg => seg === ''))
2911
+ return undefined;
2912
+ return resourceName(segs);
2913
+ }
2914
+ // A catalog name's dot-separated segments. A segment containing a reserved
2915
+ // character, such as a domain-scoped project, is wrapped in backticks, so the
2916
+ // dots and colons inside backticks do not split or fail. Returns [] when the
2917
+ // quoting is malformed (an unquoted colon, an unclosed backtick, text after a
2918
+ // closing backtick, or a backtick mid-segment outside quotes). With `colons`
2919
+ // set, an unquoted colon after the first backtick stays in its segment.
2920
+ // Dataplex reads a fullyQualifiedName that way: it splits the system and
2921
+ // subtype off at the colons before the first backtick, and keeps the rest as
2922
+ // the path.
2923
+ function catalogNameSegments(path, colons = false) {
2924
+ const out = [];
2925
+ let cur = '';
2926
+ let state = 'start';
2927
+ let colonOk = false;
2928
+ for (let i = 0; i < path.length; i++) {
2929
+ const ch = path[i];
2930
+ if (state === 'start') {
2931
+ if (ch === '`') {
2932
+ state = 'quoted';
2933
+ colonOk = colons;
2934
+ }
2935
+ else if (ch === '.') {
2936
+ out.push('');
2937
+ }
2938
+ else if (ch === ':' && !colonOk) {
2939
+ return [];
2940
+ }
2941
+ else {
2942
+ cur = ch;
2943
+ state = 'unquoted';
2944
+ }
2945
+ }
2946
+ else if (state === 'unquoted') {
2947
+ if (ch === '.') {
2948
+ out.push(cur);
2949
+ cur = '';
2950
+ state = 'start';
2951
+ }
2952
+ else if (ch === '`' || (ch === ':' && !colonOk)) {
2953
+ return [];
2954
+ }
2955
+ else {
2956
+ cur += ch;
2957
+ }
2958
+ }
2959
+ else if (state === 'quoted') {
2960
+ if (ch === '`') {
2961
+ // A doubled backtick inside a quoted segment is a literal backtick.
2962
+ if (path[i + 1] === '`') {
2963
+ cur += '`';
2964
+ i++;
2965
+ }
2966
+ else {
2967
+ state = 'after_quote';
2968
+ }
2969
+ }
2970
+ else {
2971
+ cur += ch;
2972
+ }
2973
+ }
2974
+ else {
2975
+ if (ch === '.') {
2976
+ out.push(cur);
2977
+ cur = '';
2978
+ state = 'start';
2979
+ }
2980
+ else {
2981
+ return [];
2982
+ }
2983
+ }
2984
+ }
2985
+ if (state === 'quoted')
2986
+ return [];
2987
+ out.push(cur);
2988
+ return out;
2989
+ }
2990
+ // The pairs of backticks or double quotes in `text`, as the index of each
2991
+ // pair's opening and closing quote, in order. A quote opens a pair only when
2992
+ // the same character follows later, and the next such character closes it.
2993
+ // Inside a pair, the other quote character is ordinary.
2994
+ function quotePairs(text) {
2995
+ const pairs = [];
2996
+ for (let i = 0; i < text.length; i++) {
2997
+ const close = text[i] === '`' || text[i] === '"' ? text.indexOf(text[i], i + 1) : -1;
2998
+ if (close >= 0) {
2999
+ pairs.push([i, close]);
3000
+ i = close;
3001
+ }
3002
+ }
3003
+ return pairs;
3004
+ }
3005
+ // True when `source` holds whitespace outside a pair of backticks or double
3006
+ // quotes, which marks it as a query.
3007
+ function hasUnquotedWhitespace(source) {
3008
+ let from = 0;
3009
+ for (const [open, close] of quotePairs(source)) {
3010
+ if (/\s/.test(source.slice(from, open)))
3011
+ return true;
3012
+ from = close + 1;
3013
+ }
3014
+ return /\s/.test(source.slice(from));
3015
+ }
3016
+ // The database a source lives in, for the one-database rule and source
3017
+ // validation, or undefined when the source is in no accepted form: a bare name,
3018
+ // a query, a graph rather than a table, a catalog name under a prefix kcmd does
3019
+ // not accept, or one with the wrong number of segments. The result starts with
3020
+ // its system, which DIALECT_OF_SYSTEM maps to the dialect the source infers,
3021
+ // except for a source whose dialect is looked up (see dialectOfSources).
3022
+ //
3023
+ // A database is what a single query can reach: all of BigQuery, BigLake
3024
+ // included because BigQuery reads it; one Spanner, AlloyDB or Cloud SQL for
3025
+ // PostgreSQL database, or one Cloud SQL for MySQL or SQL Server instance. The
3026
+ // segment counts are those of the Knowledge Catalog FQN reference. The shape
3027
+ // of a Cloud SQL resource name cannot tell a SQL Server table from a
3028
+ // PostgreSQL one, so this keys it on its database, and the one-database rule
3029
+ // widens it to its instance once the lookup names SQL Server. Every
3030
+ // third-party table gets the key `thirdparty`, which is no target's database.
3031
+ // The one-database rule puts it in the database its entry's
3032
+ // fullyQualifiedName names, or else with the other tables of its system.
3033
+ function databaseOf(written) {
3034
+ if (hasUnquotedWhitespace(written))
3035
+ return undefined;
3036
+ const source = asResourceUri(written);
3037
+ if (BIGQUERY_TABLE_URI.test(source) || BIGLAKE_TABLE_URI.test(source)) {
3038
+ return 'bigquery';
3039
+ }
3040
+ let m = source.match(SPANNER_TABLE_URI);
3041
+ if (m)
3042
+ return `spanner/${m[1]}/${m[2]}/${m[3]}`;
3043
+ // An AlloyDB database belongs to its cluster, and a table to a schema in it.
3044
+ m = source.match(ALLOYDB_TABLE_URI);
3045
+ if (m)
3046
+ return `alloydb/${m[1]}/${m[2]}/${m[3]}/${m[4]}`;
3047
+ // A Cloud SQL for MySQL table's database is its instance, as for the
3048
+ // `cloudsql_mysql:` form, so both spellings of one instance agree. A table
3049
+ // in a schema is in Cloud SQL for PostgreSQL or SQL Server, which the shape
3050
+ // cannot tell apart, so its key names neither engine.
3051
+ m = source.match(CLOUDSQL_TABLE_URI);
3052
+ if (m)
3053
+ return `cloudsql_mysql/${m[1]}.${m[2]}.${m[3]}`;
3054
+ m = source.match(CLOUDSQL_SCHEMA_TABLE_URI);
3055
+ if (m)
3056
+ return `cloudsql/${m[1]}.${m[2]}.${m[3]}.${m[4]}`;
3057
+ if (thirdPartyEntryOf(source))
3058
+ return 'thirdparty';
3059
+ if (source.length > MAX_CATALOG_NAME_LENGTH)
3060
+ return undefined;
3061
+ m = source.match(/^([a-z_]+):(.+)$/);
3062
+ if (!m)
3063
+ return undefined;
3064
+ const seg = catalogNameSegments(m[2]);
3065
+ if (seg.length === 0 || seg.some(s => s.length === 0))
3066
+ return undefined;
3067
+ const first = (n) => seg.slice(0, n).join('.');
3068
+ switch (m[1]) {
3069
+ case 'bigquery':
3070
+ return seg.length === 3 && !seg[1].includes('.') && !seg[2].includes('.') ?
3071
+ 'bigquery' :
3072
+ undefined;
3073
+ case 'spanner':
3074
+ return seg.length === 5 ? `spanner/${seg[0]}/${seg[2]}/${seg[3]}` :
3075
+ undefined;
3076
+ case 'alloydb':
3077
+ return seg.length === 6 ?
3078
+ `alloydb/${seg[0]}/${seg[1]}/${seg[2]}/${seg[3]}` :
3079
+ undefined;
3080
+ case 'cloudsql_mysql':
3081
+ return seg.length === 5 ? `cloudsql_mysql/${first(3)}` : undefined;
3082
+ case 'cloudsql_postgresql':
3083
+ return seg.length === 6 ? `cloudsql_postgresql/${first(4)}` : undefined;
3084
+ default:
3085
+ return undefined;
3086
+ }
3087
+ }
3088
+ // True when `source` names a table in a form `databaseOf` reads, as opposed to
3089
+ // a legacy bare table name. An author writes only its GCP resource name
3090
+ // (`parseSource` rejects the other forms); the resource URI and catalog name
3091
+ // forms are read from an older push. The loader, the Knowledge Catalog emitter
3092
+ // and the serializer share it so they agree on which sources are written back
3093
+ // as authored.
3094
+ function isAcceptedSource(source) {
3095
+ return source !== undefined && databaseOf(source) !== undefined;
3096
+ }
3097
+ // The source an entity is written with: its `authoredSource` when that names a
3098
+ // table in a form `databaseOf` reads, and otherwise its `dataSource`, which is
3099
+ // how the load completes a legacy bare table name, shown as a resource name. A
3100
+ // model is serialized, shown and judged by it.
3101
+ function writtenSource(entity) {
3102
+ const authored = entity.authoredSource?.trim();
3103
+ return authored !== undefined && isAcceptedSource(authored) ?
3104
+ authored :
3105
+ asResourceName(entity.dataSource ?? '');
3106
+ }
3107
+ // Maps an accepted source string (or catalog resource path) to the IR's
3108
+ // canonical `dataSource`. A GCP resource name is read as its resource URI
3109
+ // first. A BigQuery or BigLake resource URI becomes the dot-separated table
3110
+ // string. A Spanner, AlloyDB or Cloud SQL resource URI is returned as the URI,
3111
+ // with the leading `//`. Anything else is returned trimmed. Shared by the
3112
+ // loader (`parseSource`) and the Knowledge Catalog reader
3113
+ // (`dataSourceFromResource`) so `readRelationships` matches `Entity.dataSource`
3114
+ // against the `schema-join` aspect's table names.
3115
+ function sourceToDataSource(source) {
3116
+ const value = asResourceUri(source.trim());
3117
+ const bqUri = value.match(BIGQUERY_TABLE_URI);
3118
+ if (bqUri)
3119
+ return `${bqUri[1]}.${bqUri[2]}.${bqUri[3]}`;
3120
+ const biglakeUri = value.match(BIGLAKE_TABLE_URI);
3121
+ if (biglakeUri) {
3122
+ const [, project, catalog, namespace, table] = biglakeUri;
3123
+ return `${project}.${catalog}.${namespace}.${table}`;
3124
+ }
3125
+ return value;
3126
+ }
3127
+ // The dialect each Cloud SQL table or view entry type gives.
3128
+ exports.DIALECT_OF_ENTRY_TYPE = {
3129
+ 'cloudsql-mysql-table': 'MYSQL',
3130
+ 'cloudsql-mysql-view': 'MYSQL',
3131
+ 'cloudsql-postgresql-table': 'POSTGRES',
3132
+ 'cloudsql-postgresql-view': 'POSTGRES',
3133
+ 'cloudsql-sqlserver-table': 'SQLSERVER',
3134
+ 'cloudsql-sqlserver-view': 'SQLSERVER',
3135
+ };
3136
+ // The dialect each fullyQualifiedName prefix of a third-party table gives.
3137
+ exports.DIALECT_OF_FQN_PREFIX = new Map([
3138
+ ['snowflake', 'SNOWFLAKE'], ['databricks', 'DATABRICKS'], ['mysql', 'MYSQL'],
3139
+ ['postgresql', 'POSTGRES'], ['sqlserver', 'SQLSERVER'],
3140
+ ]);
3141
+ // The shape of a Knowledge Catalog entry name.
3142
+ const ENTRY_NAME = /^projects\/[^/]+\/locations\/[^/]+\/entryGroups\/([^/]+)\/entries\/(.+)$/;
3143
+ // The entry name form, as messages show it.
3144
+ exports.ENTRY_NAME_FORM = 'projects/<p>/locations/<l>/entryGroups/<g>/entries/<id>';
3145
+ // How a source names a table outside Google Cloud, as messages say it.
3146
+ exports.THIRD_PARTY_SOURCE = 'A table outside Google Cloud is written ' +
3147
+ `as the entry name of its Knowledge Catalog entry, '${exports.ENTRY_NAME_FORM}'.`;
3148
+ // The third-party entry `source` names, or undefined when it names none, as
3149
+ // an entry name with quotes does.
3150
+ function thirdPartyEntryOf(source) {
3151
+ const name = source.trim();
3152
+ const m = name.match(ENTRY_NAME);
3153
+ if (!m || m[1].startsWith('@') || QUOTE.test(name))
3154
+ return undefined;
3155
+ const [, project, , location] = name.split('/');
3156
+ return { project, location, name };
3157
+ }
3158
+ // True when `entity` is written with a third-party table's entry name.
3159
+ function bindsThirdPartyTable(entity) {
3160
+ return thirdPartyEntryOf(entity.authoredSource ?? '') !== undefined;
3161
+ }
3162
+ // The key LoadOptions.sourceDialects holds a source's dialect under: a Cloud
3163
+ // SQL table's resource name, or a third-party table's entry name. Undefined
3164
+ // for a source whose own name gives its dialect.
3165
+ function dialectLookupKey(source) {
3166
+ return cloudSqlTableOf(source)?.resourceName ??
3167
+ thirdPartyEntryOf(source)?.name;
3168
+ }
3169
+ // Why `source` is not accepted when it is a Knowledge Catalog entry name in a
3170
+ // system entry group, one whose id starts with `@`, such as `@bigquery`.
3171
+ // Undefined for any other source. The entry id of a Google Cloud table in a
3172
+ // system entry group is its GCP resource name, so the message names it as the
3173
+ // source to write, without quotes. Any other id, such as a dataset's, gets an
3174
+ // example. An id that holds a space gets no name to write.
3175
+ function systemEntrySourceError(source, ctx) {
3176
+ const entry = source.match(ENTRY_NAME);
3177
+ if (!entry?.[1].startsWith('@'))
3178
+ return undefined;
3179
+ const id = entry[2].replace(/^\/\//, '').replace(/["`]/g, '');
3180
+ const where = `Semantic model load error: ${ctx}: source '${source}' is a ` +
3181
+ `Knowledge Catalog entry name in the system entry group '${entry[1]}'`;
3182
+ if (/\s/.test(id))
3183
+ return `${where}, and ${exports.NO_SPACE}.`;
3184
+ const write = RESOURCE_NAME.test(id) && /\/tables\/[^/]+$/.test(id) ?
3185
+ `: '${id}'` :
3186
+ `, such as ` +
3187
+ `'bigquery.googleapis.com/projects/<p>/datasets/<d>/tables/<t>'`;
3188
+ return `${where}; write the table's resource name instead${write}.`;
3189
+ }
3190
+ // Google Cloud services whose tables the preview does not support yet, each
3191
+ // with its resource URI host and its catalog name prefix.
3192
+ const UNSUPPORTED_SERVICES = [
3193
+ { name: 'Bigtable', host: 'bigtable.googleapis.com', prefix: 'bigtable:' },
3194
+ {
3195
+ name: 'Dataproc Metastore',
3196
+ host: 'metastore.googleapis.com',
3197
+ prefix: 'dataproc_metastore:',
3198
+ },
3199
+ ];
3200
+ // Why `source` is not accepted when it names a table of a Google Cloud service
3201
+ // the preview does not support yet, as a resource URI, a resource name, a
3202
+ // catalog name or an entry name whose id is one of those. Undefined for any
3203
+ // other source.
3204
+ function unsupportedServiceSourceError(source, ctx) {
3205
+ const uri = asResourceUri(source.match(ENTRY_NAME)?.[2] ?? source);
3206
+ const service = UNSUPPORTED_SERVICES.find(s => uri.startsWith(`//${s.host}/`) || source.startsWith(s.prefix));
3207
+ return service ?
3208
+ `Semantic model load error: ${ctx}: source '${source}' is a ` +
3209
+ `${service.name} resource; the preview does not support ` +
3210
+ `${service.name} sources yet.` :
3211
+ undefined;
3212
+ }
3213
+ // True when `source` is in a Google Cloud form, accepted or not: a resource
3214
+ // name or resource URI, a catalog name of a Google Cloud system, or an entry
3215
+ // name in a system entry group.
3216
+ function isGoogleCloudForm(source) {
3217
+ const system = source.match(/^([a-z_]+):/)?.[1];
3218
+ return RESOURCE_NAME.test(source.replace(/^\/\//, '')) ||
3219
+ [...CATALOG_NAME_FORMS.map(([p]) => p),
3220
+ ...UNSUPPORTED_SERVICES.map(s => s.prefix)]
3221
+ .some(p => p.startsWith(`${system}:`)) ||
3222
+ !!source.match(ENTRY_NAME)?.[1].startsWith('@');
3223
+ }
3224
+ // Why `source` is not accepted when it is a catalog name under a third-party
3225
+ // prefix, such as `snowflake:acme.retail.public.orders`. Undefined for any
3226
+ // other source.
3227
+ function thirdPartyNameError(source, ctx) {
3228
+ return exports.DIALECT_OF_FQN_PREFIX.has(source.match(/^([a-z]+):/)?.[1] ?? '') ?
3229
+ `Semantic model load error: ${ctx}: source '${source}' is a ` +
3230
+ `Knowledge Catalog name; write the entry name of the table's ` +
3231
+ `Knowledge Catalog entry instead, such as '${exports.ENTRY_NAME_FORM}'.` :
3232
+ undefined;
3233
+ }
3234
+ // Reads an entity's `source` into the dotted name the generators use and the
3235
+ // source as written. Throws on a form the loader does not accept. A profile
3236
+ // file's sources are read here too, so they follow the same rules. With
3237
+ // `importHint`, for a model file's source, a bare table name's error also
3238
+ // names `kcmd import`, which completes a model file's bare names but not a
3239
+ // profile file's.
3240
+ function parseSource(source, opts, ctx, warnings, importHint = false) {
3241
+ const trimmed = source.trim();
3242
+ // Under the legacy wrapper, a space inside double quotes also marks a query.
3243
+ if (hasUnquotedWhitespace(trimmed) || (opts.allowLegacyBareSource &&
3244
+ hasUnquotedWhitespace(trimmed.replaceAll('"', '')))) {
3245
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: source '${trimmed}' looks like a ` +
3246
+ `SQL query; a query-valued source is not supported yet. Bind 'source' ` +
3247
+ `to a physical table using its GCP resource name (e.g. ` +
3248
+ `'bigquery.googleapis.com/projects/<p>/datasets/<d>/tables/<t>').`);
3249
+ }
3250
+ const problem = oldSourceForm(trimmed);
3251
+ // The accepted forms: a GCP resource name, or a third-party entry name.
3252
+ if (problem === undefined && isAcceptedSource(trimmed)) {
3253
+ const key = dialectLookupKey(trimmed);
3254
+ const sourceDialect = key && opts.sourceDialects?.get(key);
3255
+ return {
3256
+ authoredSource: trimmed,
3257
+ dataSource: sourceToDataSource(trimmed),
3258
+ ...(sourceDialect ? { sourceDialect } : {}),
3259
+ };
3260
+ }
3261
+ if (opts.warnUnacceptedSource) {
3262
+ warnings?.push(`${ctx}: source '${trimmed}' is not a valid GCP resource name.`);
3263
+ return { authoredSource: trimmed, dataSource: '' };
3264
+ }
3265
+ if (problem !== undefined) {
3266
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: source '${trimmed}' is ${problem}.`);
3267
+ }
3268
+ // Legacy instance-based AlloyDB name: reported in any spelling with the
3269
+ // cluster-based name to write.
3270
+ const legacyAlloy = asResourceUri(trimmed).match(LEGACY_ALLOYDB_INSTANCE_URI);
3271
+ if (legacyAlloy) {
3272
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: source '${trimmed}' uses the ` +
3273
+ `legacy instance-based AlloyDB URI. An AlloyDB database belongs to ` +
3274
+ `its cluster and a table to a schema in it; write ` +
3275
+ `'alloydb.googleapis.com/projects/${legacyAlloy[1]}/locations/${legacyAlloy[2]}/clusters/${legacyAlloy[3]}/databases/${legacyAlloy[4]}/schemas/<schema>/tables/${legacyAlloy[5]}'.`);
3276
+ }
3277
+ const unsupported = unsupportedServiceSourceError(trimmed, ctx) ??
3278
+ systemEntrySourceError(trimmed, ctx) ??
3279
+ thirdPartyNameError(trimmed, ctx);
3280
+ if (unsupported)
3281
+ throw new SemanticModelError(unsupported);
3282
+ if (trimmed.length > MAX_CATALOG_NAME_LENGTH) {
3283
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: source exceeds the ${MAX_CATALOG_NAME_LENGTH}-character limit (${trimmed.length} characters).`);
3284
+ }
3285
+ // Bare table name: no `/`, which every resource URI and resource name has,
3286
+ // and no `:` scheme or prefix.
3287
+ const isBareTableName = trimmed.length > 0 && !trimmed.includes('/') &&
3288
+ !trimmed.includes(':');
3289
+ let dotted;
3290
+ if (isBareTableName) {
3291
+ // The legacy wrapper splits at every dot outside backticks.
3292
+ const rawSegs = catalogNameSegments(trimmed);
3293
+ if (opts.allowLegacyBareSource && rawSegs.length > 0) {
3294
+ const parts = rawSegs.map(unquote);
3295
+ if (parts.length === 1 && opts.defaultDataset) {
3296
+ parts.unshift(opts.defaultDataset);
3297
+ }
3298
+ if (parts.length < 3 && opts.defaultProject) {
3299
+ parts.unshift(opts.defaultProject);
3300
+ }
3301
+ return {
3302
+ authoredSource: trimmed,
3303
+ dataSource: parts.join('.'),
3304
+ };
3305
+ }
3306
+ // With `importHint`, the example also names the command that completes a
3307
+ // model file's bare names.
3308
+ const viaImport = importHint ?
3309
+ `\nOr run 'kcmd import <file> --flavor <ossie|google> ` +
3310
+ `--source-prefix <prefix>' to complete each bare table name ` +
3311
+ `from the database that <prefix> names.` :
3312
+ '';
3313
+ // A name holding a space gets no name to write. Any other name gets an
3314
+ // example built from its parts when the loader accepts the example, and
3315
+ // the general error below otherwise, which says so when only a quoted
3316
+ // name's dot keeps the example out. A path in one pair of backticks counts
3317
+ // as its parts, as import reads it under a BigQuery or BigLake prefix.
3318
+ const segs = bareNameSegments(trimmed, true);
3319
+ if (segs.length > 0 && segs.length <= 4 && segs.every(s => s.length > 0)) {
3320
+ if (segs.some(s => /\s/.test(s))) {
3321
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: bare table name '${trimmed}' ` +
3322
+ `is not a valid source, and ${exports.NO_SPACE}. ${exports.THIRD_PARTY_SOURCE}`);
3323
+ }
3324
+ const form = segs.length === 4 ? exports.TABLE_FORMS.biglake : exports.TABLE_FORMS.bigquery;
3325
+ const write = segs.length === 4 ?
3326
+ 'Write its BigLake resource name instead:' :
3327
+ "Write the table's GCP resource name instead, for example:";
3328
+ const example = fillForm(form, segs);
3329
+ if (oldSourceForm(example) === undefined && isAcceptedSource(example)) {
3330
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: bare table name '${trimmed}' ` +
3331
+ `is not a valid source. ${write}\n source: ${example}${viaImport}`);
3332
+ }
3333
+ dotted = dottedPartProblem(segs, form, false);
3334
+ }
3335
+ }
3336
+ // Each table form with its placeholders shortened, such as <p> for
3337
+ // <project>, and the databases a Cloud SQL form is for.
3338
+ const forms = Object.entries(exports.TABLE_FORMS).map(([kind, form]) => {
3339
+ const short = form.replace(/<(\w+)>/g, (_, name) => `<${name === 'database' ? 'db' : name[0]}>`);
3340
+ const databases = CLOUD_SQL_DATABASES[kind];
3341
+ return `'${short}'${databases ? ` (${databases})` : ''}`;
3342
+ });
3343
+ throw new SemanticModelError(`Semantic model load error: ${ctx}: source '${trimmed}' is not a valid ` +
3344
+ `table reference${dotted ? `; ${dotted}` : ''}. Expected a GCP ` +
3345
+ `resource name (${forms.slice(0, -1).join(', ')}, or ${forms.at(-1)}), ` +
3346
+ `or, for a table outside Google Cloud, the entry name of its ` +
3347
+ `Knowledge Catalog entry ('${exports.ENTRY_NAME_FORM}').`);
3348
+ }
3349
+ function unquote(part) {
3350
+ return part.replace(/^[`"]/, '').replace(/[`"]$/, '');
3351
+ }
3352
+ // The parts of a bare table name, without their quotes. A part in backticks
3353
+ // or double quotes is one name, dots included, and a doubled quote character
3354
+ // in it, as in "a""b", is one literal quote. The quotes are those
3355
+ // hasUnquotedWhitespace reads. Returns [] when a quote does not wrap a whole
3356
+ // part. With `bigQuery`, a path in one pair of backticks, as BigQuery writes
3357
+ // `p.d.t`, has the parts of the same path written without them. A path in one
3358
+ // pair of double quotes is one name.
3359
+ function bareNameSegments(name, bigQuery) {
3360
+ return bareNameParts(name, bigQuery).map(p => p.text);
3361
+ }
3362
+ // The parts bareNameSegments gives, each with whether quotes wrap it.
3363
+ function bareNameParts(name, bigQuery) {
3364
+ const pairs = quotePairs(name);
3365
+ if (bigQuery && name[0] === '`' && pairs.length === 1 && pairs[0][0] === 0 &&
3366
+ pairs[0][1] === name.length - 1) {
3367
+ return bareNameParts(name.slice(1, -1), false);
3368
+ }
3369
+ const closeOf = new Map(pairs);
3370
+ const parts = [];
3371
+ let start = 0;
3372
+ while (true) {
3373
+ let part = /^[^.`"]*/.exec(name.slice(start))[0];
3374
+ let end = start + part.length;
3375
+ const firstClose = part ? undefined : closeOf.get(start);
3376
+ if (firstClose !== undefined) {
3377
+ let close = firstClose;
3378
+ part = name.slice(start + 1, close);
3379
+ // Pairs that touch, as in "a""b", are one name.
3380
+ while (name[close + 1] === name[start] && closeOf.has(close + 1)) {
3381
+ const next = closeOf.get(close + 1);
3382
+ part += name.slice(close + 1, next);
3383
+ close = next;
3384
+ }
3385
+ end = close + 1;
3386
+ }
3387
+ parts.push({ text: part, quoted: firstClose !== undefined });
3388
+ if (end === name.length)
3389
+ return parts;
3390
+ if (name[end] !== '.')
3391
+ return [];
3392
+ start = end + 1;
3393
+ }
3394
+ }
3395
+ /**
3396
+ * Loads every authored document into the IR once, so a multi-destination push
3397
+ * parses and validates each model a single time and fans the result out to each
3398
+ * deploy leg (BigQuery, Knowledge Catalog) rather than re-parsing per leg.
3399
+ *
3400
+ * A parse/schema error is returned as `error` (naming the document) rather than
3401
+ * thrown, mirroring how the deploy legs previously reported it; loader warnings
3402
+ * are prefixed with their document name.
3403
+ *
3404
+ * A document's `allowLegacyBareSource` sets that load option for that document
3405
+ * alone.
3406
+ *
3407
+ * Each document needs the SQL parser as loadModels does, so call
3408
+ * `loadSqlEngine()` first. Without it, this throws the parser's plain Error.
3409
+ */
3410
+ function loadSemanticModels(docs, opts = {}) {
3411
+ const models = [];
3412
+ const warnings = [];
3413
+ for (const doc of docs) {
3414
+ let loaded;
3415
+ const docOpts = {
3416
+ ...opts,
3417
+ ...(doc.allowLegacyBareSource !== undefined ?
3418
+ { allowLegacyBareSource: doc.allowLegacyBareSource } :
3419
+ {}),
3420
+ };
3421
+ try {
3422
+ loaded = loadModels(doc.text, docOpts);
3423
+ }
3424
+ catch (err) {
3425
+ if (!(err instanceof SemanticModelError))
3426
+ throw err;
3427
+ return {
3428
+ models,
3429
+ warnings,
3430
+ error: `Model document '${doc.name}': ${err.message}`,
3431
+ };
3432
+ }
3433
+ for (const w of loaded.warnings)
3434
+ warnings.push(`[${doc.name}] ${w}`);
3435
+ for (const model of loaded.models) {
3436
+ models.push({ document: doc.name, model });
3437
+ }
3438
+ }
3439
+ return { models, warnings };
3440
+ }