@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,1899 @@
1
+ "use strict";
2
+ // Push-time validation gate for a semantic model.
3
+ //
4
+ // Runs once over the shared, already-parsed models (see loadSemanticModels)
5
+ // before any destination leg, so a real `kcmd push` AND a `--validate-only` dry
6
+ // run enforce the same requirements. Returns one message per violation (an
7
+ // empty array means valid); the caller (commands.ts) prints them and aborts the
8
+ // push. Kept separate from the loader -- which validates a document against the
9
+ // schema -- because these are deployment requirements, not schema rules, and
10
+ // they read the GOOGLE deployment-target extension the BigQuery leg owns.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.validatePushRequirements = validatePushRequirements;
13
+ exports.validateDeployments = validateDeployments;
14
+ exports.validateInlineDialects = validateInlineDialects;
15
+ exports.validateInheritedColumns = validateInheritedColumns;
16
+ exports.inlineBindingWarnings = inlineBindingWarnings;
17
+ exports.validateActions = validateActions;
18
+ exports.validateInheritance = validateInheritance;
19
+ exports.validateExpressionReferences = validateExpressionReferences;
20
+ exports.deploymentTableErrors = deploymentTableErrors;
21
+ exports.validateBigQueryDataSources = validateBigQueryDataSources;
22
+ exports.storeMessage = storeMessage;
23
+ exports.validateSpannerActionStatements = validateSpannerActionStatements;
24
+ exports.validateBigQueryActionStatements = validateBigQueryActionStatements;
25
+ const bigquery_1 = require("./bigquery");
26
+ const deploy_bigquery_1 = require("./deploy_bigquery");
27
+ const deployment_target_1 = require("./deployment_target");
28
+ const ir_1 = require("./ir");
29
+ const kc_ids_1 = require("./kc_ids");
30
+ const loader_1 = require("./loader");
31
+ const names_1 = require("./names");
32
+ const parameters_1 = require("./parameters");
33
+ const resolve_inheritance_1 = require("./resolve_inheritance");
34
+ const resolve_profiles_1 = require("./resolve_profiles");
35
+ const sql_expr_utils_1 = require("./sql_expr_utils");
36
+ const sql_identifiers_1 = require("./sql_identifiers");
37
+ const sql_parser_1 = require("./sql_parser");
38
+ // Checks every model against the push requirements and returns the collected
39
+ // error messages (empty when all models pass), each tagged with the model's
40
+ // source document so the author can find it.
41
+ //
42
+ // `fieldsPruned` says the caller has already dropped every field the selected
43
+ // binding profile leaves unbound, so `entity.fields` is a subset of what the
44
+ // author declared. Checks that read a field list have to stand down for such a
45
+ // model (see validateConstraints).
46
+ //
47
+ // `warnings` collects non-fatal messages (such as an expression the SQL parser
48
+ // could not read, whose field references were therefore not checked) for the
49
+ // caller to print.
50
+ //
51
+ // `v2Aspects` says push writes the second-generation Knowledge Catalog types
52
+ // (selected via KC_V2_ASPECTS=1).
53
+ function validatePushRequirements(models, opts = {}) {
54
+ const errors = [];
55
+ for (const { document, model } of models) {
56
+ errors.push(...nameAndDescriptionErrors(model, document, !!opts.v2Aspects));
57
+ let deployInfo;
58
+ try {
59
+ // One pass over the model's GOOGLE extension(s): both checks below read
60
+ // the same parse rather than re-parsing the JSON per reader.
61
+ deployInfo = (0, deploy_bigquery_1.googleDeploymentTargets)(model);
62
+ }
63
+ catch (err) {
64
+ // Malformed GOOGLE extension JSON: surface it as a validation error here
65
+ // rather than letting it throw out of a later leg as an uncaught stack.
66
+ errors.push(`${err.message || err} (${document})`);
67
+ continue;
68
+ }
69
+ // A model that targets a graph (BigQuery OR Spanner) must have every
70
+ // relationship's join columns bound. The loader accepts a column-less
71
+ // relationship so a purely logical model (an OWL import) loads and pushes
72
+ // to Knowledge Catalog, but a graph deploy would emit an invalid
73
+ // `DESTINATION KEY () REFERENCES Dest ()` for such an edge -- so reject it
74
+ // here rather than generate broken DDL. A model with no graph target
75
+ // skips it.
76
+ if (deployInfo.bigQuery.length + deployInfo.spanner.length > 0) {
77
+ for (const rel of model.relationships ?? []) {
78
+ // An M:N edge binds through its junction table (association), so its
79
+ // direct source/destination columns are empty by design -- the graph
80
+ // generators render it from `rel.association`. Only a plain FK edge
81
+ // needs direct join columns.
82
+ if (rel.association)
83
+ continue;
84
+ if (!rel.source.columns.length && !rel.destination.columns.length) {
85
+ errors.push(`relationship '${rel.name}' in model '${model.name}' (${document}) targets a graph but has no join columns; add its ` +
86
+ `from_columns and to_columns to the relationship in the model ` +
87
+ `before a BigQuery or Spanner Graph deploy.`);
88
+ }
89
+ }
90
+ }
91
+ errors.push(...validateInheritance([{ document, model }]));
92
+ errors.push(...validateColumnsAndRelationships(model, document));
93
+ errors.push(...relationshipKeyErrors(model, document));
94
+ const flaggedMetrics = new Set();
95
+ errors.push(...validateExpressionReferences([{ document, model }], !!opts.fieldsPruned, opts.warnings, flaggedMetrics));
96
+ if (deployInfo.bigQuery.length > 0) {
97
+ errors.push(...validateBigQueryMetrics(model, document, flaggedMetrics));
98
+ }
99
+ // An action reaches Knowledge Catalog only, so its checks are
100
+ // target-independent: each parameter's type must resolve to something in
101
+ // the ontology, the executor must carry the coordinates a runtime needs to
102
+ // dispatch it, and each guard must name a constraint the model declares.
103
+ // (The "exactly one executor kind" rule is already guaranteed by the loader
104
+ // schema, so it cannot reach here.)
105
+ errors.push(...validateActions(model, document, !!opts.fieldsPruned, true));
106
+ // Constraints are logical invariants, target-independent like actions.
107
+ errors.push(...validateConstraints(model, document, !!opts.fieldsPruned));
108
+ }
109
+ return errors;
110
+ }
111
+ // Each name in `model` that breaks its kind's pattern or length limit, each
112
+ // pair of relationships whose names give one Knowledge Catalog link ID, and
113
+ // each description too long for the Knowledge Catalog entry that stores it.
114
+ // The link-ID check applies with `v2Aspects` off only.
115
+ // The loader checks names too; this check is for a model built in memory and
116
+ // a profile that reaches `model.profiles` some other way. Link IDs and
117
+ // description limits are checked at push only, since only push writes them.
118
+ function nameAndDescriptionErrors(model, document, v2Aspects) {
119
+ const at = `model '${model.name}' (${document})`;
120
+ const errors = [];
121
+ const check = (kind, name) => {
122
+ const problem = (0, names_1.nameProblem)(kind, name);
123
+ if (problem)
124
+ errors.push(`${at}: ${problem}`);
125
+ };
126
+ const tooLong = (what, description) => {
127
+ if (description !== undefined &&
128
+ description.length > names_1.MAX_ENTRY_DESCRIPTION) {
129
+ errors.push(`${at}: the description of ${what} is ${description.length} characters long; a Knowledge Catalog ` +
130
+ `entry's description holds at most ${names_1.MAX_ENTRY_DESCRIPTION}.`);
131
+ }
132
+ };
133
+ check('model', model.name);
134
+ tooLong(`model '${model.name}'`, model.description);
135
+ for (const e of model.entities ?? []) {
136
+ check('entity', e.name);
137
+ tooLong(`entity '${e.name}'`, e.description);
138
+ for (const f of e.fields ?? [])
139
+ check('field', f.name);
140
+ }
141
+ for (const m of model.metrics ?? []) {
142
+ check('metric', m.name);
143
+ tooLong(`metric '${m.name}'`, m.description);
144
+ }
145
+ // The relationship that first took each link ID.
146
+ const byLinkId = new Map();
147
+ for (const r of model.relationships ?? []) {
148
+ check('relationship', r.name);
149
+ tooLong(`relationship '${r.name}'`, r.description);
150
+ if (v2Aspects)
151
+ continue;
152
+ const linkId = (0, kc_ids_1.relationshipLinkId)(model.name, r.name);
153
+ const first = byLinkId.get(linkId);
154
+ if (first === undefined) {
155
+ byLinkId.set(linkId, r.name);
156
+ }
157
+ else {
158
+ errors.push(`Relationships '${first}' and '${r.name}' in model '${model.name}' (${document}) collide on Knowledge Catalog link ` +
159
+ `ID '${linkId}'; rename one of them.`);
160
+ }
161
+ }
162
+ for (const d of model.deployments ?? [])
163
+ check('deployment', d.name);
164
+ for (const p of model.profiles ?? [])
165
+ check('profile', p.name);
166
+ const reserved = (0, resolve_profiles_1.reservedProfileNameError)(document, model.profiles ?? []);
167
+ if (reserved)
168
+ errors.push(reserved);
169
+ return errors;
170
+ }
171
+ // Checks each deployment of `model` against the rules of its engine, under the
172
+ // profile it names in `model.profiles` or the model file's own bindings, and
173
+ // returns one message per problem, naming every deployment it applies to.
174
+ function validateDeployments(model, document) {
175
+ // The deployments each problem applies to, keyed by the text that follows
176
+ // their names in the message.
177
+ const problems = new Map();
178
+ const add = (deployment, problem) => problems.set(problem, [...(problems.get(problem) ?? []), deployment]);
179
+ const unbound = unboundInline(model);
180
+ const unboundFields = unboundGraphFields(model);
181
+ // The metrics the reference rules report, which the BigQuery rule leaves to
182
+ // them.
183
+ let referenced;
184
+ for (const d of model.deployments ?? []) {
185
+ // A profile file that does not load is not in `model.profiles`, and is
186
+ // left out; push reports the file.
187
+ const named = model.profiles?.find(p => p.name === d.profile);
188
+ if ((d.profile ?? resolve_profiles_1.DEFAULT_PROFILE) !== resolve_profiles_1.DEFAULT_PROFILE && !named)
189
+ continue;
190
+ const resolved = (0, deployment_target_1.resolveModelDeployments)(model, [d.name]);
191
+ const engine = 'error' in resolved ? undefined : resolved.deployments[0].engine;
192
+ const alloyDb = engine === 'alloydb';
193
+ // The model the deployment's bindings give, less what they leave
194
+ // unavailable. The completeness check reports a named profile that does
195
+ // not apply, and the fields its metrics read.
196
+ const bound = named ? profiled(model, named) : (0, resolve_profiles_1.pruneUnavailable)(model, resolve_profiles_1.DEFAULT_PROFILE, alloyDb ? 'ALLOYDB' : (0, resolve_profiles_1.sourcesDialect)(model));
197
+ if (!bound)
198
+ continue;
199
+ const available = bound.model.metrics ?? [];
200
+ if (engine === 'bigquery' || engine === 'spanner') {
201
+ for (const p of graphNameProblems(bound.model, engine))
202
+ add(d.name, p);
203
+ }
204
+ if (named) {
205
+ if (engine === 'spanner' && available.length) {
206
+ add(d.name, `targets Spanner, which has no measures, and profile '${named.name}' leaves metric(s) ${(0, loader_1.listed)(available.map(m => `'${m.name}'`))} available; name ` +
207
+ `them in its 'metrics_exclude', or set it to "*".`);
208
+ }
209
+ }
210
+ else {
211
+ if (unbound) {
212
+ add(d.name, `uses the model file's own bindings, which leave ${unbound}; a deployment needs every entity and relationship ` +
213
+ `bound.`);
214
+ }
215
+ if (unboundFields && (engine === 'bigquery' || engine === 'spanner')) {
216
+ add(d.name, `uses the model file's own bindings, ${unboundFields}`);
217
+ }
218
+ }
219
+ // A Spanner deployment deploys no metric.
220
+ if (engine === 'spanner')
221
+ continue;
222
+ for (const { name, field } of named ? [] : bound.report.droppedMetrics) {
223
+ if (field === undefined)
224
+ continue;
225
+ add(d.name, `uses the model file's own bindings, under which metric '${name}' reads field '${field}', which ${bound.report.unboundFields.includes(field) ?
226
+ 'has no expression' :
227
+ 'reads a field with no expression, directly or through ' +
228
+ 'other fields'}; a deployment needs every field its ` +
229
+ `metrics read bound.`);
230
+ }
231
+ if (engine !== 'bigquery')
232
+ continue;
233
+ if (!referenced) {
234
+ referenced = new Set();
235
+ validateExpressionReferences([{ document, model }], false, [], referenced);
236
+ }
237
+ for (const m of available) {
238
+ const problem = !referenced.has(m.name) && measureProblem(bound.model, m);
239
+ if (problem) {
240
+ add(d.name, `targets BigQuery, where metric '${m.name}' cannot be a measure: ${problem}.`);
241
+ }
242
+ }
243
+ for (const p of bigQueryOptionProblems(bound.model))
244
+ add(d.name, p);
245
+ }
246
+ // The verb that starts each problem agrees with the number of deployments.
247
+ return [...problems].map(([problem, names]) => {
248
+ const many = names.length > 1;
249
+ return `model '${model.name}' (${document}) deployment${many ? 's' : ''} ${(0, loader_1.listed)(names.map(n => `'${n}'`))} ${many ? problem.replace(/^(use|target)s /, '$1 ') : problem}`;
250
+ });
251
+ }
252
+ // `model` with `profile` applied and what it leaves unavailable pruned, or
253
+ // undefined when the profile does not apply.
254
+ function profiled(model, profile) {
255
+ try {
256
+ return (0, resolve_profiles_1.pruneUnavailable)((0, resolve_profiles_1.applyProfile)(model, profile), profile.name);
257
+ }
258
+ catch (err) {
259
+ if (!(err instanceof loader_1.SemanticModelError))
260
+ throw err;
261
+ return undefined;
262
+ }
263
+ }
264
+ // Why `metric` cannot be a BigQuery measure, or undefined when it can. Leaves
265
+ // out a metric that validateBigQueryMetrics or the inheritance rules report:
266
+ // one that reads several entities or none, or one on an abstract or extended
267
+ // entity.
268
+ function measureProblem(model, metric) {
269
+ const text = (0, ir_1.expressionForDialect)(metric, 'BIGQUERY');
270
+ if (text === undefined)
271
+ return undefined;
272
+ const columns = (0, sql_expr_utils_1.columnsForSource)(text, 'BIGQUERY');
273
+ if (!columns)
274
+ return undefined;
275
+ const entities = model.entities ?? [];
276
+ const names = entities.map(e => e.name);
277
+ // Read `text` (from `expressionForDialect(metric, 'BIGQUERY')`), the SQL the
278
+ // BigQuery graph runs.
279
+ const matched = (0, sql_expr_utils_1.referencedEntityNames)((0, sql_parser_1.withoutComments)(text, 'BIGQUERY'), names);
280
+ if (matched.length > 1 || !matched.length && !metric.entity)
281
+ return undefined;
282
+ const reached = (0, sql_expr_utils_1.entitiesReached)(columns, names);
283
+ const name = reached.length > 1 ? undefined : reached[0] ?? metric.entity;
284
+ const entity = entities.find(e => e.name === name);
285
+ if (!entity || entity.abstract ||
286
+ entities.some(e => e.extends?.includes(entity.name))) {
287
+ return undefined;
288
+ }
289
+ if (!(0, sql_expr_utils_1.isSingleAggregate)(text, 'BIGQUERY')) {
290
+ return 'it is not SUM, AVG, COUNT, MIN or MAX over one operand';
291
+ }
292
+ if (!(0, resolve_profiles_1.statesKey)(entity.keys, entity.uniqueKeys)) {
293
+ return `it belongs to entity '${entity.name}', which has no primary key ` +
294
+ `or unique key, so the graph has no node table for it`;
295
+ }
296
+ return undefined;
297
+ }
298
+ // Checks that each bound field of the model file has a text for the dialect
299
+ // of its entity's source, and returns one message per field.
300
+ function validateInlineDialects(model, document) {
301
+ const errors = [];
302
+ for (const { entity: e, field: f, table: { name: dialect } } of (0, resolve_profiles_1.fieldsOnTables)(model)) {
303
+ if (!(0, resolve_profiles_1.noTextFor)(f, [dialect]))
304
+ continue;
305
+ const hint = (0, resolve_profiles_1.importTranspiles)(f, dialect) ?
306
+ `; give it one, or add one with '${resolve_profiles_1.IMPORT_TRANSPILE_COMMAND}'` : '';
307
+ errors.push(`field '${e.name}.${f.name}' in model '${model.name}' (${document}) has no expression for ${dialect} or ANSI_SQL, and ` +
308
+ `the model file binds entity '${e.name}' to ${/^[AEIOU]/.test(dialect) ? 'an' : 'a'} ${dialect} source${hint}.`);
309
+ }
310
+ return errors;
311
+ }
312
+ // One message for each field a concrete entity with a source inherits without
313
+ // restating it, when the inherited expression reads a column. Empty when the
314
+ // hierarchy does not resolve, which push reports elsewhere.
315
+ function validateInheritedColumns(model, document) {
316
+ if ((0, resolve_profiles_1.inheritanceErrorOf)(model))
317
+ return [];
318
+ const entities = model.entities ?? [];
319
+ const dialect = (0, resolve_profiles_1.sourcesDialect)(model);
320
+ const errors = [];
321
+ for (const e of entities.filter(e => !e.abstract && e.dataSource)) {
322
+ for (const f of (0, resolve_profiles_1.inheritedNamesOf)(model, e.name)) {
323
+ const read = !e.fields.some(x => x.name === f) &&
324
+ (0, resolve_profiles_1.columnBinding)(model, e.name, f, dialect);
325
+ if (!read)
326
+ continue;
327
+ errors.push(`entity '${e.name}' in model '${model.name}' (${document}): field '${f}' is inherited from '${read.owner}', whose expression reads ` +
328
+ `column '${read.column}' of that entity's table; restate it with ` +
329
+ `an expression for this entity's table.`);
330
+ }
331
+ }
332
+ return errors;
333
+ }
334
+ // The warning for a model file whose own bindings are incomplete and that no
335
+ // deployment uses. With `namedProfiles`, it says 'default' is unbound, and
336
+ // otherwise that the model cannot be queried at all.
337
+ function inlineBindingWarnings(model, document, namedProfiles) {
338
+ const unbound = unboundInline(model);
339
+ const used = (model.deployments ?? []).some(d => (d.profile ?? resolve_profiles_1.DEFAULT_PROFILE) === resolve_profiles_1.DEFAULT_PROFILE);
340
+ if (!unbound || used)
341
+ return [];
342
+ const where = `model '${model.name}' (${document})`;
343
+ return [
344
+ namedProfiles ?
345
+ `${where} has an unbound 'default' profile: the model file leaves ${unbound}. It cannot be queried through 'default'.` :
346
+ `${where} is non-executable: the model file leaves ${unbound}, and the model declares no named profile. It cannot be ` +
347
+ `queried until it is bound.`,
348
+ ];
349
+ }
350
+ // What the model file's own bindings leave unbound, such as "entity 'a'
351
+ // without a source", or '' when nothing is.
352
+ function unboundInline(model) {
353
+ const sourceless = (model.entities ?? [])
354
+ .filter(e => !e.abstract && !e.dataSource)
355
+ .map(e => e.name);
356
+ const unjoined = (model.relationships ?? [])
357
+ .filter(r => !r.association &&
358
+ !(r.source.columns.length && r.destination.columns.length))
359
+ .map(r => r.name);
360
+ const part = (names, one, many, without) => names.length ? `${names.length === 1 ? one : many} ${(0, loader_1.listed)(names.map(n => `'${n}'`))} without ${without}` :
361
+ '';
362
+ return [
363
+ part(sourceless, 'entity', 'entities', 'a source'),
364
+ part(unjoined, 'relationship', 'relationships', 'join columns'),
365
+ ].filter(p => p).join(' and ');
366
+ }
367
+ // The fields the model file's own bindings leave without an expression on
368
+ // each concrete leaf entity, counting the fields it inherits, as the end of a
369
+ // deployment's error, or undefined when there are none.
370
+ function unboundGraphFields(model) {
371
+ const extended = new Set((model.entities ?? []).flatMap(e => e.extends ?? []));
372
+ const fields = (0, resolve_profiles_1.resolvedEntities)(model)
373
+ .filter(e => !e.abstract && !extended.has(e.name))
374
+ .flatMap(e => (e.fields ?? [])
375
+ .filter(f => !(0, ir_1.isFieldBound)(f))
376
+ .map(f => `'${e.name}.${f.name}'`));
377
+ if (!fields.length)
378
+ return undefined;
379
+ const many = fields.length > 1;
380
+ return `which leave ${many ? 'fields' : 'field'} ${(0, loader_1.listed)(fields)} without an expression; a graph deployment ` +
381
+ `needs every field of a concrete leaf entity bound. Give ${many ? 'each field' : 'the field'} an expression, or use a ` +
382
+ `named profile that lists ${many ? 'them' : 'it'} in 'fields_exclude'.`;
383
+ }
384
+ // The error for a metric whose name matches, ignoring case, a declared or
385
+ // inherited field of `owner`, or undefined.
386
+ function metricFieldCollisionError(metric, owner, fieldsByEntity, model, document) {
387
+ const fields = owner === undefined ? undefined : fieldsByEntity.get(owner);
388
+ if (!fields)
389
+ return undefined;
390
+ const lower = metric.name.toLowerCase();
391
+ const match = [...fields].find(f => f.toLowerCase() === lower);
392
+ if (match === undefined)
393
+ return undefined;
394
+ return `Metric '${metric.name}' in model '${model.name}' (${document}) collides with field '${match}' on entity '${owner}'; metrics and fields on the same entity share a ` +
395
+ `namespace.`;
396
+ }
397
+ // The names a graph on `engine` cannot hold in `model`, compared ignoring
398
+ // case, each as the end of a sentence about a deployment. A metric named like
399
+ // a field of its own entity is left to metricFieldCollisionError.
400
+ function graphNameProblems(model, engine) {
401
+ const at = `targets ${engine === 'bigquery' ? 'BigQuery' : 'Spanner'}, where`;
402
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
403
+ const problems = [];
404
+ for (const r of model.relationships ?? []) {
405
+ const e = model.entities?.find(e => same(e.name, r.name));
406
+ if (!e)
407
+ continue;
408
+ problems.push(`${at} entity '${e.name}' and relationship '${r.name}' cannot both ` +
409
+ `name a table: node and edge tables share one set of names, ` +
410
+ `compared ignoring case; rename one.`);
411
+ }
412
+ if (engine === 'spanner')
413
+ return problems;
414
+ const tables = (0, resolve_profiles_1.resolvedEntities)(model).filter(e => (0, resolve_profiles_1.statesKey)(e.keys, e.uniqueKeys));
415
+ for (const m of model.metrics ?? []) {
416
+ const owner = m.authoredEntity ?? m.entity;
417
+ for (const e of tables.filter(e => e.name !== owner)) {
418
+ const f = e.fields.find(f => same(f.name, m.name));
419
+ if (!f)
420
+ continue;
421
+ problems.push(`${at} metric '${m.name}' cannot share its name with field '${e.name}.${f.name}': a measure's name must differ from every ` +
422
+ `property name in the graph, compared ignoring case; rename one.`);
423
+ }
424
+ }
425
+ return problems;
426
+ }
427
+ // What a BigQuery graph rejects in the OPTIONS kcmd writes for `model`, each
428
+ // as the end of a sentence about a deployment. It reads only the labels and
429
+ // properties the generator writes OPTIONS for.
430
+ function bigQueryOptionProblems(model) {
431
+ const problems = [];
432
+ const check = (kind, name, description, synonyms = [], entity) => {
433
+ const what = `${kind} '${entity ? `${entity}.` : ''}${name}'`;
434
+ if (description && description.length > names_1.MAX_BIGQUERY_DESCRIPTION) {
435
+ problems.push(`the description kcmd writes for ${what} is ${description.length} characters long; a label or ` +
436
+ `property's description holds at most ${names_1.MAX_BIGQUERY_DESCRIPTION}.`);
437
+ }
438
+ if (synonyms.length > names_1.MAX_BIGQUERY_SYNONYM_COUNT) {
439
+ problems.push(`${what} has ${synonyms.length} synonyms; a label or property ` +
440
+ `holds at most ${names_1.MAX_BIGQUERY_SYNONYM_COUNT}.`);
441
+ }
442
+ const firstByLower = new Map();
443
+ for (const s of synonyms) {
444
+ const lower = s.toLowerCase();
445
+ if (!s)
446
+ problems.push(`${what} has an empty synonym; remove it.`);
447
+ if (s.length > names_1.MAX_BIGQUERY_SYNONYM_LENGTH) {
448
+ problems.push(`a synonym of ${what} is ${s.length} characters long; a ` +
449
+ `synonym holds at most ${names_1.MAX_BIGQUERY_SYNONYM_LENGTH}.`);
450
+ }
451
+ if (lower === name.toLowerCase()) {
452
+ problems.push(`${what} has the synonym '${s}', which BigQuery treats as ` +
453
+ `the ${kind}'s own name; remove it.`);
454
+ }
455
+ const first = firstByLower.get(lower);
456
+ if (first === undefined) {
457
+ firstByLower.set(lower, s);
458
+ }
459
+ else {
460
+ problems.push(`${what} has the synonyms '${first}' and '${s}', which ` +
461
+ `BigQuery treats as one; remove one.`);
462
+ }
463
+ }
464
+ };
465
+ const element = (kind, x) => check(kind, x.name, (0, bigquery_1.elementDescription)(x.description, x.aiContext), x.aiContext?.synonyms);
466
+ const tables = (0, resolve_profiles_1.resolvedEntities)(model).filter(e => (0, resolve_profiles_1.statesKey)(e.keys, e.uniqueKeys));
467
+ const extended = new Set(tables.flatMap(e => e.extends ?? []));
468
+ for (const e of tables) {
469
+ if (!extended.has(e.name))
470
+ element('entity', e);
471
+ for (const f of e.fields) {
472
+ if ((0, ir_1.expressionForDialect)(f, 'BIGQUERY') === undefined)
473
+ continue;
474
+ check('field', f.name, (0, bigquery_1.fieldDescription)(f), f.aiContext?.synonyms, e.name);
475
+ }
476
+ }
477
+ for (const r of model.relationships ?? [])
478
+ element('relationship', r);
479
+ for (const m of model.metrics ?? [])
480
+ element('metric', m);
481
+ return problems.map(p => `targets BigQuery, where ${p}`);
482
+ }
483
+ // Static, target-independent checks for a model's actions. Returns one message
484
+ // per violation. What can be statically wrong once the model has parsed:
485
+ // - a parameter projects from a concept the model does not declare, or a
486
+ // field that concept does not have (the loader kept it and only warned) --
487
+ // each reported as itself, because "fix the concept" and "fix the field"
488
+ // are two different repairs;
489
+ // - a parameter resolved to no scalar type at all, or to something that is
490
+ // not one -- the commonest case being an entity name written as the
491
+ // parameter's `type`, which is the old object-reference spelling;
492
+ // - an executor is missing a coordinate a runtime needs to dispatch it (an
493
+ // empty server/tool, endpoint/method, or service/method) -- the schema
494
+ // accepts empty strings, so this is caught here rather than at parse;
495
+ // - a guard names a constraint the model does not declare;
496
+ // - an `affects` entry names a concept the model does not declare, names
497
+ // fields on a 'delete' (which takes the whole instance), or names a field
498
+ // the concept does not have.
499
+ //
500
+ // Every `affects` check is a hard error for the reason the guard check is: an
501
+ // entry that names nothing real leaves a reader believing the blast radius is
502
+ // described when it is not, and a consumer routing on it would route on a
503
+ // concept that does not exist.
504
+ function validateActions(model, document, fieldsPruned, checkDescriptions) {
505
+ const errors = [];
506
+ const actions = model.actions ?? [];
507
+ if (!actions.length)
508
+ return errors;
509
+ const constraintNames = new Set((model.constraints ?? []).map(c => c.name));
510
+ // Built when an `affects` entry or a projected parameter will actually read
511
+ // it, and through `ifResolvable` because declaredConcepts resolves
512
+ // inheritance.
513
+ //
514
+ // `fieldsPruned` stands the whole lookup down, which is what a profile push
515
+ // needs: pruning removes unbound and unavailable fields, so the field a
516
+ // parameter projects from may be gone from the model in hand even though the
517
+ // author's document names one that exists. A parameter needs only
518
+ // the logical definition and the loader already copied it down, so nothing
519
+ // about the push depends on resolving the reference a second time here.
520
+ // A `type` naming something that is not a scalar counts too, and it is the
521
+ // case that most needs the lookup: the message worth printing is "that is an
522
+ // entity, project the field you meant", and only the concept table can tell
523
+ // an entity from a typo. Leaving it out meant the one model making exactly
524
+ // this mistake -- the migration from an entity-reference parameter, with no
525
+ // `affects` and nothing yet projected -- got the generic "not a scalar
526
+ // datatype" instead of the instruction.
527
+ const needsConcepts = actions.some(a => a.affects?.length ||
528
+ a.parameters.some(p => p.concept ||
529
+ (p.type &&
530
+ !ir_1.DATA_TYPES.includes(p.type))));
531
+ const concepts = !fieldsPruned && needsConcepts ?
532
+ ifResolvable(() => declaredConcepts(model)) :
533
+ undefined;
534
+ for (const action of actions) {
535
+ const where = `action '${action.name}' in model '${model.name}' (${document})`;
536
+ // Two parameters are confusable when a caller cannot tell from their types
537
+ // which is which, so this keys on the datatype the caller actually sees in
538
+ // the tool schema. Projecting the SAME field is the stronger case of that
539
+ // -- two Account.accountId parameters are not merely both integers, they
540
+ // denote the same kind of thing -- but it needs no key of its own: a
541
+ // projected parameter takes the field's type, so a same-field pair is
542
+ // already a same-type pair. The projection only picks the wording below.
543
+ const byIdentity = new Map();
544
+ for (const param of action.parameters) {
545
+ errors.push(...parameterTypeErrors(param, where, concepts));
546
+ if (param.required === true && param.default !== undefined) {
547
+ errors.push(`${where} has parameter '${param.name}' with both ` +
548
+ `'required: true' and a 'default'; a parameter with a default is ` +
549
+ `optional.`);
550
+ }
551
+ // Every parameter is a scalar now, so every default is checkable --
552
+ // including one on a parameter whose definition came from a field.
553
+ if (param.default !== undefined && param.default !== null &&
554
+ param.type !== undefined) {
555
+ const bound = (0, parameters_1.bindScalar)(param, param.default);
556
+ if ('error' in bound) {
557
+ errors.push(`${where} has parameter '${param.name}' whose default '${param.default}' is invalid: ${bound.error}`);
558
+ }
559
+ }
560
+ const identity = param.type ?? '';
561
+ if (identity === '')
562
+ continue;
563
+ const list = byIdentity.get(identity) ?? [];
564
+ list.push(param);
565
+ byIdentity.set(identity, list);
566
+ }
567
+ if (checkDescriptions) {
568
+ for (const [identity, params] of byIdentity) {
569
+ if (params.length <= 1)
570
+ continue;
571
+ // A description has to be the parameter's OWN to separate it from its
572
+ // twin. Two parameters projected from one field come out of the loader
573
+ // carrying that field's wording verbatim, so both are described and
574
+ // neither is distinguished -- an identical description is worth no
575
+ // more here than a missing one.
576
+ const shares = new Map();
577
+ for (const p of params) {
578
+ const said = p.description?.trim() ?? '';
579
+ shares.set(said, (shares.get(said) ?? 0) + 1);
580
+ }
581
+ const indistinct = params.filter(p => shares.get(p.description?.trim() ?? '') > 1 ||
582
+ !p.description?.trim());
583
+ if (indistinct.length > 0) {
584
+ const names = indistinct.map(p => `'${p.name}'`);
585
+ // Name the projection only when every one of them came from the one
586
+ // field AND that field is what more than one parameter came from.
587
+ // A mixed bucket has nothing in common but the datatype. So does a
588
+ // bucket holding a single projection beside a described parameter of
589
+ // the same type: the projection is indistinct, but blaming
590
+ // "multiple parameters projected from Account.accountId" names a
591
+ // duplication that is not there, and sends the author looking for a
592
+ // second projection to delete.
593
+ const pairOf = (p) => p.concept !== undefined ? `${p.concept}.${p.field}` : '';
594
+ const projections = new Set(indistinct.map(pairOf));
595
+ const only = projections.size === 1 ? [...projections][0] : '';
596
+ const from = only !== '' && params.filter(p => pairOf(p) === only).length > 1 ?
597
+ only :
598
+ '';
599
+ const shared = from !== '' ?
600
+ `multiple parameters projected from '${from}'` :
601
+ `multiple parameters of type '${identity}'`;
602
+ const one = names.length === 1;
603
+ errors.push(`${where} has ${shared}, so ${one ? `parameter ${names[0]}` :
604
+ `parameters ${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`} must ${one ? 'have' : 'each have'} a 'description' of ${one ? 'its' :
605
+ 'their'} own to distinguish ${one ? 'it' : 'them'}.`);
606
+ }
607
+ }
608
+ }
609
+ // No executor is not an error: it is an action no binding performs here,
610
+ // which the availability pass reports rather than the validator.
611
+ const executor = action.executor;
612
+ if (executor !== undefined) {
613
+ for (const missing of missingExecutorFields(executor)) {
614
+ errors.push(`${where} has an ${executor.kind} executor whose '${missing}' is missing or blank.`);
615
+ }
616
+ }
617
+ // An unresolved guard leaves the author believing the write is checked when
618
+ // nothing checks it, so it fails the push rather than warning. Constraint
619
+ // names are model-scoped and the loader rejects duplicates, so a name
620
+ // either resolves here or names nothing at all.
621
+ for (const guard of action.guards ?? []) {
622
+ if (!constraintNames.has(guard)) {
623
+ errors.push(`${where} is guarded by '${guard}', but model '${model.name}' declares no constraint of that name.`);
624
+ }
625
+ }
626
+ errors.push(...affectedConceptErrors(action, where, concepts));
627
+ errors.push(...sqlExecutorErrors(action, where));
628
+ }
629
+ return errors;
630
+ }
631
+ // The errors in a SQL executor's statements. Nothing here for the other three
632
+ // executor kinds: they name a system that performs the write, so the model has
633
+ // no text to check.
634
+ //
635
+ // A SQL executor contains the write, which is what makes its blast radius
636
+ // checkable and its guards enforceable. The same property makes it the one
637
+ // executor kind that could smuggle an unreviewed write into a governed model, so
638
+ // the shape is pinned rather than trusted:
639
+ //
640
+ // - One DML verb per statement. A statement that reads is a query, and a
641
+ // statement that reshapes the schema is not an action.
642
+ // - No statement separator. Each statement is executed on its own, so a
643
+ // semicolon means the author expected a second statement to run and it
644
+ // silently would not.
645
+ // - Every `@parameter` is declared. This is the load-bearing one: it is what
646
+ // lets the runtime BIND every value instead of interpolating it, so no
647
+ // argument can reach the store as SQL.
648
+ function sqlExecutorErrors(action, where) {
649
+ if (action.executor?.kind !== 'sql')
650
+ return [];
651
+ const executor = action.executor;
652
+ const errors = [];
653
+ const bindable = new Set(action.parameters.map(p => p.name));
654
+ executor.sql.statements.forEach((stmt, i) => {
655
+ const at = `${where} has a sql executor whose statement ${i + 1}`;
656
+ const text = stmt.trim();
657
+ if (!text) {
658
+ errors.push(`${at} is blank.`);
659
+ return;
660
+ }
661
+ // The same reader the runtime uses, deliberately: what a model may
662
+ // PUBLISH and what the runtime will EXECUTE have to agree about where a
663
+ // statement's verb is. A naive first-word read disagreed -- the runtime
664
+ // ran `-- why\nUPDATE ...` and a CTE ahead of the verb, both covered by
665
+ // tests, while this rejected them at push time, so the library accepted an
666
+ // authored executor `kcmd` refused. Sharing the scanner does not widen
667
+ // what may be published: it can return MERGE, which SQL_EXECUTOR_VERBS
668
+ // does not list, so a MERGE is still refused here.
669
+ const verb = (0, sql_identifiers_1.leadingDmlVerb)(text);
670
+ if (!ir_1.SQL_EXECUTOR_VERBS.includes(verb)) {
671
+ // Name what the author wrote, not merely that nothing was found. The
672
+ // scanner reads DML verbs only, so a SELECT executor -- the likeliest
673
+ // mistake here -- comes back empty, and 'has no readable DML verb' would
674
+ // describe a query the author can see perfectly well. When the statement
675
+ // does not plainly open with a word, the first token is a comment marker
676
+ // and names nothing, so report the absence instead.
677
+ const first = text.split(/\s/, 1)[0].toUpperCase();
678
+ const wrote = verb || (/^[A-Z_][A-Z0-9_]*$/.test(first) ? first : '');
679
+ errors.push(`${at} ${wrote ? `starts with '${wrote}'` :
680
+ 'has no readable DML verb'}, but a statement must be ` +
681
+ `one of ${ir_1.SQL_EXECUTOR_VERBS.join(', ')}.`);
682
+ }
683
+ if (text.slice(0, -1).includes(';')) {
684
+ errors.push(`${at} contains ';'. Each statement runs on its own, so write ` +
685
+ `one statement per list entry.`);
686
+ }
687
+ for (const name of (0, sql_identifiers_1.referencedParameters)(text)) {
688
+ if (!bindable.has(name)) {
689
+ errors.push(`${at} binds '@${name}', but action '${action.name}' ` +
690
+ `declares no parameter of that name.`);
691
+ }
692
+ }
693
+ });
694
+ return errors;
695
+ }
696
+ // The errors in one action's `affects`. Split out because the checks chain: a
697
+ // concept that does not resolve makes every later check about it meaningless,
698
+ // so an entry that fails one of those says nothing more. Undeclared fields do
699
+ // not chain -- each is an independent fact about a concept that did resolve --
700
+ // so an entry reports all of them.
701
+ //
702
+ // An absent `concepts` says the model reaching this point is a profile's view
703
+ // of the author's model, not the author's model. Everything that reads the
704
+ // ontology stands down there, because pruneUnavailable drops the fields a
705
+ // profile leaves unbound, and the metrics that read them. An entry naming a
706
+ // dropped field is the profile's doing rather than the author's, and failing
707
+ // the deploy over it would fail it for no reason. An action reaches no graph in any case. What is left is the one
708
+ // check that reads only the entry itself.
709
+ function affectedConceptErrors(action, where, concepts) {
710
+ const errors = [];
711
+ for (const affected of action.affects ?? []) {
712
+ // A `delete` takes the whole instance, so naming fields alongside one is
713
+ // self-contradictory whatever the ontology says.
714
+ if (affected.fields?.length && affected.operation === 'delete') {
715
+ errors.push(`${where} affects '${affected.concept}' with operation 'delete' ` +
716
+ `and also names fields. A 'delete' takes the whole instance; drop ` +
717
+ `the fields.`);
718
+ continue;
719
+ }
720
+ if (!concepts)
721
+ continue;
722
+ const concept = concepts.get(affected.concept);
723
+ if (!concept) {
724
+ errors.push(`${where} affects '${affected.concept}', which is neither an ` +
725
+ `entity nor a relationship this model declares.`);
726
+ continue;
727
+ }
728
+ for (const field of affected.fields ?? []) {
729
+ if (!concept.fields.has(field)) {
730
+ errors.push(`${where} affects '${affected.concept}.${field}', but ${concept.kind} '${affected.concept}' declares no field '${field}'.`);
731
+ }
732
+ }
733
+ }
734
+ return errors;
735
+ }
736
+ // What a `concept` may name, and the fields it has. Shared with the loader --
737
+ // which resolves a projected parameter against exactly this -- so the two can
738
+ // never disagree about whether `Order` exists or what it declares.
739
+ const declaredConcepts = resolve_inheritance_1.declaredConceptFields;
740
+ // Why a parameter has no usable scalar type, or nothing when it has one.
741
+ //
742
+ // Each shape of the mistake gets its own message, because each has its own
743
+ // repair. Telling an author who misspelled a field that the concept is
744
+ // unknown, or telling one who wrote `type: Account` that `Account` is not a
745
+ // datatype, sends them to the wrong line.
746
+ //
747
+ // The projection is checked only when the ontology is in hand: `concepts` is
748
+ // absent for a profile's pruned view, where a concept the author named may
749
+ // legitimately be gone (see affectedConceptErrors). The type check below
750
+ // survives that, because the loader resolved the type before any pruning ran.
751
+ function parameterTypeErrors(param, where, concepts) {
752
+ const errors = [];
753
+ if (param.concept !== undefined && concepts) {
754
+ const concept = concepts.get(param.concept);
755
+ if (!concept) {
756
+ errors.push(`${where} has parameter '${param.name}' projected from '${param.concept}', which is neither an entity nor a ` +
757
+ `relationship this model declares.`);
758
+ }
759
+ else if (!concept.fields.has(param.field ?? '')) {
760
+ errors.push(`${where} has parameter '${param.name}' projected from field '${param.field}', which ${concept.kind} '${param.concept}' does not declare.`);
761
+ }
762
+ }
763
+ if (param.type === undefined) {
764
+ // A projection this pass could not resolve at all says nothing about the
765
+ // field. `concepts` is undefined when the concept table would not build --
766
+ // a dangling `extends`, which is reported on its own line -- and the
767
+ // loader then drops `extends` model-wide, so a parameter projecting from
768
+ // an INHERITED field arrives here typeless for a reason that has nothing
769
+ // to do with the field. Blaming it would send the author to a field that
770
+ // is correctly typed, and away from the `extends` that is the actual
771
+ // fault.
772
+ if (param.concept !== undefined && !concepts)
773
+ return errors;
774
+ // A projection that failed above already says why there is no type; a
775
+ // second line repeating it would only add noise.
776
+ if (!errors.length) {
777
+ errors.push(param.concept !== undefined ?
778
+ // The projection resolved -- the field is simply untyped. Saying
779
+ // "state a type, or project one from a field" here would advise
780
+ // the author to do what they already did, and send them looking
781
+ // at the action instead of at the field that is missing one.
782
+ `${where} has parameter '${param.name}' projected from field '${param.concept}.${param.field}', which declares no ` +
783
+ `datatype. Give that field a scalar 'type' (${ir_1.DATA_TYPES.join('/')}) and the parameter takes it.` :
784
+ `${where} has parameter '${param.name}' with no type. A ` +
785
+ `parameter states a scalar 'type' (${ir_1.DATA_TYPES.join('/')}), or projects one from a field ` +
786
+ `with 'concept' and 'field'.`);
787
+ }
788
+ return errors;
789
+ }
790
+ if (ir_1.DATA_TYPES.includes(param.type))
791
+ return errors;
792
+ const named = concepts?.get(param.type);
793
+ errors.push(named ? `${where} has parameter '${param.name}' typed '${param.type}', which is ${named.kind === 'entity' ? 'an entity' : 'a relationship'} ` +
794
+ `rather than a scalar datatype. A parameter carries a value, ` +
795
+ `so take one from ${param.type} by projecting it: ` +
796
+ `{concept: ${param.type}, field: <field>}.` :
797
+ `${where} has parameter '${param.name}' typed '${param.type}', which is not a scalar datatype (${ir_1.DATA_TYPES.join('/')}).`);
798
+ return errors;
799
+ }
800
+ // Static, target-independent checks for a model's constraints.
801
+ //
802
+ // A constraint states its rule as a `judgment`, and every constraint must state
803
+ // one: a constraint with no body states no rule at all. (A document that states
804
+ // the removed `expression` body never gets this far -- rejectExpressionBody in
805
+ // loader.ts fails the parse and says where the rule goes instead.)
806
+ //
807
+ // A judgment gets two checks, in judgedConstraintErrors: it must say what a
808
+ // violation does, and every `<Entity>.<field>` token naming a KNOWN entity must
809
+ // name a field that entity declares. The second catches a typo that would
810
+ // otherwise surface only inside an agent's rejected action -- a rule is as easy
811
+ // to misspell in a sentence as anywhere else, and a judge handed `Order.totl`
812
+ // is being asked about a column that does not exist.
813
+ //
814
+ // Everything else is left alone. So a leading qualifier that is not a known
815
+ // entity is not guessed at here: a relationship-qualified name like
816
+ // `OrderedAs.quantity`, a metric reference, an ordinary English word that
817
+ // happens to contain a dot. A valid constraint must never be falsely rejected.
818
+ //
819
+ // Keeping that promise takes care, because the model reaching this function is
820
+ // not the document the author wrote. Its field lists have moved twice:
821
+ // - Inheritance is still AS DECLARED. `extends` is flattened by the graph
822
+ // legs, which run after this gate, so a subtype's `fields` here omit every
823
+ // field it inherits. Looking the field up on the resolved model fixes that.
824
+ // - A profile push has already pruned unbound fields (`fieldsPruned`). The
825
+ // author's field is gone from the model, and resolving does not bring it
826
+ // back, so the field check stands down. A constraint reaches no graph in
827
+ // any case, and failing a push over a field this profile does not bind
828
+ // would refuse a deploy for no reason.
829
+ function validateConstraints(model, document, fieldsPruned) {
830
+ const errors = [];
831
+ const constraints = model.constraints ?? [];
832
+ if (!constraints.length)
833
+ return errors;
834
+ const fieldsByEntity = fieldsPruned ? undefined : ifResolvable(() => declaredFields(model));
835
+ // Names the model declares that are not fields of any one entity, which a
836
+ // token's tail may legitimately carry. See unknownFieldRefs.
837
+ const nonFieldNames = new Set([
838
+ ...(model.relationships ?? []).map(r => r.name),
839
+ ...(model.metrics ?? []).map(m => m.name),
840
+ ]);
841
+ for (const c of constraints) {
842
+ const where = `constraint '${c.name}' in model '${model.name}' (${document})`;
843
+ if (c.judgment === undefined) {
844
+ errors.push(`${where} declares no judgment. A constraint states its rule in ` +
845
+ `words, under 'judgment', and says what a violation does, under ` +
846
+ `'on_violation'.`);
847
+ continue;
848
+ }
849
+ errors.push(...judgedConstraintErrors(c, where, fieldsByEntity, nonFieldNames));
850
+ }
851
+ return errors;
852
+ }
853
+ // What a constraint must satisfy, beyond stating a body at all.
854
+ //
855
+ // It must say what a violation does. Any of the three words is allowed,
856
+ // `reject` included, but silence is not: an unmarked constraint rejects, and
857
+ // inheriting the harshest consequence by omission is the one outcome an author
858
+ // is least likely to have meant. Nothing here reads the prose to check the word
859
+ // against it -- the prose is prose, and a check that asked a model whether a
860
+ // sentence means refusal would be no guardrail at all.
861
+ //
862
+ // The other check resolves the `Entity.field` tokens the prose mentions, which
863
+ // is the whole of the static checking a rule settled by reading can get.
864
+ function judgedConstraintErrors(c, where, fieldsByEntity, nonFieldNames) {
865
+ const errors = [];
866
+ // The two checks are independent, so an empty judgment does not skip the
867
+ // routing one. Reporting only the empty body would send the author back for a
868
+ // second failure over a key they were never told about.
869
+ const empty = !c.judgment.trim();
870
+ if (empty) {
871
+ errors.push(`${where} has an empty judgment.`);
872
+ }
873
+ if (c.onViolation === undefined) {
874
+ errors.push(`${where} must state on_violation: 'reject' to ` +
875
+ `refuse the write, 'escalate' to hold it for a person, or 'warn' to ` +
876
+ `let it through and report it. An unmarked constraint rejects, which ` +
877
+ `is too strong a thing to inherit by leaving the key out.`);
878
+ }
879
+ if (!empty) {
880
+ errors.push(...unknownFieldRefs(c.judgment, where, fieldsByEntity, nonFieldNames));
881
+ }
882
+ return errors;
883
+ }
884
+ // Every `Entity.field` token in a judgment whose entity the model declares and
885
+ // whose field it does not.
886
+ //
887
+ // What makes a token a reference is that the model declares the entity, so no
888
+ // spelling heuristic is needed and none is used: entity names here are as often
889
+ // lowercase (`customer`, `orders`) as capitalized, and a rule keyed on the
890
+ // capital would check some models and quietly skip others. An unrecognized
891
+ // entity name is left alone on the principle that keeps this scan
892
+ // conservative -- a rule may name a concept from another system, and
893
+ // refusing to guess is what stops a valid rule being falsely rejected. A known
894
+ // entity with an unknown field is the case where the author plainly meant this
895
+ // model and got the name wrong, so that one is an error.
896
+ // An entity that declares no fields at all is unknowable in the same way an
897
+ // unrecognized name is, so the scan stands down there too. Fields are optional
898
+ // on an entity, and a logical model bound to nothing but Knowledge Catalog
899
+ // routinely declares none; reading an empty set as "this entity has no such
900
+ // field" would refuse every constraint such a model can write.
901
+ //
902
+ // A tail that names something the model declares is not a misspelled field
903
+ // either. Traversal has no syntax in the model today, so a prose token like
904
+ // `Customer.Order` or `LineItem.BelongsTo` reads as a field of the head and
905
+ // would be rejected for a field the author never claimed existed; the same goes
906
+ // for `Order.total_revenue`, where metrics are model-level and the qualifier is
907
+ // the entity the metric hangs off. The scan therefore settles only the case it
908
+ // can: a tail the model does not declare under any kind is the misspelling.
909
+ //
910
+ // The two segments must be adjacent to the dot, which is what keeps a sentence
911
+ // boundary ("check the memo. Every credit...") out of the scan. A decimal
912
+ // number cannot survive either, since no entity is named `30`.
913
+ function unknownFieldRefs(judgment, where, fieldsByEntity, nonFieldNames) {
914
+ if (!fieldsByEntity)
915
+ return [];
916
+ const errors = [];
917
+ const seen = new Set();
918
+ const token = /\b([A-Za-z_]\w*)\.([A-Za-z_]\w*)/g;
919
+ for (let m = token.exec(judgment); m; m = token.exec(judgment)) {
920
+ const [, entity, field] = m;
921
+ // A token with a third segment (`Order.lineItems.amount`) is a path rather
922
+ // than a field of `Order`, and reading its middle segment as one would
923
+ // reject it for a field the author never claimed existed. Nothing in the
924
+ // model defines path syntax today, so the scan declines to guess in both
925
+ // directions: a token followed by another dotted segment is skipped, and so
926
+ // is one preceded by a dot, which is how the tail of a path presents.
927
+ const after = judgment.slice(m.index + m[0].length);
928
+ if (/^\.\w/.test(after) || (m.index > 0 && judgment[m.index - 1] === '.')) {
929
+ continue;
930
+ }
931
+ const fields = fieldsByEntity.get(entity);
932
+ if (!fields || !fields.size || fields.has(field))
933
+ continue;
934
+ // The tail names something the model declares that is not a field of the
935
+ // head: another entity, a relationship, or a metric. `Customer.Order` and
936
+ // `LineItem.BelongsTo` are traversals and `Order.total_revenue` is a metric
937
+ // reference, none of which this scan can settle, and all of which an author
938
+ // writing prose reaches for. Only a tail the model does not declare at all
939
+ // is read as the misspelling this check exists to catch.
940
+ if (fieldsByEntity.has(field) || nonFieldNames.has(field))
941
+ continue;
942
+ const ref = `${entity}.${field}`;
943
+ if (seen.has(ref))
944
+ continue;
945
+ seen.add(ref);
946
+ errors.push(`${where} references '${ref}', but entity '${entity}' declares no ` +
947
+ `field '${field}'.`);
948
+ }
949
+ return errors;
950
+ }
951
+ // What `build` returns, or nothing when the model's inheritance does not
952
+ // resolve.
953
+ //
954
+ // `declaredFields` and `declaredConcepts` both resolve inheritance, and
955
+ // resolving THROWS on an `extends` naming an entity the model does not declare,
956
+ // on a cycle, and on an ambiguously inherited field, rather than reporting it.
957
+ // The loader accepts such a model, a
958
+ // Knowledge-Catalog-only push reaches no graph leg to catch it, and a profile
959
+ // push can create one by pruning a supertype whole. A validation gate reports;
960
+ // it does not stack-trace, so every caller that resolves inheritance to answer
961
+ // a question comes through here and stands its own check down when the answer
962
+ // is unavailable. That is the same thing a pruned profile does. Standing down
963
+ // is not silence: validatePushRequirements reports the resolution failure once
964
+ // per model, so the larger problem is named and only the checks that depend on
965
+ // the resolved model go quiet.
966
+ function ifResolvable(build) {
967
+ try {
968
+ return build();
969
+ }
970
+ catch {
971
+ return undefined;
972
+ }
973
+ }
974
+ // Why resolving the model's inheritance fails, or nothing when it succeeds.
975
+ //
976
+ // Reported rather than thrown, and reported once for the model rather than once
977
+ // per check that needed it. A model declaring no inheritance cannot fail, and
978
+ // resolving clones, so it is not asked.
979
+ function inheritanceFailure(model) {
980
+ if (!(model.entities ?? []).some(e => e.extends?.length))
981
+ return undefined;
982
+ try {
983
+ (0, resolve_inheritance_1.resolveInheritance)(model);
984
+ return undefined;
985
+ }
986
+ catch (e) {
987
+ const message = e instanceof Error ? e.message : String(e);
988
+ return {
989
+ kind: e instanceof resolve_inheritance_1.InheritanceError ? e.kind : 'other',
990
+ message: message.endsWith('.') ? message : `${message}.`,
991
+ };
992
+ }
993
+ }
994
+ // The authored name of each property that defines a field rather than binds
995
+ // it, for messages.
996
+ const AUTHORED_NAME = {
997
+ type: 'datatype',
998
+ aiContext: 'ai_context',
999
+ customExtensions: 'custom_extensions',
1000
+ };
1001
+ /**
1002
+ * Returns one message per broken inheritance or concrete-leaf rule, or an
1003
+ * empty list: a cycle, an unknown parent, a field inherited from two
1004
+ * unrelated ancestors, or two fields of one entity whose names differ only in
1005
+ * case; a redeclared inherited field; an
1006
+ * abstract entity with a table; and a relationship or metric on an entity that
1007
+ * is not a concrete leaf. Pruning can remove the field a rule is about, so a
1008
+ * push that prunes runs this on the model before pruning as well as after.
1009
+ */
1010
+ function validateInheritance(models) {
1011
+ const errors = [];
1012
+ for (const { document, model } of models) {
1013
+ // Resolving inheritance throws on an `extends` naming an entity the model
1014
+ // does not declare, on a cycle, on a field inherited from two unrelated
1015
+ // ancestors, and on two fields of one entity whose names differ only in
1016
+ // case, and the checks below stand down rather than stack-trace on
1017
+ // one. Standing down has to mean reporting somewhere or it means
1018
+ // publishing a broken model in silence: the loader accepts such a model,
1019
+ // and a Knowledge-Catalog-only push reaches no graph leg that would resolve
1020
+ // inheritance and catch it. So the failure is reported here, once per
1021
+ // model. Pruning never removes an entity, so an unknown parent is always
1022
+ // the author's.
1023
+ const failure = inheritanceFailure(model);
1024
+ if (failure) {
1025
+ errors.push(`model '${model.name}' (${document}): ${failure.message} Checks ` +
1026
+ `that need the resolved model are skipped until this is fixed.`);
1027
+ }
1028
+ errors.push(...inheritanceRuleErrors(model, document));
1029
+ }
1030
+ return errors;
1031
+ }
1032
+ // The rules inheritance puts on a model beyond resolving it:
1033
+ // - a subtype may redeclare an inherited field only to rebind it: the line
1034
+ // sets `expression` and nothing else;
1035
+ // - an abstract entity has no table, so it declares no source, no key and no
1036
+ // bound field;
1037
+ // - a relationship endpoint, and the entity a metric belongs to, is a
1038
+ // concrete leaf: not abstract, and extended by no other entity. An edge
1039
+ // joins one table to one table and a measure binds to one table, while a
1040
+ // non-leaf entity stands for the union of its descendants' tables.
1041
+ // The first rule needs the resolved model and is skipped when resolution
1042
+ // fails; that failure is reported once elsewhere.
1043
+ function inheritanceRuleErrors(model, document) {
1044
+ const errors = [];
1045
+ const entities = model.entities ?? [];
1046
+ const where = (name) => `entity '${name}' in model '${model.name}' (${document})`;
1047
+ const resolved = entities.some(e => e.extends?.length) ?
1048
+ ifResolvable(() => (0, resolve_inheritance_1.resolveInheritance)(model).model.entities ?? []) :
1049
+ undefined;
1050
+ if (resolved) {
1051
+ const ancestorsOf = new Map(resolved.map(e => [e.name, e.extends ?? []]));
1052
+ const ownNames = new Map(entities.map(e => [e.name, new Set(e.fields.map(f => f.name))]));
1053
+ for (const entity of entities) {
1054
+ const inherited = new Set((ancestorsOf.get(entity.name) ?? [])
1055
+ .flatMap(a => [...(ownNames.get(a) ?? [])]));
1056
+ for (const field of entity.fields) {
1057
+ if (!inherited.has(field.name))
1058
+ continue;
1059
+ const defined = ir_1.FIELD_DEFINITION_KEYS.filter(k => field[k] !== undefined)
1060
+ .map(k => `'${AUTHORED_NAME[k] ?? k}'`);
1061
+ if (defined.length) {
1062
+ errors.push(`${where(entity.name)}: field '${field.name}' is inherited, so ` +
1063
+ `it may only be rebound to a column; remove ${defined.join(', ')}, which an ancestor defines.`);
1064
+ }
1065
+ else if (!(0, ir_1.isFieldBound)(field)) {
1066
+ // A redeclaration sets `expression` and nothing else, so a line with
1067
+ // only the name is not one.
1068
+ const from = (ancestorsOf.get(entity.name) ?? [])
1069
+ .find(a => ownNames.get(a)?.has(field.name));
1070
+ errors.push(`${where(entity.name)}: field '${field.name}' is inherited from ` +
1071
+ `'${from}'; restate it only to rebind it with 'expression', or ` +
1072
+ `remove the line.`);
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+ for (const entity of entities) {
1078
+ if (!entity.abstract)
1079
+ continue;
1080
+ if (entity.dataSource || entity.authoredSource) {
1081
+ errors.push(`${where(entity.name)} is abstract, so it has no table and cannot ` +
1082
+ `declare a source.`);
1083
+ }
1084
+ if (entity.keys.length || entity.uniqueKeys?.length) {
1085
+ errors.push(`${where(entity.name)} is abstract, so it has no table and cannot ` +
1086
+ `declare a primary key or unique keys.`);
1087
+ }
1088
+ for (const field of entity.fields) {
1089
+ if ((0, ir_1.isFieldBound)(field)) {
1090
+ errors.push(`${where(entity.name)} is abstract, so its field '${field.name}' cannot carry an expression; bind it on each ` +
1091
+ `concrete subtype instead.`);
1092
+ }
1093
+ }
1094
+ }
1095
+ const abstractNames = new Set(entities.filter(e => e.abstract).map(e => e.name));
1096
+ const extendedNames = new Set(entities.flatMap(e => e.extends ?? []));
1097
+ const notLeaf = (name) => abstractNames.has(name) ?
1098
+ 'abstract' :
1099
+ extendedNames.has(name) ? 'extended by another entity' : undefined;
1100
+ for (const rel of model.relationships ?? []) {
1101
+ // A relationship from an entity to itself is reported once.
1102
+ for (const end of new Set([rel.source.entity, rel.destination.entity])) {
1103
+ const why = notLeaf(end);
1104
+ if (why) {
1105
+ errors.push(`relationship '${rel.name}' in model '${model.name}' (${document}) connects '${end}', which is ${why}; a ` +
1106
+ `relationship may connect only concrete leaf entities.`);
1107
+ }
1108
+ }
1109
+ }
1110
+ for (const metric of model.metrics ?? []) {
1111
+ const owner = metric.authoredEntity ?? metric.entity;
1112
+ const why = owner ? notLeaf(owner) : undefined;
1113
+ if (why) {
1114
+ errors.push(`metric '${metric.name}' in model '${model.name}' (${document}) belongs to '${owner}', which is ${why}; a metric ` +
1115
+ `may belong only to a concrete leaf entity.`);
1116
+ }
1117
+ }
1118
+ return errors;
1119
+ }
1120
+ // True when a rejected column string could be a column name written without
1121
+ // backticks, such as `order date`, `order-date`, `margin%` or `R&D cost`. An
1122
+ // empty value gets no hint. Neither does a value that starts or ends with
1123
+ // whitespace, because the fix there is to delete the whitespace. BigQuery
1124
+ // allows only letters, numeric characters, marks, connector punctuation,
1125
+ // dashes, whitespace and `& % = + : ' < > # |` in a column name, so any other
1126
+ // character rules out the hint. Of the operators isColumnName rejects,
1127
+ // BigQuery allows `- + % = < > | &`. Each of those counts as an operator at
1128
+ // the start of the value, or next to whitespace unless it ends the value. No
1129
+ // operator among them can end an expression, so `growth %` keeps the hint. A
1130
+ // value made only of words, such as `a AND b`, passes this test, so the hint
1131
+ // is worded as a condition.
1132
+ function couldBeUnquotedColumnName(col) {
1133
+ return col === col.trim() &&
1134
+ /^[\p{L}\p{N}\p{Pc}\p{Pd}\p{M}\s&%=+:'<>#|]+$/u.test(col) &&
1135
+ !/^[-+%=<>|&]|\s[-+%=<>|&](?!$)|[-+%=<>|&]\s/.test(col);
1136
+ }
1137
+ function invalidColumnNameError(owner, modelName, document, slot, col) {
1138
+ const hint = couldBeUnquotedColumnName(col) ?
1139
+ ' (if it names a column with spaces or special characters, wrap it ' +
1140
+ 'in backticks, for example `order date`)' :
1141
+ '';
1142
+ return `${owner} in model '${modelName}' (${document}): ${slot} column '${col}' is not a physical column name; keys and join columns must name ` +
1143
+ `physical table columns, not expressions or field references${hint}.`;
1144
+ }
1145
+ // Checks that every key column and join column is a physical column name and
1146
+ // not a SQL expression or an entity-qualified reference. Key columns are the
1147
+ // entries of `primary_key` and `unique_keys`. Join columns are the entries of
1148
+ // `from_columns` and `to_columns`. Also checks that every relationship's
1149
+ // endpoints name entities the model declares, and that its two join column
1150
+ // lists have equal length. The loader checks endpoints and lengths for a model
1151
+ // it reads, and this repeats them for a model built in memory. A relationship
1152
+ // from an entity to itself is rejected, and so are relationships in both
1153
+ // directions between two entities. Push runs this
1154
+ // on the model with its profile applied, so it also checks the key and join
1155
+ // columns a profile file supplies. An abstract entity's keys and an
1156
+ // association's direct join columns are not checked.
1157
+ function validateColumnsAndRelationships(model, document) {
1158
+ const errors = [];
1159
+ const entityNames = new Set((model.entities ?? []).map(e => e.name));
1160
+ // The first relationship from each entity to each other entity.
1161
+ const firstFrom = new Map();
1162
+ for (const entity of model.entities ?? []) {
1163
+ if (entity.abstract)
1164
+ continue;
1165
+ const owner = `entity '${entity.name}'`;
1166
+ for (const col of entity.keys) {
1167
+ if (!(0, sql_expr_utils_1.isColumnName)(col)) {
1168
+ errors.push(invalidColumnNameError(owner, model.name, document, 'primary_key', col));
1169
+ }
1170
+ }
1171
+ for (const uk of entity.uniqueKeys ?? []) {
1172
+ for (const col of uk) {
1173
+ if (!(0, sql_expr_utils_1.isColumnName)(col)) {
1174
+ errors.push(invalidColumnNameError(owner, model.name, document, 'unique_keys', col));
1175
+ }
1176
+ }
1177
+ }
1178
+ }
1179
+ for (const rel of model.relationships ?? []) {
1180
+ const owner = `relationship '${rel.name}'`;
1181
+ const at = `${owner} in model '${model.name}' (${document})`;
1182
+ if (!entityNames.has(rel.source.entity)) {
1183
+ errors.push(`${at}: ${(0, loader_1.undefinedEndpointProblem)('from', rel.source.entity)}.`);
1184
+ }
1185
+ if (!entityNames.has(rel.destination.entity)) {
1186
+ errors.push(`${at}: ${(0, loader_1.undefinedEndpointProblem)('to', rel.destination.entity)}.`);
1187
+ }
1188
+ const [from, to] = [rel.source.entity, rel.destination.entity];
1189
+ const back = firstFrom.get(`${to}\0${from}`);
1190
+ if (from === to) {
1191
+ errors.push(`${at} runs from '${from}' to itself; a relationship joins two ` +
1192
+ `different entities.`);
1193
+ }
1194
+ else if (back !== undefined) {
1195
+ errors.push(`${at} runs from '${from}' to '${to}', and relationship '${back}' ` +
1196
+ `runs the other way; keep the relationships between two entities ` +
1197
+ `in one direction.`);
1198
+ }
1199
+ if (!firstFrom.has(`${from}\0${to}`)) {
1200
+ firstFrom.set(`${from}\0${to}`, rel.name);
1201
+ }
1202
+ if (rel.association)
1203
+ continue;
1204
+ const fromCount = rel.source.columns.length;
1205
+ const toCount = rel.destination.columns.length;
1206
+ if (!fromCount !== !toCount) {
1207
+ errors.push(`${at}: ${loader_1.HALF_BOUND_JOIN_PROBLEM}`);
1208
+ }
1209
+ else if (fromCount !== toCount) {
1210
+ errors.push(`${at}: ${(0, loader_1.joinLengthProblem)(fromCount, toCount)}.`);
1211
+ }
1212
+ for (const col of rel.source.columns) {
1213
+ if (!(0, sql_expr_utils_1.isColumnName)(col)) {
1214
+ errors.push(invalidColumnNameError(owner, model.name, document, 'from_columns', col));
1215
+ }
1216
+ }
1217
+ for (const col of rel.destination.columns) {
1218
+ if (!(0, sql_expr_utils_1.isColumnName)(col)) {
1219
+ errors.push(invalidColumnNameError(owner, model.name, document, 'to_columns', col));
1220
+ }
1221
+ }
1222
+ }
1223
+ return errors;
1224
+ }
1225
+ // A relationship's `to_columns` must cover a primary or unique key of the `to`
1226
+ // entity. A relationship is many-to-one or one-to-one only when its
1227
+ // `to_columns` cover a key, so a join that covers no key is invalid in
1228
+ // every model, whatever it deploys to. The check runs on every push, a
1229
+ // catalog-only push included. A superset of a key covers it (see
1230
+ // coversKey). Keys and join columns are physical columns, so they are compared
1231
+ // as written, never read through a field of the same name. Skipped here when
1232
+ // the check's own inputs are absent or already invalid (reported by their own
1233
+ // checks): an association (junction-table) relationship, an empty `to_columns`
1234
+ // list, a non-column entry in `to_columns`, an undeclared or abstract `to`
1235
+ // entity, or a non-column entry in the `to` entity's `primary_key` or
1236
+ // `unique_keys`.
1237
+ function relationshipKeyErrors(model, document) {
1238
+ const errors = [];
1239
+ const byName = new Map((model.entities ?? []).map(e => [e.name, e]));
1240
+ for (const rel of model.relationships ?? []) {
1241
+ const to = rel.destination.columns;
1242
+ if (rel.association || !to.length)
1243
+ continue;
1244
+ if (!to.every(sql_expr_utils_1.isColumnName))
1245
+ continue;
1246
+ const target = byName.get(rel.destination.entity);
1247
+ if (!target || target.abstract)
1248
+ continue;
1249
+ if (!target.keys.every(sql_expr_utils_1.isColumnName) ||
1250
+ !(target.uniqueKeys ?? []).flat().every(sql_expr_utils_1.isColumnName)) {
1251
+ continue;
1252
+ }
1253
+ if (!(0, ir_1.coversKey)(to, target.keys, target.uniqueKeys ?? [])) {
1254
+ errors.push(`relationship '${rel.name}' in model '${model.name}' (${document}): its to_columns ${JSON.stringify(to)} cover no primary or unique key of '${target.name}'; join on every column of one of its keys, and ` +
1255
+ `declare that key if '${target.name}' has none.`);
1256
+ }
1257
+ }
1258
+ return errors;
1259
+ }
1260
+ // Why each field of `model` holds no SQL, in the loader's words. Each field
1261
+ // is read on the entity that declares it, and each field an entity inherits
1262
+ // is read on that entity, as the loader does. An association's field is an
1263
+ // edge property, read on its relationship.
1264
+ function fieldNoSqlErrors(model, document) {
1265
+ const where = `in model '${model.name}' (${document})`;
1266
+ const errors = [];
1267
+ for (const e of model.entities ?? []) {
1268
+ for (const f of e.fields ?? []) {
1269
+ const problem = (0, loader_1.noSqlProblemOf)(f, e.name);
1270
+ if (problem) {
1271
+ errors.push(`field '${e.name}.${f.name}' ${where}: ${problem}; write the ` +
1272
+ `field's SQL, or remove 'expression' to leave the field unbound.`);
1273
+ }
1274
+ }
1275
+ }
1276
+ if ((model.entities ?? []).some(e => e.extends?.length)) {
1277
+ const concepts = ifResolvable(() => declaredConcepts(model));
1278
+ const inherited = concepts ?
1279
+ (0, loader_1.inheritedNoSqlProblems)(model.entities ?? [], concepts, loader_1.noSqlProblemOf) :
1280
+ [];
1281
+ for (const problem of inherited) {
1282
+ errors.push(`model '${model.name}' (${document}): ${problem}.`);
1283
+ }
1284
+ }
1285
+ for (const rel of model.relationships ?? []) {
1286
+ for (const f of rel.association?.fields ?? []) {
1287
+ const problem = (0, loader_1.noSqlProblemOf)(f, rel.name);
1288
+ if (problem) {
1289
+ errors.push(`relationship '${rel.name}' field '${f.name}' ${where}: ${problem}; write the field's SQL.`);
1290
+ }
1291
+ }
1292
+ }
1293
+ return errors;
1294
+ }
1295
+ // The error for a metric with no SQL, or undefined. A metric is rejected when
1296
+ // none of its texts holds SQL, with or without an anchor, since the graph
1297
+ // would drop it. It is also rejected when any one of its texts holds none, as
1298
+ // the loader rejects it, since a model built in memory skips the loader.
1299
+ function metricNoSqlError(metric, model, document) {
1300
+ const where = `metric '${metric.name}' in model '${model.name}' (${document})`;
1301
+ const readable = (0, resolve_profiles_1.expressionSources)(metric).filter(s => (0, loader_1.noSqlReason)(s.text, s.dialect) === undefined);
1302
+ if (!readable.length) {
1303
+ return `${where} has no SQL in 'expression'; write the metric's SQL.`;
1304
+ }
1305
+ const problem = (0, loader_1.noSqlProblemOf)(metric);
1306
+ return problem && `${where}: ${problem}; write the metric's SQL.`;
1307
+ }
1308
+ // Validates field and metric expressions:
1309
+ // - Reject a field or a metric with no SQL, as the loader does, since a
1310
+ // model built in memory skips the loader (see fieldNoSqlErrors and
1311
+ // metricNoSqlError).
1312
+ // The rules below run when inheritance resolved cleanly:
1313
+ // - For each metric:
1314
+ // 1. Unless it has no SQL, reject a metric with no `authoredEntity` when
1315
+ // its expressions, read together, reach no entity or more than one.
1316
+ // 2. Reject when `authoredEntity ?? entity` does not name a declared
1317
+ // entity (runs on every push).
1318
+ // 3. When `authoredEntity` is set and the expressions reference at least
1319
+ // one entity, at least one of those must be `authoredEntity`.
1320
+ // 4. For every referenced entity, every field it is referenced with must
1321
+ // be declared or inherited on that entity (gated on `!fieldsPruned`,
1322
+ // and run before pruning).
1323
+ // 5. Reject a column reference with no entity qualifier, or a qualified
1324
+ // reference whose qualifier is not a declared entity (including when it
1325
+ // differs in case from a declared entity), in a metric expression.
1326
+ // 6. Reject a metric whose name matches, ignoring case, a declared or
1327
+ // inherited field of `authoredEntity ?? entity`, since metrics and
1328
+ // fields on one entity share a namespace. This runs before pruning, so
1329
+ // a graph push catches it even when pruning drops either side.
1330
+ // - For each field on each entity E:
1331
+ // 1. Reject any reference to an entity other than E, and a qualifier that
1332
+ // differs only in case from a declared entity.
1333
+ // 2. For references to E itself, every field named must be declared or
1334
+ // inherited on E (gated on `!fieldsPruned`, and run before pruning).
1335
+ //
1336
+ // Exported so `commands.ts` can also run it on the unpruned model before
1337
+ // `pruneUnavailable` drops unbound fields and metrics on a graph push. When
1338
+ // given, `flaggedMetrics` collects the name of each metric it reports.
1339
+ function validateExpressionReferences(models, fieldsPruned = false, warnings = [], flaggedMetrics) {
1340
+ const errors = [];
1341
+ for (const { document, model } of models) {
1342
+ errors.push(...fieldNoSqlErrors(model, document));
1343
+ const fieldsByEntity = ifResolvable(() => declaredFields(model));
1344
+ if (!fieldsByEntity) {
1345
+ for (const metric of model.metrics ?? []) {
1346
+ const noSql = metricNoSqlError(metric, model, document);
1347
+ if (!noSql)
1348
+ continue;
1349
+ errors.push(noSql);
1350
+ flaggedMetrics?.add(metric.name);
1351
+ }
1352
+ continue;
1353
+ }
1354
+ const entityNames = [...fieldsByEntity.keys()];
1355
+ const impliedDialect = (0, resolve_profiles_1.sourcesDialect)(model);
1356
+ const addUnreadableWarning = (what, total, unreadDialects) => {
1357
+ if (!unreadDialects.length)
1358
+ return;
1359
+ if (unreadDialects.length === total) {
1360
+ warnings.push(`${what} in model '${model.name}' (${document}): kcmd could ` +
1361
+ `not read its SQL, so the fields it reads were not checked.`);
1362
+ }
1363
+ else {
1364
+ const listed = unreadDialects.map(d => `'${d}'`).join(', ');
1365
+ const entryText = unreadDialects.length === 1 ?
1366
+ 'that entry reads' :
1367
+ 'those entries read';
1368
+ warnings.push(`${what} in model '${model.name}' (${document}): kcmd could ` +
1369
+ `not read its ${listed} SQL, so the fields ${entryText} were ` +
1370
+ `not checked.`);
1371
+ }
1372
+ };
1373
+ for (const entity of model.entities ?? []) {
1374
+ const others = entityNames.filter(n => n !== entity.name);
1375
+ const knownFields = fieldsByEntity.get(entity.name);
1376
+ for (const field of entity.fields ?? []) {
1377
+ // A text with no SQL has nothing to read, so it gets no warning.
1378
+ const sources = (0, resolve_profiles_1.expressionSources)(field).filter(s => (0, loader_1.noSqlReason)(s.text, s.dialect, entity.name) === undefined);
1379
+ const allColumns = [];
1380
+ const unreadDialects = [];
1381
+ for (const { text, dialect } of sources) {
1382
+ const columns = (0, sql_expr_utils_1.columnsForSource)(text, dialect, impliedDialect);
1383
+ if (!columns) {
1384
+ unreadDialects.push(dialect);
1385
+ continue;
1386
+ }
1387
+ allColumns.push(...columns);
1388
+ }
1389
+ addUnreadableWarning(`field '${entity.name}.${field.name}'`, sources.length, unreadDialects);
1390
+ for (const other of others) {
1391
+ const refs = (0, sql_expr_utils_1.fieldsReadOn)(allColumns, other);
1392
+ if (refs.length) {
1393
+ errors.push(`field '${entity.name}.${field.name}' in model '${model.name}' (${document}) references entity '${other}' ('${other}.${refs[0]}'); a field expression may only reference its ` +
1394
+ `own entity '${entity.name}' or unqualified columns of its backing ` +
1395
+ `table.`);
1396
+ }
1397
+ }
1398
+ const where = `field '${entity.name}.${field.name}' in model '${model.name}' (${document})`;
1399
+ errors.push(...new Set(allColumns.flatMap(c => (0, names_1.qualifierCaseError)(where, c, entityNames) ?? [])));
1400
+ if (!fieldsPruned) {
1401
+ for (const ref of (0, sql_expr_utils_1.fieldsReadOn)(allColumns, entity.name)) {
1402
+ if (!knownFields.has(ref)) {
1403
+ errors.push(`field '${entity.name}.${field.name}' in model '${model.name}' (${document}) references '${entity.name}.${ref}', which is not a declared or ` +
1404
+ `inherited field of '${entity.name}'.`);
1405
+ }
1406
+ }
1407
+ }
1408
+ }
1409
+ }
1410
+ for (const metric of model.metrics ?? []) {
1411
+ const before = errors.length;
1412
+ const sources = (0, resolve_profiles_1.expressionSources)(metric);
1413
+ // A text with no SQL has nothing to read, so it gets no warning.
1414
+ const readable = sources.filter(s => (0, loader_1.noSqlReason)(s.text, s.dialect) === undefined);
1415
+ const allColumns = [];
1416
+ const unreadDialects = [];
1417
+ for (const { text, dialect } of readable) {
1418
+ const columns = (0, sql_expr_utils_1.columnsForSource)(text, dialect, impliedDialect);
1419
+ if (!columns) {
1420
+ unreadDialects.push(dialect);
1421
+ continue;
1422
+ }
1423
+ allColumns.push(...columns);
1424
+ }
1425
+ addUnreadableWarning(`metric '${metric.name}'`, readable.length, unreadDialects);
1426
+ const authored = metric.authoredEntity;
1427
+ const anchor = authored ?? metric.entity;
1428
+ const reached = (0, sql_expr_utils_1.entitiesReached)(allColumns, entityNames);
1429
+ const collision = metricFieldCollisionError(metric, anchor, fieldsByEntity, model, document);
1430
+ if (collision)
1431
+ errors.push(collision);
1432
+ // An anchor does not make up for missing SQL: the graph would drop the
1433
+ // metric.
1434
+ const noSql = metricNoSqlError(metric, model, document);
1435
+ if (noSql) {
1436
+ errors.push(noSql);
1437
+ }
1438
+ else if (!authored && unreadDialects.length === 0 && allColumns.length === 0 ||
1439
+ !authored && reached.length > 1) {
1440
+ const fixHint = model.version === '0.2.0.dev0' ?
1441
+ `set 'entity' in a GOOGLE custom_extensions block on the metric ` +
1442
+ `(or use version '0.2.0.dev0/google' and set 'entity' on the ` +
1443
+ `metric) or scope its expression to one entity.` :
1444
+ `set 'entity' on the metric or scope its expression to one entity.`;
1445
+ errors.push(`metric '${metric.name}' in model '${model.name}' (${document}) has no 'entity' anchor and its expression${reached.length === 0 ?
1446
+ ' reaches no declared entity' :
1447
+ ` reaches multiple entities (${reached.join(', ')})`}, ` +
1448
+ `so it does not resolve to a single entity; ${fixHint}`);
1449
+ }
1450
+ if (anchor !== undefined && !fieldsByEntity.has(anchor)) {
1451
+ errors.push(`metric '${metric.name}' in model '${model.name}' (${document}): entity '${anchor}' is not declared in the model.`);
1452
+ }
1453
+ if (authored !== undefined && fieldsByEntity.has(authored) &&
1454
+ reached.length > 0 && !reached.includes(authored)) {
1455
+ errors.push(`metric '${metric.name}' in model '${model.name}' (${document}) is anchored to entity '${authored}', but its expression only references ${reached.map(e => `'${e}'`).join(', ')}; the anchor ` +
1456
+ `contradicts the SQL.`);
1457
+ }
1458
+ if (!fieldsPruned) {
1459
+ for (const entityName of reached) {
1460
+ const knownFields = fieldsByEntity.get(entityName);
1461
+ for (const ref of (0, sql_expr_utils_1.fieldsReadOn)(allColumns, entityName)) {
1462
+ if (!knownFields.has(ref)) {
1463
+ errors.push(`metric '${metric.name}' in model '${model.name}' (${document}): expression references '${entityName}.${ref}', but entity '${entityName}' has no field '${ref}'; metric expressions must reference declared ` +
1464
+ `fields, not physical columns.`);
1465
+ }
1466
+ }
1467
+ }
1468
+ }
1469
+ const seenUnqualified = new Set();
1470
+ for (const col of allColumns) {
1471
+ if (col.qualifier !== undefined &&
1472
+ (fieldsByEntity.has(col.qualifier) || col.qualifier === anchor)) {
1473
+ continue;
1474
+ }
1475
+ const token = col.qualifier !== undefined ?
1476
+ `${col.qualifier}.${col.name}` :
1477
+ col.name;
1478
+ if (seenUnqualified.has(token))
1479
+ continue;
1480
+ seenUnqualified.add(token);
1481
+ if (col.qualifier === undefined) {
1482
+ errors.push(`metric '${metric.name}' in model '${model.name}' (${document}): '${token}' must be written as ` +
1483
+ `'entity.field'; a metric reads fields, never columns.`);
1484
+ }
1485
+ else {
1486
+ const where = `metric '${metric.name}' in model '${model.name}' (${document})`;
1487
+ errors.push((0, names_1.qualifierCaseError)(where, col, entityNames) ??
1488
+ `${where}: '${token}' has qualifier '${col.qualifier}', which ` +
1489
+ `is not a declared entity in model '${model.name}'; write ` +
1490
+ `'entity.field' starting with a declared entity.`);
1491
+ }
1492
+ }
1493
+ if (errors.length > before)
1494
+ flaggedMetrics?.add(metric.name);
1495
+ }
1496
+ }
1497
+ return errors;
1498
+ }
1499
+ // Temporary BigQuery-only metric check, until the BigQuery generator places
1500
+ // measures with the SQL parser. Rejects a metric that the generator
1501
+ // (`bigquery.ts`) would skip: one with only another engine's SQL, which the
1502
+ // generator does not emit, or one it drops because its regex
1503
+ // (`referencedEntityNames`) disagrees with the parser or because
1504
+ // `metric.entity` is unset. Reports only metrics that
1505
+ // `validateExpressionReferences` has not already flagged.
1506
+ function validateBigQueryMetrics(model, document, alreadyFlagged) {
1507
+ const errors = [];
1508
+ const entityNames = (model.entities ?? []).map(e => e.name);
1509
+ for (const metric of model.metrics ?? []) {
1510
+ if (alreadyFlagged.has(metric.name))
1511
+ continue;
1512
+ const prefix = `metric '${metric.name}' in model '${model.name}' (${document})`;
1513
+ // The text BigQuery runs: its `BIGQUERY` entry, else its `ANSI_SQL` one,
1514
+ // else `expression` for a metric held without a dialect list.
1515
+ const bqText = (0, ir_1.expressionForDialect)(metric, 'BIGQUERY');
1516
+ if (bqText === undefined && metric.importedExpression !== undefined) {
1517
+ errors.push(`${prefix} targets a BigQuery graph with only a '${metric.importedDialect ?? 'vendor'}' expression; supply a ` +
1518
+ `'BIGQUERY' or 'ANSI_SQL' expression, or add one with '${resolve_profiles_1.IMPORT_TRANSPILE_COMMAND}'.`);
1519
+ continue;
1520
+ }
1521
+ // The generator reads the resolved BigQuery expression (`bqText`) with its
1522
+ // comments removed.
1523
+ const expr = (0, sql_parser_1.withoutComments)(bqText ?? metric.importedExpression ?? '', 'BIGQUERY');
1524
+ let referenced = (0, sql_expr_utils_1.referencedEntityNames)(expr, entityNames);
1525
+ if (referenced.length === 0 && metric.entity) {
1526
+ referenced = [metric.entity];
1527
+ }
1528
+ if (referenced.length > 1) {
1529
+ errors.push(`${prefix} targets a BigQuery graph and references multiple ` +
1530
+ `entities (${referenced.join(', ')}); the BigQuery generator ` +
1531
+ `reads every '<entity>.' outside comments as a qualifier, ` +
1532
+ `including a function namespace such as 'NET.', so remove the ` +
1533
+ `extra '<entity>.' match or scope its expression to one entity.`);
1534
+ }
1535
+ else if (referenced.length === 0) {
1536
+ const anchor = model.version === loader_1.OSSIE_VERSION ?
1537
+ `'entity' in a GOOGLE custom_extensions block on the metric` :
1538
+ `top-level 'entity'`;
1539
+ errors.push(`${prefix} targets a BigQuery graph but does not resolve to a ` +
1540
+ `single entity; the BigQuery generator reads '<entity>.' ` +
1541
+ `qualifiers in the expression or ${anchor}.`);
1542
+ }
1543
+ }
1544
+ return errors;
1545
+ }
1546
+ // Every field each entity has, inherited and profile-excluded ones included so
1547
+ // an unpruned check before `pruneUnavailable` does not report a profile-excluded
1548
+ // inherited field as undeclared. Inheritance is resolved through the same pass
1549
+ // the graph legs use rather than by walking `extends` here, so the two can
1550
+ // never disagree about what a subtype has. The pass clones, so it is skipped
1551
+ // for a model that declares no inheritance.
1552
+ function declaredFields(model) {
1553
+ const inherits = (model.entities ?? []).some(e => e.extends?.length);
1554
+ const entities = (inherits ? (0, resolve_inheritance_1.resolveInheritance)(model).model : model).entities ?? [];
1555
+ return new Map(entities.map(e => [
1556
+ e.name,
1557
+ new Set([
1558
+ ...(e.fields ?? []).map(f => f.name),
1559
+ ...(e.excludedFields ?? []),
1560
+ ]),
1561
+ ]));
1562
+ }
1563
+ // The executor coordinate fields that are absent or blank. An executor with no
1564
+ // gaps yields an empty list.
1565
+ function missingExecutorFields(ex) {
1566
+ const blank = (s) => s.trim().length === 0;
1567
+ switch (ex.kind) {
1568
+ case 'mcp':
1569
+ return [['server', ex.mcp.server], ['tool', ex.mcp.tool]]
1570
+ .filter(([, v]) => blank(v))
1571
+ .map(([k]) => k);
1572
+ case 'rest':
1573
+ return [['endpoint', ex.rest.endpoint], ['method', ex.rest.method]]
1574
+ .filter(([, v]) => blank(v))
1575
+ .map(([k]) => k);
1576
+ case 'grpc':
1577
+ return [['service', ex.grpc.service], ['method', ex.grpc.method]]
1578
+ .filter(([, v]) => blank(v))
1579
+ .map(([k]) => k);
1580
+ case 'sql':
1581
+ return ex.sql.statements.every(blank) ? ['statements'] : [];
1582
+ }
1583
+ }
1584
+ // One error for each entity that `deployment` binds to a table outside the
1585
+ // database it deploys to. Sources come from the deployment's profile in
1586
+ // `model.profiles`, or from the model file for `default`.
1587
+ function deploymentTableErrors(document, model, deployment) {
1588
+ let sourceOf = (e) => e.authoredSource;
1589
+ let bindings = 'the model file';
1590
+ if (deployment.profile !== resolve_profiles_1.DEFAULT_PROFILE) {
1591
+ const profile = model.profiles?.find(p => p.name === deployment.profile);
1592
+ // `model.profiles` lacks a profile file that does not load, which push
1593
+ // reports instead.
1594
+ if (!profile)
1595
+ return [];
1596
+ const bound = new Map(profile.entities.map(pe => [pe.name, pe.source]));
1597
+ sourceOf = e => bound.get(e.name);
1598
+ bindings = `profile '${profile.name}'`;
1599
+ }
1600
+ const { database, target, outside } = targetDatabase(deployment);
1601
+ const errors = [];
1602
+ for (const e of model.entities ?? []) {
1603
+ const source = sourceOf(e)?.trim();
1604
+ if (!source || (0, loader_1.databaseOf)(source) === database)
1605
+ continue;
1606
+ errors.push(`[${document}] deployment '${deployment.name}' targets ${target}, ` +
1607
+ `but ${bindings} binds entity '${e.name}' to '${source}', ${outside}.`);
1608
+ }
1609
+ return errors;
1610
+ }
1611
+ // The database the tables of a deployment target must be in, as databaseOf
1612
+ // gives it, and how an error names the target and a table outside it.
1613
+ function targetDatabase({ engine, coordinates: c }) {
1614
+ if (engine === 'bigquery') {
1615
+ return { database: 'bigquery', target: 'a BigQuery graph',
1616
+ outside: 'which is not a BigQuery or BigLake table' };
1617
+ }
1618
+ const [system, name] = engine === 'spanner' ?
1619
+ ['Spanner', `${c.project}/${c.instance}/${c.database}`] :
1620
+ ['AlloyDB', `${c.project}/${c.location}/${c.cluster}/${c.database}`];
1621
+ return { database: `${engine}/${name}`, target: `${system} database '${name}'`,
1622
+ outside: 'which is not a table in that database' };
1623
+ }
1624
+ // Live pre-flight over the BigQuery-targeting models: confirms every entity's
1625
+ // BigQuery source table is reachable BEFORE any destination leg runs, so a push
1626
+ // fails fast when the model could not deploy, rather than surfacing a missing
1627
+ // table only once the BigQuery leg executes its DDL. The caller passes only the
1628
+ // models whose deployment target is a BigQuery Graph; a Spanner-targeting
1629
+ // model's sources are Spanner tables (probed against a different system) and
1630
+ // are not checked here.
1631
+ //
1632
+ // Each distinct source is probed with a dry-run query (`SELECT 1 FROM <ref>`,
1633
+ // suffixed `WHERE FALSE` so it scans no data),
1634
+ // so BigQuery resolves the reference exactly as the generated DDL will. That
1635
+ // covers every reference form the generator emits -- a three-part
1636
+ // `project.dataset.table`, a four-part federated REST-catalog / Lakehouse name
1637
+ // (e.g. an Apache Iceberg table via BigLake), and quoted identifiers -- rather
1638
+ // than only a three-part name. A source the loader kept verbatim because it is
1639
+ // a query (contains whitespace) is not a table and is skipped. The dry-run is
1640
+ // billed to the model's BigQuery deployment-target project (the same project
1641
+ // the deploy runs against), falling back to `defaultProject`. Each distinct
1642
+ // (billing project, reference) pair is probed once. Returns one message per
1643
+ // unreachable table (empty when all pass).
1644
+ //
1645
+ // With `graph`, each probe runs in `graph.location`, the location of dataset
1646
+ // `graph.dataset`, and a probe that fails there runs again with no location.
1647
+ // When that run finds the table in another location, the error says so,
1648
+ // starting with `graph.at`, which names the deployment.
1649
+ async function validateBigQueryDataSources(models, bq, defaultProject, graph) {
1650
+ // Dedup by billing project + reference so a table shared across
1651
+ // entities/models is probed once; keep the first reference for a locatable
1652
+ // error message.
1653
+ const refs = new Map();
1654
+ for (const { document, model } of models) {
1655
+ const project = billingProject(model, defaultProject);
1656
+ for (const entity of model.entities ?? []) {
1657
+ const ref = probeableRef(entity.dataSource);
1658
+ if (!ref)
1659
+ continue;
1660
+ const key = `${project}\u0000${ref}`;
1661
+ if (!refs.has(key)) {
1662
+ refs.set(key, {
1663
+ project,
1664
+ ref,
1665
+ document,
1666
+ model: model.name,
1667
+ entity: entity.name,
1668
+ });
1669
+ }
1670
+ }
1671
+ }
1672
+ const errors = [];
1673
+ for (const { project, ref, document, model, entity } of refs.values()) {
1674
+ const sql = `SELECT 1 FROM \`${ref}\` WHERE FALSE`;
1675
+ const res = await bq.query(project, sql, graph?.location, true);
1676
+ if (res.status === 200)
1677
+ continue;
1678
+ if (graph) {
1679
+ const anywhere = await bq.query(project, sql, undefined, true);
1680
+ const found = anywhere.result?.jobReference?.location;
1681
+ if (found && found !== graph.location) {
1682
+ errors.push(`${graph.at} targets a BigQuery graph in dataset ` +
1683
+ `'${graph.dataset}', which is in location '${graph.location}', ` +
1684
+ `but entity '${entity}' reads table '${ref}', which is in ` +
1685
+ `location '${found}'. A property graph can use only tables in ` +
1686
+ `its dataset's location.`);
1687
+ continue;
1688
+ }
1689
+ }
1690
+ const msg = res.message?.trim() || `HTTP ${res.status}`;
1691
+ const why = /not found/i.test(msg) ?
1692
+ 'does not exist' :
1693
+ /access denied|permission denied|not authorized|does not have permission/i
1694
+ .test(msg) ?
1695
+ 'is not accessible (permission denied)' :
1696
+ `could not be verified (${msg})`;
1697
+ errors.push(`entity '${entity}' in model '${model}' (${document}) references ` +
1698
+ `BigQuery table '${ref}', which ${why}; the model cannot be deployed. ` +
1699
+ `Create the table or grant access to it, or fix the entity's source.`);
1700
+ }
1701
+ return errors;
1702
+ }
1703
+ // The BigQuery project a model's deploy -- and thus its dry-run pre-flight --
1704
+ // bills to: the project of the model's first BigQuery Graph deployment target
1705
+ // (where the CREATE PROPERTY GRAPH runs), falling back to the scope's default
1706
+ // project when the model declares no parseable BigQuery Graph target.
1707
+ // googleDeploymentTargets is safe here: validatePushRequirements ran first and
1708
+ // already rejected a malformed GOOGLE extension.
1709
+ function billingProject(model, defaultProject) {
1710
+ try {
1711
+ return (0, deploy_bigquery_1.googleDeploymentTargets)(model).bigQuery[0]?.project ??
1712
+ defaultProject;
1713
+ }
1714
+ catch {
1715
+ return defaultProject;
1716
+ }
1717
+ }
1718
+ // A source that can be probed as a BigQuery table: the canonical `dataSource`,
1719
+ // trimmed, or null when it is empty or a non-BigQuery resource URI. Unlike a
1720
+ // tables.get probe this imposes no part-count limit, so a three-part
1721
+ // `project.dataset.table` and a four-part BigLake / REST-catalog name are both
1722
+ // returned for the dry-run to resolve.
1723
+ function probeableRef(dataSource) {
1724
+ const trimmed = (dataSource ?? '').trim();
1725
+ if (!trimmed)
1726
+ return null;
1727
+ // A non-BigQuery resource URI (Spanner/AlloyDB/iceberg/...) is not a
1728
+ // BigQuery table, so the BigQuery pre-flight does not probe it. (BigQuery
1729
+ // and BigLake source URIs are normalized to dotted names by the loader, so a
1730
+ // URI reaching here is non-BigQuery.)
1731
+ if (trimmed.startsWith('//') || /^[a-z][\w+.-]*:\/\//i.test(trimmed)) {
1732
+ return null;
1733
+ }
1734
+ return trimmed;
1735
+ }
1736
+ // Every `sql` executor statement in a model, in declaration order. A blank one
1737
+ // is skipped: sqlExecutorErrors has already failed the push over it, and
1738
+ // sending it would earn a second, less useful complaint from the store.
1739
+ function pendingStatements({ document, model }) {
1740
+ const pending = [];
1741
+ for (const action of model.actions ?? []) {
1742
+ if (action.executor?.kind !== 'sql')
1743
+ continue;
1744
+ const executor = action.executor;
1745
+ executor.sql.statements.forEach((sql, index) => {
1746
+ if (!sql.trim())
1747
+ return;
1748
+ pending.push({ document, model: model.name, action, index, sql });
1749
+ });
1750
+ }
1751
+ return pending;
1752
+ }
1753
+ // The parameter types to send with one statement's pre-flight request, keyed by
1754
+ // `@name`.
1755
+ //
1756
+ // Only the parameters the statement actually references are sent, and only
1757
+ // those whose declared type maps to a store type we can state. An `Opaque` type
1758
+ // is exactly the case we cannot: it means the model does not know the type, so
1759
+ // asserting one would invent a constraint the author never wrote. Those are
1760
+ // left out and the store resolves them from context, which both backends do.
1761
+ //
1762
+ // Sending the types at all is optional -- a statement with no parameter types
1763
+ // passes either backend's dry run. It is worth doing because it turns the
1764
+ // action's DECLARED parameter types into a claim checked against the real
1765
+ // column types, so a model that says `Float` where the column is a STRING fails
1766
+ // here rather than on the first call.
1767
+ function statementParameterTypes(action, sql) {
1768
+ const declared = new Map(action.parameters.map(p => [p.name, p.type]));
1769
+ const types = {};
1770
+ for (const name of (0, sql_identifiers_1.referencedParameters)(sql)) {
1771
+ const type = declared.get(name);
1772
+ if (!type || type === 'Opaque')
1773
+ continue;
1774
+ types[name] = (0, parameters_1.storeCodeFor)(type);
1775
+ }
1776
+ return types;
1777
+ }
1778
+ // The message for a statement the store refused. `store` names the database so
1779
+ // a multi-profile push says which one answered.
1780
+ function rejectedStatement(pending, store, detail) {
1781
+ return `action '${pending.action.name}' in model '${pending.model}' (${pending.document}): statement ${pending.index + 1} of its sql executor was rejected by ${store}: ${detail} Statements reach the store exactly as written, so every ` +
1782
+ `table and column has to be the name the database uses.`;
1783
+ }
1784
+ // The first line of a multi-line diagnosis. The lines after it echo the
1785
+ // statement and point a caret at the offending token, which is worth nothing
1786
+ // here: the author has the statement open in the profile, the position in
1787
+ // `[at 1:54]` survives, and a caret counted from column 1 lands in the wrong
1788
+ // place once the message is embedded in a longer sentence.
1789
+ //
1790
+ // `\n` is matched in both its forms because Spanner's top-level `error.message`
1791
+ // arrives with its newlines ESCAPED -- a literal backslash and an n survive
1792
+ // JSON.parse -- while every other message uses real ones.
1793
+ function firstLine(message) {
1794
+ return message.replace(/\\n/g, '\n').split('\n')[0].trim();
1795
+ }
1796
+ // An API error's message reduced to its first line as a sentence, such as
1797
+ // "Unrecognized name: accountId; Did you mean account_id? [at 1:54]."
1798
+ function storeMessage(message, status) {
1799
+ const text = (message ?? '').trim();
1800
+ return text ? (0, parameters_1.sentence)(firstLine(text)) : `HTTP ${status}.`;
1801
+ }
1802
+ // The Spanner arm. Each distinct database gets one session, reused across every
1803
+ // statement bound to it and deleted at the end -- which also discards the
1804
+ // read-write transactions PLAN had to begin and never committed. Returns one
1805
+ // message per statement that failed to plan, empty when all pass.
1806
+ async function validateSpannerActionStatements(models, spanner) {
1807
+ // Group by database so a model set sharing one target opens one session
1808
+ // rather than one per statement.
1809
+ const byDatabase = new Map();
1810
+ for (const loaded of models) {
1811
+ const statements = pendingStatements(loaded);
1812
+ if (!statements.length)
1813
+ continue;
1814
+ // The database an action writes to is the one this profile deploys its
1815
+ // graph to: on Spanner the published graph and the operational rows live in
1816
+ // the same database. A model with no Spanner target contributes nothing.
1817
+ let target;
1818
+ try {
1819
+ target = (0, deployment_target_1.spannerGraphTargets)(loaded.model).targets[0];
1820
+ }
1821
+ catch {
1822
+ continue;
1823
+ }
1824
+ if (!target)
1825
+ continue;
1826
+ const key = `${target.project}/${target.instance}/${target.database}`;
1827
+ const entry = byDatabase.get(key) ?? { target, statements: [] };
1828
+ entry.statements.push(...statements);
1829
+ byDatabase.set(key, entry);
1830
+ }
1831
+ const errors = [];
1832
+ for (const { target, statements } of byDatabase.values()) {
1833
+ const store = `Spanner database '${target.project}/${target.instance}/${target.database}'`;
1834
+ const opened = await spanner.createSession(target.project, target.instance, target.database);
1835
+ const session = opened.result?.name;
1836
+ if (!session) {
1837
+ // Reaching the database is the whole check, so failing to open a session
1838
+ // is reported rather than skipped: a push that could not verify its
1839
+ // statements must not proceed as though it had.
1840
+ errors.push(`could not open a session on ${store} to verify the sql executor ` +
1841
+ `statements in model '${statements[0].model}' (${statements[0].document}): ${storeMessage(opened.message, opened.status)}`);
1842
+ continue;
1843
+ }
1844
+ try {
1845
+ for (const pending of statements) {
1846
+ const types = statementParameterTypes(pending.action, pending.sql);
1847
+ const params = {};
1848
+ const paramTypes = {};
1849
+ for (const [name, code] of Object.entries(types)) {
1850
+ params[name] = null;
1851
+ paramTypes[name] = { code };
1852
+ }
1853
+ const res = await spanner.planDml(session, pending.sql, params, paramTypes);
1854
+ if (res.status === 200)
1855
+ continue;
1856
+ errors.push(rejectedStatement(pending, store, storeMessage(res.message, res.status)));
1857
+ }
1858
+ }
1859
+ finally {
1860
+ // Best effort, and the catch is what makes it one. A leaked session
1861
+ // expires on its own, while letting a cleanup failure escape would throw
1862
+ // away every statement error collected above and report a transport
1863
+ // problem as though the statements had never been checked.
1864
+ try {
1865
+ await spanner.deleteSession(session);
1866
+ }
1867
+ catch {
1868
+ // Nothing to say: the session is the store's to reclaim.
1869
+ }
1870
+ }
1871
+ }
1872
+ return errors;
1873
+ }
1874
+ // The BigQuery arm. Each statement is dry-run on jobs.query, billed to the same
1875
+ // project the model's graph deploys to (a dry run scans nothing, but it still
1876
+ // needs a project to run in). There is no session to manage: a dry run is a
1877
+ // single stateless call. Returns one message per statement BigQuery refused.
1878
+ //
1879
+ // Note what this does NOT settle. BigQuery accepts DML against a native or
1880
+ // managed table and refuses it against most external ones, and a dry run
1881
+ // reports that refusal -- which is the point, because a real run would hit the
1882
+ // same wall.
1883
+ async function validateBigQueryActionStatements(models, bq, defaultProject) {
1884
+ const errors = [];
1885
+ for (const loaded of models) {
1886
+ const statements = pendingStatements(loaded);
1887
+ if (!statements.length)
1888
+ continue;
1889
+ const project = billingProject(loaded.model, defaultProject);
1890
+ const store = `BigQuery (project '${project}')`;
1891
+ for (const pending of statements) {
1892
+ const res = await bq.query(project, pending.sql, undefined, true, statementParameterTypes(pending.action, pending.sql));
1893
+ if (res.status === 200)
1894
+ continue;
1895
+ errors.push(rejectedStatement(pending, store, storeMessage(res.message, res.status)));
1896
+ }
1897
+ }
1898
+ return errors;
1899
+ }