@google-cloud/kcmd 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +203 -0
- package/README.md +266 -2
- package/build/ts/kcmd/cli.js +19578 -0
- package/build/ts/kcmd/gcp/alloydb.d.ts +12 -0
- package/build/ts/kcmd/gcp/alloydb.js +70 -0
- package/build/ts/kcmd/gcp/api.d.ts +29 -0
- package/build/ts/kcmd/gcp/api.js +162 -0
- package/build/ts/kcmd/gcp/biglake.d.ts +16 -0
- package/build/ts/kcmd/gcp/biglake.js +50 -0
- package/build/ts/kcmd/gcp/bigquery.d.ts +57 -0
- package/build/ts/kcmd/gcp/bigquery.js +134 -0
- package/build/ts/kcmd/gcp/context.d.ts +10 -0
- package/build/ts/kcmd/gcp/context.js +74 -0
- package/build/ts/kcmd/gcp/crm.d.ts +13 -0
- package/build/ts/kcmd/gcp/crm.js +91 -0
- package/build/ts/kcmd/gcp/dataplex.d.ts +147 -0
- package/build/ts/kcmd/gcp/dataplex.js +406 -0
- package/build/ts/kcmd/gcp/index.d.ts +3 -0
- package/build/ts/kcmd/gcp/index.js +21 -0
- package/build/ts/kcmd/gcp/spanner.d.ts +63 -0
- package/build/ts/kcmd/gcp/spanner.js +123 -0
- package/build/ts/kcmd/index.d.ts +7 -0
- package/build/ts/kcmd/index.js +46 -0
- package/build/ts/kcmd/layout.d.ts +16 -0
- package/build/ts/kcmd/layout.js +31 -0
- package/build/ts/kcmd/layouts/documents.d.ts +24 -0
- package/build/ts/kcmd/layouts/documents.js +264 -0
- package/build/ts/kcmd/layouts/okf.d.ts +31 -0
- package/build/ts/kcmd/layouts/okf.js +344 -0
- package/build/ts/kcmd/layouts/semantic-model.d.ts +61 -0
- package/build/ts/kcmd/layouts/semantic-model.js +542 -0
- package/build/ts/kcmd/layouts/standard.d.ts +13 -0
- package/build/ts/kcmd/layouts/standard.js +108 -0
- package/build/ts/kcmd/manifest.d.ts +34 -0
- package/build/ts/kcmd/manifest.js +262 -0
- package/build/ts/kcmd/metadata.d.ts +25 -0
- package/build/ts/kcmd/metadata.js +8 -0
- package/build/ts/kcmd/okf/catalog_residue.d.ts +14 -0
- package/build/ts/kcmd/okf/catalog_residue.js +124 -0
- package/build/ts/kcmd/okf/concept.d.ts +14 -0
- package/build/ts/kcmd/okf/concept.js +104 -0
- package/build/ts/kcmd/okf/concept_to_entry.d.ts +8 -0
- package/build/ts/kcmd/okf/concept_to_entry.js +254 -0
- package/build/ts/kcmd/okf/init_okf_workspace.d.ts +15 -0
- package/build/ts/kcmd/okf/init_okf_workspace.js +115 -0
- package/build/ts/kcmd/okf/links.d.ts +23 -0
- package/build/ts/kcmd/okf/links.js +468 -0
- package/build/ts/kcmd/okf/okf_metadata.d.ts +12 -0
- package/build/ts/kcmd/okf/okf_metadata.js +82 -0
- package/build/ts/kcmd/semantic/bigquery.d.ts +22 -0
- package/build/ts/kcmd/semantic/bigquery.js +525 -0
- package/build/ts/kcmd/semantic/binding.d.ts +3 -0
- package/build/ts/kcmd/semantic/binding.js +97 -0
- package/build/ts/kcmd/semantic/catalog_entry.d.ts +24 -0
- package/build/ts/kcmd/semantic/catalog_entry.js +35 -0
- package/build/ts/kcmd/semantic/converters/owl/convert.d.ts +26 -0
- package/build/ts/kcmd/semantic/converters/owl/convert.js +41 -0
- package/build/ts/kcmd/semantic/converters/owl/model.d.ts +98 -0
- package/build/ts/kcmd/semantic/converters/owl/model.js +30 -0
- package/build/ts/kcmd/semantic/converters/owl/parse.d.ts +11 -0
- package/build/ts/kcmd/semantic/converters/owl/parse.js +634 -0
- package/build/ts/kcmd/semantic/converters/owl/to_ir.d.ts +19 -0
- package/build/ts/kcmd/semantic/converters/owl/to_ir.js +390 -0
- package/build/ts/kcmd/semantic/deploy_bigquery.d.ts +20 -0
- package/build/ts/kcmd/semantic/deploy_bigquery.js +226 -0
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.d.ts +129 -0
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.js +1396 -0
- package/build/ts/kcmd/semantic/deploy_spanner.d.ts +15 -0
- package/build/ts/kcmd/semantic/deploy_spanner.js +150 -0
- package/build/ts/kcmd/semantic/deployment_target.d.ts +93 -0
- package/build/ts/kcmd/semantic/deployment_target.js +379 -0
- package/build/ts/kcmd/semantic/graph_ddl.d.ts +34 -0
- package/build/ts/kcmd/semantic/graph_ddl.js +563 -0
- package/build/ts/kcmd/semantic/import_model.d.ts +50 -0
- package/build/ts/kcmd/semantic/import_model.js +1275 -0
- package/build/ts/kcmd/semantic/ir.d.ts +589 -0
- package/build/ts/kcmd/semantic/ir.js +247 -0
- package/build/ts/kcmd/semantic/kc_actions.d.ts +45 -0
- package/build/ts/kcmd/semantic/kc_actions.js +531 -0
- package/build/ts/kcmd/semantic/kc_constraints.d.ts +44 -0
- package/build/ts/kcmd/semantic/kc_constraints.js +279 -0
- package/build/ts/kcmd/semantic/kc_converter.d.ts +31 -0
- package/build/ts/kcmd/semantic/kc_converter.js +824 -0
- package/build/ts/kcmd/semantic/kc_custom_types.d.ts +70 -0
- package/build/ts/kcmd/semantic/kc_custom_types.js +828 -0
- package/build/ts/kcmd/semantic/kc_entries.d.ts +155 -0
- package/build/ts/kcmd/semantic/kc_entries.js +284 -0
- package/build/ts/kcmd/semantic/kc_ids.d.ts +103 -0
- package/build/ts/kcmd/semantic/kc_ids.js +191 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.d.ts +32 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.js +1043 -0
- package/build/ts/kcmd/semantic/loader.d.ts +262 -0
- package/build/ts/kcmd/semantic/loader.js +3440 -0
- package/build/ts/kcmd/semantic/names.d.ts +10 -0
- package/build/ts/kcmd/semantic/names.js +85 -0
- package/build/ts/kcmd/semantic/osi_converter.d.ts +60 -0
- package/build/ts/kcmd/semantic/osi_converter.js +911 -0
- package/build/ts/kcmd/semantic/parameters.d.ts +18 -0
- package/build/ts/kcmd/semantic/parameters.js +152 -0
- package/build/ts/kcmd/semantic/pull_kc.d.ts +14 -0
- package/build/ts/kcmd/semantic/pull_kc.js +346 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.d.ts +54 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.js +330 -0
- package/build/ts/kcmd/semantic/resolve_profiles.d.ts +250 -0
- package/build/ts/kcmd/semantic/resolve_profiles.js +1822 -0
- package/build/ts/kcmd/semantic/runtime/agent_tools.d.ts +93 -0
- package/build/ts/kcmd/semantic/runtime/agent_tools.js +243 -0
- package/build/ts/kcmd/semantic/runtime/dialect.d.ts +13 -0
- package/build/ts/kcmd/semantic/runtime/dialect.js +39 -0
- package/build/ts/kcmd/semantic/runtime/runtime.d.ts +94 -0
- package/build/ts/kcmd/semantic/runtime/runtime.js +318 -0
- package/build/ts/kcmd/semantic/runtime/store.d.ts +112 -0
- package/build/ts/kcmd/semantic/runtime/store.js +344 -0
- package/build/ts/kcmd/semantic/skills.d.ts +110 -0
- package/build/ts/kcmd/semantic/skills.js +697 -0
- package/build/ts/kcmd/semantic/source_dialect.d.ts +10 -0
- package/build/ts/kcmd/semantic/source_dialect.js +261 -0
- package/build/ts/kcmd/semantic/source_entries.d.ts +12 -0
- package/build/ts/kcmd/semantic/source_entries.js +282 -0
- package/build/ts/kcmd/semantic/spanner.d.ts +18 -0
- package/build/ts/kcmd/semantic/spanner.js +60 -0
- package/build/ts/kcmd/semantic/sql_expr_utils.d.ts +17 -0
- package/build/ts/kcmd/semantic/sql_expr_utils.js +221 -0
- package/build/ts/kcmd/semantic/sql_identifiers.d.ts +6 -0
- package/build/ts/kcmd/semantic/sql_identifiers.js +267 -0
- package/build/ts/kcmd/semantic/sql_parser.d.ts +44 -0
- package/build/ts/kcmd/semantic/sql_parser.js +378 -0
- package/build/ts/kcmd/semantic/transpile.d.ts +74 -0
- package/build/ts/kcmd/semantic/transpile.js +563 -0
- package/build/ts/kcmd/semantic/validate.d.ts +35 -0
- package/build/ts/kcmd/semantic/validate.js +1899 -0
- package/build/ts/kcmd/semantic/yaml_options.d.ts +6 -0
- package/build/ts/kcmd/semantic/yaml_options.js +58 -0
- package/build/ts/kcmd/semantic/yaml_update.d.ts +26 -0
- package/build/ts/kcmd/semantic/yaml_update.js +782 -0
- package/build/ts/kcmd/snapshot.d.ts +33 -0
- package/build/ts/kcmd/snapshot.js +379 -0
- package/build/ts/kcmd/source.d.ts +31 -0
- package/build/ts/kcmd/source.js +119 -0
- package/build/ts/kcmd/sources/bq-dataset.d.ts +17 -0
- package/build/ts/kcmd/sources/bq-dataset.js +131 -0
- package/build/ts/kcmd/sources/entrygroup.d.ts +21 -0
- package/build/ts/kcmd/sources/entrygroup.js +83 -0
- package/build/ts/kcmd/sources/kb.d.ts +21 -0
- package/build/ts/kcmd/sources/kb.js +82 -0
- package/build/ts/kcmd/sources/semantic-model.d.ts +18 -0
- package/build/ts/kcmd/sources/semantic-model.js +48 -0
- package/build/ts/kcmd/sync.d.ts +35 -0
- package/build/ts/kcmd/sync.js +697 -0
- package/package.json +62 -4
|
@@ -0,0 +1,1822 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Applies a chosen binding profile to a logical semantic model, and prunes what
|
|
3
|
+
// the resulting binding cannot answer.
|
|
4
|
+
//
|
|
5
|
+
// A model is authored as ONE logical declaration (entities, fields,
|
|
6
|
+
// relationships, metrics, the grain, and the graph shape) plus zero or more
|
|
7
|
+
// BINDING PROFILES that supply the physical facets it leaves open: each entity's
|
|
8
|
+
// `source`, each field's column (`expression`) and each action's `executor`.
|
|
9
|
+
// `kcmd push` applies the profile each deployment names to the logical model
|
|
10
|
+
// BY NAME and deploys the result. See docs/semantic-model/profiles.md.
|
|
11
|
+
//
|
|
12
|
+
// Two passes live here:
|
|
13
|
+
// - applyProfile applies a profile, read into a ProfileSpec, to a loaded
|
|
14
|
+
// model. Push, `kcmd profiles`, the runtime and the profile checks apply a
|
|
15
|
+
// profile file this way, and a profile held in memory is applied the same
|
|
16
|
+
// way. Push, `kcmd profiles` and the runtime start at profileForDocument,
|
|
17
|
+
// which finds the profile by name and loads the model file on its own
|
|
18
|
+
// (applyToDocument). A file is read with loadProfileFile.
|
|
19
|
+
// applyProfileSpec then checks what the profile may set
|
|
20
|
+
// (profileApplyError), sets the values (applyBindings) and gives the
|
|
21
|
+
// warnings (warningsAfterProfile). applyProfile and applyProfileFile take
|
|
22
|
+
// the same steps for a model already loaded, and the completeness check
|
|
23
|
+
// applies the bindings with applyBindings.
|
|
24
|
+
// - pruneUnavailable runs over the loaded IR and drops each field left unbound
|
|
25
|
+
// or excluded, every field that reads one of those, and every metric whose
|
|
26
|
+
// expression reads any of them, returning the pruned model and a
|
|
27
|
+
// per-profile availability report. Keys and join columns are physical
|
|
28
|
+
// columns rather than fields, so pruning fields never removes an entity or
|
|
29
|
+
// a relationship.
|
|
30
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
31
|
+
if (k2 === undefined) k2 = k;
|
|
32
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
33
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
34
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
35
|
+
}
|
|
36
|
+
Object.defineProperty(o, k2, desc);
|
|
37
|
+
}) : (function(o, m, k, k2) {
|
|
38
|
+
if (k2 === undefined) k2 = k;
|
|
39
|
+
o[k2] = m[k];
|
|
40
|
+
}));
|
|
41
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
42
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
43
|
+
}) : function(o, v) {
|
|
44
|
+
o["default"] = v;
|
|
45
|
+
});
|
|
46
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
47
|
+
var ownKeys = function(o) {
|
|
48
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
49
|
+
var ar = [];
|
|
50
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
51
|
+
return ar;
|
|
52
|
+
};
|
|
53
|
+
return ownKeys(o);
|
|
54
|
+
};
|
|
55
|
+
return function (mod) {
|
|
56
|
+
if (mod && mod.__esModule) return mod;
|
|
57
|
+
var result = {};
|
|
58
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
59
|
+
__setModuleDefault(result, mod);
|
|
60
|
+
return result;
|
|
61
|
+
};
|
|
62
|
+
})();
|
|
63
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
64
|
+
exports.IMPORT_TRANSPILE_COMMAND = exports.DEFAULT_PROFILE = void 0;
|
|
65
|
+
exports.plainCopy = plainCopy;
|
|
66
|
+
exports.pruneUnavailable = pruneUnavailable;
|
|
67
|
+
exports.withoutExclusions = withoutExclusions;
|
|
68
|
+
exports.resolvedEntities = resolvedEntities;
|
|
69
|
+
exports.inheritedNamesOf = inheritedNamesOf;
|
|
70
|
+
exports.loadProfileFile = loadProfileFile;
|
|
71
|
+
exports.applyProfile = applyProfile;
|
|
72
|
+
exports.applyProfileFile = applyProfileFile;
|
|
73
|
+
exports.declaredProfiles = declaredProfiles;
|
|
74
|
+
exports.profileForDocument = profileForDocument;
|
|
75
|
+
exports.profileErrorMessage = profileErrorMessage;
|
|
76
|
+
exports.unknownProfileMessage = unknownProfileMessage;
|
|
77
|
+
exports.reservedProfileNameError = reservedProfileNameError;
|
|
78
|
+
exports.profileSpecOf = profileSpecOf;
|
|
79
|
+
exports.expressionSources = expressionSources;
|
|
80
|
+
exports.noTextFor = noTextFor;
|
|
81
|
+
exports.tableDialects = tableDialects;
|
|
82
|
+
exports.fieldsOnTables = fieldsOnTables;
|
|
83
|
+
exports.importTranspiles = importTranspiles;
|
|
84
|
+
exports.sourcesDialect = sourcesDialect;
|
|
85
|
+
exports.validateProfileCompleteness = validateProfileCompleteness;
|
|
86
|
+
exports.validateProfileConsistency = validateProfileConsistency;
|
|
87
|
+
exports.inheritanceErrorOf = inheritanceErrorOf;
|
|
88
|
+
exports.columnBinding = columnBinding;
|
|
89
|
+
exports.statesKey = statesKey;
|
|
90
|
+
const yaml = __importStar(require("yaml"));
|
|
91
|
+
const ir_1 = require("./ir");
|
|
92
|
+
const loader_1 = require("./loader");
|
|
93
|
+
const names_1 = require("./names");
|
|
94
|
+
const resolve_inheritance_1 = require("./resolve_inheritance");
|
|
95
|
+
const sql_expr_utils_1 = require("./sql_expr_utils");
|
|
96
|
+
const transpile_1 = require("./transpile");
|
|
97
|
+
const yaml_options_1 = require("./yaml_options");
|
|
98
|
+
// The implicit profile: the inline bindings already in the model document (the
|
|
99
|
+
// combined single-file form).
|
|
100
|
+
exports.DEFAULT_PROFILE = 'default';
|
|
101
|
+
// Keys a profile may carry at each level. Everything else is a logical
|
|
102
|
+
// declaration the model owns; setting it in a profile is rejected so swapping a
|
|
103
|
+
// profile can move data but never change what the model means.
|
|
104
|
+
const PROFILE_FILE_KEYS = new Set([
|
|
105
|
+
'name', 'entities', 'relationships', 'metrics_exclude', 'actions',
|
|
106
|
+
]);
|
|
107
|
+
const PROFILE_ENTITY_KEYS = new Set([
|
|
108
|
+
'name', 'source', 'primary_key', 'unique_keys', 'fields', 'fields_exclude',
|
|
109
|
+
]);
|
|
110
|
+
const PROFILE_RELATIONSHIP_KEYS = new Set(['name', 'from_columns', 'to_columns']);
|
|
111
|
+
const PROFILE_FIELD_KEYS = new Set(['name', 'expression']);
|
|
112
|
+
const PROFILE_ACTION_KEYS = new Set(['name', 'executor']);
|
|
113
|
+
// A deep copy in which every node is its own object. structuredClone keeps a
|
|
114
|
+
// YAML anchor and its aliases as one object, so an edit through one alias
|
|
115
|
+
// would change every node that shares it.
|
|
116
|
+
function plainCopy(value) {
|
|
117
|
+
if (Array.isArray(value))
|
|
118
|
+
return value.map(plainCopy);
|
|
119
|
+
if (value && typeof value === 'object') {
|
|
120
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainCopy(v)]));
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
function notAllowed(where, key) {
|
|
125
|
+
return `${where} sets '${key}', which a profile may not set; a profile ` +
|
|
126
|
+
`carries only physical bindings, and the logical model owns everything ` +
|
|
127
|
+
`else`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Returns a clone of `model` reduced to what `profileName` can answer: unbound
|
|
131
|
+
* and excluded fields taken off their entities, with every field that reads
|
|
132
|
+
* one of them, directly or through other fields; every excluded metric and
|
|
133
|
+
* every metric that reads an unavailable field dropped; and every action with
|
|
134
|
+
* no executor dropped. A field counts whether the entity declares or inherits
|
|
135
|
+
* it. What a field or metric reads comes from the text `dialect` evaluates
|
|
136
|
+
* (see textsFor). `dialect` defaults to the dialect the model's sources
|
|
137
|
+
* imply. When the sources are in systems of different dialects, or one names
|
|
138
|
+
* no system, there is no dialect and every text counts.
|
|
139
|
+
* The input is never mutated. The report names each dropped block
|
|
140
|
+
* and the unbound field that stops it, so a caller can state the withheld
|
|
141
|
+
* coverage. A model with nothing unbound resolves unchanged.
|
|
142
|
+
*/
|
|
143
|
+
function pruneUnavailable(model, profileName, dialect = sourcesDialect(model)) {
|
|
144
|
+
const clone = structuredClone(model);
|
|
145
|
+
const report = {
|
|
146
|
+
profile: profileName,
|
|
147
|
+
unboundFields: [],
|
|
148
|
+
excludedFields: [],
|
|
149
|
+
droppedEntities: [],
|
|
150
|
+
droppedMetrics: [],
|
|
151
|
+
droppedRelationships: [],
|
|
152
|
+
droppedActions: [],
|
|
153
|
+
droppedFields: [],
|
|
154
|
+
warnings: [],
|
|
155
|
+
};
|
|
156
|
+
// A field is bound when isFieldBound says so; otherwise it is unbound
|
|
157
|
+
// (structurally absent under this profile). A field awaiting transpilation
|
|
158
|
+
// (its column carried on the imported expression) or bound only through a
|
|
159
|
+
// dialect list counts as bound, not dropped. The graph generators ask a
|
|
160
|
+
// narrower question, whether the field has SQL for their engine, and leave
|
|
161
|
+
// out a bound field that has none with a warning of their own.
|
|
162
|
+
//
|
|
163
|
+
// Availability is judged on each entity's resolved fields, declared plus
|
|
164
|
+
// inherited, since a metric can read an inherited field as readily as a
|
|
165
|
+
// declared one. A field the profile excludes on an entity is unavailable on
|
|
166
|
+
// that entity.
|
|
167
|
+
const unbound = new Set();
|
|
168
|
+
const resolved = resolvedEntities(clone);
|
|
169
|
+
for (const e of resolved) {
|
|
170
|
+
// An abstract entity has no table and no bindings by design: it survives
|
|
171
|
+
// only as a label on its subtypes (which bind its inherited fields on their
|
|
172
|
+
// own tables). Its fields are legitimately column-less, so they are not
|
|
173
|
+
// "unbound" in the pruning sense.
|
|
174
|
+
if (e.abstract)
|
|
175
|
+
continue;
|
|
176
|
+
for (const f of e.fields ?? []) {
|
|
177
|
+
if (!(0, ir_1.isFieldBound)(f))
|
|
178
|
+
unbound.add(`${e.name}.${f.name}`);
|
|
179
|
+
}
|
|
180
|
+
for (const name of e.excludedFields ?? []) {
|
|
181
|
+
unbound.add(`${e.name}.${name}`);
|
|
182
|
+
report.excludedFields.push(`${e.name}.${name}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
report.unboundFields = [...unbound];
|
|
186
|
+
// A field that reads an unavailable field of its entity is unavailable too,
|
|
187
|
+
// and so is every field and metric that reads it in turn. Each field's
|
|
188
|
+
// dependencies are read once, and only when something is unbound.
|
|
189
|
+
const unavailable = new Set(unbound);
|
|
190
|
+
const unreadable = new Set();
|
|
191
|
+
const dependencies = new Map();
|
|
192
|
+
if (unbound.size) {
|
|
193
|
+
for (const e of resolved) {
|
|
194
|
+
if (e.abstract)
|
|
195
|
+
continue;
|
|
196
|
+
for (const f of e.fields ?? []) {
|
|
197
|
+
const key = `${e.name}.${f.name}`;
|
|
198
|
+
if (unbound.has(key))
|
|
199
|
+
continue;
|
|
200
|
+
dependencies.set(key, fieldDependencies(clone, e.name, f.name, unreadable, dialect)
|
|
201
|
+
.map(dep => `${e.name}.${dep}`));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
let grew = true;
|
|
206
|
+
while (grew) {
|
|
207
|
+
grew = false;
|
|
208
|
+
for (const [key, deps] of dependencies) {
|
|
209
|
+
if (unavailable.has(key))
|
|
210
|
+
continue;
|
|
211
|
+
const reached = deps.find(target => unavailable.has(target));
|
|
212
|
+
if (reached) {
|
|
213
|
+
unavailable.add(key);
|
|
214
|
+
report.droppedFields.push({ name: key, reason: `reads ${reached}, which is unavailable` });
|
|
215
|
+
grew = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const what of unreadable) {
|
|
220
|
+
report.warnings.push(`${what}: kcmd could not read its SQL, so the fields it reads were ` +
|
|
221
|
+
`not checked`);
|
|
222
|
+
}
|
|
223
|
+
// Keys and join columns are physical column names, not fields, so whether a
|
|
224
|
+
// field is bound says nothing about them. Excluding a field that happens to
|
|
225
|
+
// share a key column's name drops the field and nothing else.
|
|
226
|
+
const allEntityNames = (clone.entities ?? []).map(e => e.name);
|
|
227
|
+
// Take unbound and excluded fields off the entities that declare them. (A
|
|
228
|
+
// bound field whose value is null still emits a column -- unbound is not
|
|
229
|
+
// null.) A declaration that a descendant inherits stays, so the descendant
|
|
230
|
+
// keeps the definition and whatever binding it has, and is marked excluded on
|
|
231
|
+
// the declaring entity instead, since a binding is a fact about one table.
|
|
232
|
+
const extendedNames = new Set(resolvedEntities(clone).flatMap(e => e.extends ?? []));
|
|
233
|
+
for (const e of clone.entities ?? []) {
|
|
234
|
+
// Keep an abstract entity's fields intact: they are column-less by design
|
|
235
|
+
// and name the shared label's property set for the emitter (see above).
|
|
236
|
+
if (e.abstract)
|
|
237
|
+
continue;
|
|
238
|
+
// An inherited field that is unavailable on this entity is marked excluded
|
|
239
|
+
// here, whether it is unbound, excluded, or reads such a field. Removing
|
|
240
|
+
// this entity's line alone, or having none to remove, would let inheritance
|
|
241
|
+
// hand back the ancestor's definition and binding.
|
|
242
|
+
const inheritedUnavailable = [...inheritedNamesOf(clone, e.name)].filter(name => unavailable.has(`${e.name}.${name}`));
|
|
243
|
+
const excluded = new Set(e.excludedFields ?? []);
|
|
244
|
+
const gone = (f) => !(0, ir_1.isFieldBound)(f) || excluded.has(f.name) ||
|
|
245
|
+
unavailable.has(`${e.name}.${f.name}`);
|
|
246
|
+
if (extendedNames.has(e.name)) {
|
|
247
|
+
const kept = (e.fields ?? []).filter(gone).map(f => f.name);
|
|
248
|
+
if (kept.length) {
|
|
249
|
+
e.excludedFields = [...new Set([...excluded, ...kept])];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
e.fields = (e.fields ?? []).filter(f => !gone(f));
|
|
254
|
+
}
|
|
255
|
+
if (inheritedUnavailable.length) {
|
|
256
|
+
e.excludedFields =
|
|
257
|
+
[...new Set([...(e.excludedFields ?? []), ...inheritedUnavailable])];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// A relationship's join columns are its own binding, so pruning fields never
|
|
261
|
+
// removes one.
|
|
262
|
+
const keptRels = clone.relationships ?? [];
|
|
263
|
+
// A metric is available only when the profile does not exclude it, every
|
|
264
|
+
// field it reads is available, and -- when it spans entities -- a
|
|
265
|
+
// relationship connects them.
|
|
266
|
+
const excludedMetrics = new Set(clone.excludedMetrics ?? []);
|
|
267
|
+
delete clone.excludedMetrics;
|
|
268
|
+
const keptMetrics = [];
|
|
269
|
+
for (const mt of clone.metrics ?? []) {
|
|
270
|
+
if (excludedMetrics.has(mt.name)) {
|
|
271
|
+
report.droppedMetrics.push({ name: mt.name, reason: 'excluded' });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const columns = [];
|
|
275
|
+
// A text with no SQL has nothing to read.
|
|
276
|
+
const sources = textsFor(mt, dialect)
|
|
277
|
+
.filter(s => (0, loader_1.noSqlReason)(s.text, s.dialect) === undefined);
|
|
278
|
+
let unread = 0;
|
|
279
|
+
for (const { text, dialect } of sources) {
|
|
280
|
+
const read = (0, sql_expr_utils_1.columnReferences)(text, dialect);
|
|
281
|
+
if (read) {
|
|
282
|
+
columns.push(...read);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
unread++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const hit = firstUnboundReferenced(columns, unavailable);
|
|
289
|
+
if (hit) {
|
|
290
|
+
report.droppedMetrics.push({
|
|
291
|
+
name: mt.name,
|
|
292
|
+
reason: `field ${hit} is ${unbound.has(hit) ? 'unbound' : 'unavailable'}`,
|
|
293
|
+
field: hit,
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const refs = (0, sql_expr_utils_1.entitiesReached)(columns, allEntityNames);
|
|
298
|
+
if (refs.length > 1 && !connectingRelationshipKept(refs, keptRels)) {
|
|
299
|
+
report.droppedMetrics.push({
|
|
300
|
+
name: mt.name,
|
|
301
|
+
reason: `no available relationship connects ${refs.join(', ')}`,
|
|
302
|
+
});
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
// A text the parser cannot read is skipped, so what it reads is unknown.
|
|
306
|
+
if (unread) {
|
|
307
|
+
report.warnings.push(unread === sources.length ?
|
|
308
|
+
`metric '${mt.name}': kcmd could not read its SQL, so the ` +
|
|
309
|
+
`fields it reads were not checked` :
|
|
310
|
+
`metric '${mt.name}': kcmd could not read some of its SQL, so ` +
|
|
311
|
+
`the fields that SQL reads were not checked`);
|
|
312
|
+
}
|
|
313
|
+
keptMetrics.push(mt);
|
|
314
|
+
}
|
|
315
|
+
clone.metrics = keptMetrics;
|
|
316
|
+
// An action is available only where a binding performs it. The executor is
|
|
317
|
+
// the action's binding, so an action without one is unavailable for the same
|
|
318
|
+
// reason a column-less field is: it is declared, and there is nothing here to
|
|
319
|
+
// carry it out.
|
|
320
|
+
const keptActions = [];
|
|
321
|
+
for (const a of clone.actions ?? []) {
|
|
322
|
+
if (a.executor === undefined) {
|
|
323
|
+
report.droppedActions.push({ name: a.name, reason: 'no executor is bound under this profile' });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
keptActions.push(a);
|
|
327
|
+
}
|
|
328
|
+
clone.actions = keptActions;
|
|
329
|
+
return { model: clone, report };
|
|
330
|
+
}
|
|
331
|
+
// `model` without the exclusions a profile marked on it.
|
|
332
|
+
function withoutExclusions(model) {
|
|
333
|
+
const out = structuredClone(model);
|
|
334
|
+
for (const e of out.entities ?? [])
|
|
335
|
+
delete e.excludedFields;
|
|
336
|
+
delete out.excludedMetrics;
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
// Each entity resolved for inheritance, or as declared when the hierarchy is
|
|
340
|
+
// broken, which push reports elsewhere. `extends` is expanded to every
|
|
341
|
+
// ancestor.
|
|
342
|
+
function resolvedEntities(model) {
|
|
343
|
+
const entities = model.entities ?? [];
|
|
344
|
+
if (!entities.some(e => e.extends?.length))
|
|
345
|
+
return entities;
|
|
346
|
+
try {
|
|
347
|
+
return (0, resolve_inheritance_1.resolveInheritance)(model).model.entities ?? entities;
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
if (!(err instanceof resolve_inheritance_1.InheritanceError))
|
|
351
|
+
throw err;
|
|
352
|
+
return entities;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// The names of the fields `entityName` inherits from every ancestor its
|
|
356
|
+
// `extends` reaches. A caller asking for many entities passes `byName`, the
|
|
357
|
+
// model's entities by name, built once.
|
|
358
|
+
function inheritedNamesOf(model, entityName, byName = new Map((model.entities ?? []).map(e => [e.name, e]))) {
|
|
359
|
+
return new Set(ancestorsOf(entityName, byName)
|
|
360
|
+
.flatMap(a => (a.fields ?? []).map(f => f.name)));
|
|
361
|
+
}
|
|
362
|
+
// Every ancestor `entityName`'s `extends` reaches, each once. `byName` is the
|
|
363
|
+
// model's entities by name.
|
|
364
|
+
function ancestorsOf(entityName, byName) {
|
|
365
|
+
const ancestors = [];
|
|
366
|
+
const seen = new Set([entityName]);
|
|
367
|
+
const queue = [...(byName.get(entityName)?.extends ?? [])];
|
|
368
|
+
while (queue.length) {
|
|
369
|
+
const name = queue.shift();
|
|
370
|
+
if (seen.has(name))
|
|
371
|
+
continue;
|
|
372
|
+
seen.add(name);
|
|
373
|
+
const ancestor = byName.get(name);
|
|
374
|
+
if (!ancestor)
|
|
375
|
+
continue;
|
|
376
|
+
ancestors.push(ancestor);
|
|
377
|
+
queue.push(...(ancestor.extends ?? []));
|
|
378
|
+
}
|
|
379
|
+
return ancestors;
|
|
380
|
+
}
|
|
381
|
+
// The first unbound "Entity.field" among the columns a metric expression
|
|
382
|
+
// reads, or null.
|
|
383
|
+
function firstUnboundReferenced(columns, unbound) {
|
|
384
|
+
for (const key of unbound) {
|
|
385
|
+
const dot = key.indexOf('.');
|
|
386
|
+
if ((0, sql_expr_utils_1.fieldsReadOn)(columns, key.slice(0, dot)).includes(key.slice(dot + 1))) {
|
|
387
|
+
return key;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
// The dialect `text` is written in on `f`: the dialect of the list entry that
|
|
393
|
+
// holds it, the imported dialect for the imported text, and BigQuery
|
|
394
|
+
// otherwise, since the loader fills `expression` from a `BIGQUERY` or
|
|
395
|
+
// `ANSI_SQL` entry.
|
|
396
|
+
function dialectOf(f, text) {
|
|
397
|
+
const entry = f.dialects?.find(d => d.expression === text);
|
|
398
|
+
if (entry)
|
|
399
|
+
return entry.dialect;
|
|
400
|
+
if (text === f.importedExpression && f.importedDialect) {
|
|
401
|
+
return f.importedDialect;
|
|
402
|
+
}
|
|
403
|
+
return 'BIGQUERY';
|
|
404
|
+
}
|
|
405
|
+
// Whether any relationship directly connects two of the referenced entities --
|
|
406
|
+
// the minimal check that a cross-entity metric has a join path.
|
|
407
|
+
function connectingRelationshipKept(refEntities, kept) {
|
|
408
|
+
const set = new Set(refEntities);
|
|
409
|
+
return kept.some(r => set.has(r.source.entity) && set.has(r.destination.entity));
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Reads a profile file into the IR's `ProfileSpec`. Throws on anything a
|
|
413
|
+
* profile may not say: an unknown key at any level, a value of the wrong shape,
|
|
414
|
+
* a field in both `fields` and `fields_exclude`, `"*"` inside a
|
|
415
|
+
* `metrics_exclude` list, or a `name` other than `profileName`. A field
|
|
416
|
+
* expression and an action's executor are read by the loader's own readers, so
|
|
417
|
+
* each follows the rules it follows in a model file. A source is kept as
|
|
418
|
+
* written, and applyProfile reads it with the loader's reader too. A field
|
|
419
|
+
* expression's short form means `ANSI_SQL` in either flavor. Actions are out of
|
|
420
|
+
* scope for the preview, so nothing more is checked. With `skipActions`, the
|
|
421
|
+
* profile's actions are not read.
|
|
422
|
+
*/
|
|
423
|
+
function loadProfileFile(text, profileName, opts = {}) {
|
|
424
|
+
let doc;
|
|
425
|
+
try {
|
|
426
|
+
doc = yaml.parse(text, yaml_options_1.YAML_OPTIONS);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
throw new loader_1.SemanticModelError(`profile '${profileName}' does not parse: ${err?.message ?? err}`);
|
|
430
|
+
}
|
|
431
|
+
if (opts.skipActions && doc && typeof doc === 'object' &&
|
|
432
|
+
!Array.isArray(doc)) {
|
|
433
|
+
const { actions: _, ...rest } = doc;
|
|
434
|
+
doc = rest;
|
|
435
|
+
}
|
|
436
|
+
return profileSpecOf(doc, profileName);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Returns a copy of `model` with `profile` applied: each named entity's
|
|
440
|
+
* source, its keys where the profile states them, its field bindings, as a
|
|
441
|
+
* binding-only redeclaration for a field the entity inherits, and its
|
|
442
|
+
* `fields_exclude`; each relationship's join columns; `metrics_exclude`; and
|
|
443
|
+
* each action's executor, which is taken as given, since actions are out of
|
|
444
|
+
* preview scope. A field a profile binds keeps every dialect text the profile
|
|
445
|
+
* wrote. An excluded field is marked in `excludedFields`, and loses its
|
|
446
|
+
* binding when no entity inherits it. The input is never mutated.
|
|
447
|
+
*
|
|
448
|
+
* Throws, naming the profile, when the profile repeats a name, leaves a field
|
|
449
|
+
* entry with nothing to bind, both binds and excludes a field, puts "*" inside
|
|
450
|
+
* a list, names something the model does not declare, binds an abstract
|
|
451
|
+
* entity, states one of a relationship's join column lists without the
|
|
452
|
+
* other or gives both lists with different lengths, gives a source or
|
|
453
|
+
* expression the loader's readers reject, or is applied to a model that
|
|
454
|
+
* declares a `sql` executor, which belongs in each profile.
|
|
455
|
+
*/
|
|
456
|
+
function applyProfile(model, profile, opts = {}) {
|
|
457
|
+
const res = applyProfileSpec(model, profile, opts);
|
|
458
|
+
if ('error' in res)
|
|
459
|
+
throw new loader_1.SemanticModelError(res.error);
|
|
460
|
+
if (opts.warnings) {
|
|
461
|
+
// With no `from`, these are the applied model's entity and metric
|
|
462
|
+
// warnings, judged on its tables in the load dialect, as the callers
|
|
463
|
+
// judge them. Keep those the model did not give already.
|
|
464
|
+
const before = new Set((0, loader_1.entityAndMetricWarnings)(model, opts.load?.dialect ?? loader_1.DEFAULT_DIALECT));
|
|
465
|
+
opts.warnings.push(...res.warnings.filter(w => !before.has(w)));
|
|
466
|
+
}
|
|
467
|
+
return res.model;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Reads the profile file `text` and applies it to `model`, or returns the
|
|
471
|
+
* error that stops it, naming the profile. The warnings are those of the
|
|
472
|
+
* model file's load (`opts.from`), less the ones about values the profile
|
|
473
|
+
* replaces, followed by those for the values it sets, each prefixed with the
|
|
474
|
+
* document name.
|
|
475
|
+
*/
|
|
476
|
+
function applyProfileFile(model, text, profileName, opts = {}) {
|
|
477
|
+
// Checked before the profile is read, so a model with a `sql` executor
|
|
478
|
+
// reports that first even when the profile file does not parse.
|
|
479
|
+
// profileApplyError checks it again for a profile built in memory.
|
|
480
|
+
const sql = logicalSqlExecutorError(model, profileName);
|
|
481
|
+
if (sql)
|
|
482
|
+
return { error: sql };
|
|
483
|
+
let profile;
|
|
484
|
+
try {
|
|
485
|
+
profile = loadProfileFile(text, profileName);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
489
|
+
throw err;
|
|
490
|
+
return { error: err.message };
|
|
491
|
+
}
|
|
492
|
+
return applyProfileSpec(model, profile, opts);
|
|
493
|
+
}
|
|
494
|
+
// applyProfile and applyProfileFile's shared work: the reasons a profile
|
|
495
|
+
// cannot apply, the bindings, and the warnings that follow.
|
|
496
|
+
function applyProfileSpec(model, profile, opts) {
|
|
497
|
+
const error = profileApplyError(model, profile);
|
|
498
|
+
if (error)
|
|
499
|
+
return { error };
|
|
500
|
+
const applied = applyBindings(model, profile, opts);
|
|
501
|
+
if (applied.errors.length) {
|
|
502
|
+
return { error: `profile '${profile.name}': ${applied.errors[0]}` };
|
|
503
|
+
}
|
|
504
|
+
// A field the profile binds is checked on each entity that inherits it, as
|
|
505
|
+
// a load checks a model file's. A model whose inheritance does not resolve
|
|
506
|
+
// is reported by validation instead.
|
|
507
|
+
let concepts;
|
|
508
|
+
try {
|
|
509
|
+
concepts = (0, resolve_inheritance_1.declaredConceptFields)(applied.model);
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
if (!(err instanceof resolve_inheritance_1.InheritanceError))
|
|
513
|
+
throw err;
|
|
514
|
+
}
|
|
515
|
+
const inherited = concepts && (0, loader_1.inheritedNoSqlProblems)(applied.model.entities, concepts)[0];
|
|
516
|
+
if (inherited)
|
|
517
|
+
return { error: `profile '${profile.name}': ${inherited}` };
|
|
518
|
+
return {
|
|
519
|
+
model: applied.model,
|
|
520
|
+
warnings: warningsAfterProfile(model, applied.model, opts),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Loads the model document `doc` on its own and applies `profile` to it. An
|
|
525
|
+
* error says whether the document failed to load or the profile failed to
|
|
526
|
+
* apply, since the callers word the two differently.
|
|
527
|
+
*/
|
|
528
|
+
function applyProfileToDocument(doc, profile, load) {
|
|
529
|
+
return applyToDocument(doc, (base, from) => applyProfileSpec(base, profile, { from, load }), load);
|
|
530
|
+
}
|
|
531
|
+
/** applyProfileToDocument for the profile file `text`. */
|
|
532
|
+
function applyProfileFileToDocument(doc, text, profileName, load) {
|
|
533
|
+
return applyToDocument(doc, (base, from) => applyProfileFile(base, text, profileName, { from, load }), load);
|
|
534
|
+
}
|
|
535
|
+
function applyToDocument(doc, apply, load) {
|
|
536
|
+
const loaded = (0, loader_1.loadSemanticModels)([{ name: doc.name, text: doc.text }], { bindingOptional: true, ...load });
|
|
537
|
+
if (loaded.error)
|
|
538
|
+
return { error: loaded.error, stage: 'load' };
|
|
539
|
+
const res = apply(loaded.models[0].model, { document: doc.name, warnings: loaded.warnings });
|
|
540
|
+
if ('error' in res)
|
|
541
|
+
return { error: res.error, stage: 'apply' };
|
|
542
|
+
return { model: res.model, warnings: res.warnings };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* The profiles the model document `doc` declares: `files`, its profile files,
|
|
546
|
+
* then the profiles a vanilla model carries in its GOOGLE block, by name, as
|
|
547
|
+
* the layout orders profile files. Push, `kcmd profiles` and the runtime all
|
|
548
|
+
* read them here, so they agree. Throws SemanticModelError, naming the
|
|
549
|
+
* document as a load does, when googleBlockProfiles throws, such as on block
|
|
550
|
+
* profiles a load rejects.
|
|
551
|
+
*/
|
|
552
|
+
function declaredProfiles(doc, files) {
|
|
553
|
+
let blockProfiles;
|
|
554
|
+
try {
|
|
555
|
+
blockProfiles = (0, loader_1.googleBlockProfiles)(doc.text);
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
559
|
+
throw err;
|
|
560
|
+
throw new loader_1.SemanticModelError(`Model document '${doc.name}': ${err.message}`);
|
|
561
|
+
}
|
|
562
|
+
return [
|
|
563
|
+
...files,
|
|
564
|
+
...blockProfiles.map(spec => ({ name: spec.name, spec }))
|
|
565
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
566
|
+
];
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Finds the profile `profileName` among `profiles`, the ones the model
|
|
570
|
+
* document `doc` declares, and applies it, as push, `kcmd profiles` and the
|
|
571
|
+
* runtime do. It applies to the document loaded on its own.
|
|
572
|
+
*/
|
|
573
|
+
function profileForDocument(doc, profiles, profileName, load = {}) {
|
|
574
|
+
const chosen = profiles.find(p => p.name === profileName);
|
|
575
|
+
if (!chosen)
|
|
576
|
+
return { kind: 'missing', declared: profiles.map(p => p.name) };
|
|
577
|
+
const res = 'spec' in chosen ?
|
|
578
|
+
applyProfileToDocument(doc, chosen.spec, load) :
|
|
579
|
+
applyProfileFileToDocument(doc, chosen.text, profileName, load);
|
|
580
|
+
return 'error' in res ? { kind: 'error', ...res } : { kind: 'file', ...res };
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* `error`, from loading a model document on its own before a profile file
|
|
584
|
+
* applies to it, with a note that says so. A model file that a profile used to
|
|
585
|
+
* complete, such as one with a half-given join, fails that load. Names
|
|
586
|
+
* `profileName` when one profile is selected.
|
|
587
|
+
*/
|
|
588
|
+
function modelFileAloneMessage(error, profileName) {
|
|
589
|
+
return profileName === undefined ?
|
|
590
|
+
`${error} (the model file must load on its own before a profile file ` +
|
|
591
|
+
`applies)` :
|
|
592
|
+
`${error} (under profile '${profileName}', the model file must load ` +
|
|
593
|
+
`on its own before the profile applies)`;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* The error a command gives when a profile cannot be applied to the model
|
|
597
|
+
* document `docName`, whose profile files are `profileFiles`. An error applying
|
|
598
|
+
* a profile file names that file, as the profile checks do. An error applying
|
|
599
|
+
* a profile from a GOOGLE block names the model document.
|
|
600
|
+
*/
|
|
601
|
+
function profileErrorMessage(docName, res, profileName, profileFiles) {
|
|
602
|
+
if (res.stage === 'load') {
|
|
603
|
+
return modelFileAloneMessage(res.error, profileName);
|
|
604
|
+
}
|
|
605
|
+
const file = profileFiles.find(p => p.name === profileName);
|
|
606
|
+
return `[${file?.fileName ?? docName}] ${res.error}`;
|
|
607
|
+
}
|
|
608
|
+
/** The error for a profile name a model document does not declare. */
|
|
609
|
+
function unknownProfileMessage(profileName, model, declared) {
|
|
610
|
+
return `unknown binding profile '${profileName}' for model '${model}'; ` +
|
|
611
|
+
(declared.length ? `defined profiles: ${declared.join(', ')}.` :
|
|
612
|
+
`no profiles are defined for this model.`);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* The error for a model document `docName` whose `profiles` include one named
|
|
616
|
+
* `default` in any letter case. Push refuses every graph push of such a
|
|
617
|
+
* model, and the runtime refuses the model too. Undefined when no profile has
|
|
618
|
+
* that name.
|
|
619
|
+
*/
|
|
620
|
+
function reservedProfileNameError(docName, profiles) {
|
|
621
|
+
if (!profiles.some(p => p.name.toLowerCase() === exports.DEFAULT_PROFILE)) {
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
return `[${docName}] a binding profile may not be named '${exports.DEFAULT_PROFILE}' -- that name refers to the model's inline ` +
|
|
625
|
+
`bindings. Rename the profile.`;
|
|
626
|
+
}
|
|
627
|
+
// The warnings a load of the applied model would give, each prefixed with the
|
|
628
|
+
// document name: those about its entities and metrics, from
|
|
629
|
+
// entityAndMetricWarnings, which builds them with the helpers the load uses,
|
|
630
|
+
// then the model file's other load warnings, about actions, which a profile
|
|
631
|
+
// does not change. That is the order a load gives them. The applied model is
|
|
632
|
+
// judged on its own tables, in the load's dialect.
|
|
633
|
+
function warningsAfterProfile(model, applied, opts) {
|
|
634
|
+
const from = opts.from;
|
|
635
|
+
const prefix = from ? `[${from.document}] ` : '';
|
|
636
|
+
const preferred = opts.load?.dialect ?? loader_1.DEFAULT_DIALECT;
|
|
637
|
+
const before = new Set((0, loader_1.entityAndMetricWarnings)(model, preferred).map(w => prefix + w));
|
|
638
|
+
const kept = (from?.warnings ?? []).filter(w => !before.has(w));
|
|
639
|
+
const after = (0, loader_1.entityAndMetricWarnings)(applied, preferred, true).map(w => prefix + w);
|
|
640
|
+
return [...new Set([...after, ...kept])];
|
|
641
|
+
}
|
|
642
|
+
// Every reason `profile` names something `model` cannot take, in this order:
|
|
643
|
+
// a `sql` executor in the model, the profile's own
|
|
644
|
+
// structure, and each action, entity, field, relationship and metric it names
|
|
645
|
+
// that the model does not declare or a profile cannot bind. applyProfile
|
|
646
|
+
// throws the first, and validateProfileCompleteness lists them all.
|
|
647
|
+
function profileProblems(model, profile) {
|
|
648
|
+
const at = `profile '${profile.name}'`;
|
|
649
|
+
const problems = [];
|
|
650
|
+
const sql = logicalSqlExecutorError(model, profile.name);
|
|
651
|
+
if (sql)
|
|
652
|
+
problems.push(sql);
|
|
653
|
+
problems.push(...profileStructureErrors(profile));
|
|
654
|
+
const actions = new Set((model.actions ?? []).map(a => a.name));
|
|
655
|
+
for (const pa of profile.actions ?? []) {
|
|
656
|
+
if (!actions.has(pa.name)) {
|
|
657
|
+
problems.push(`${at}: action '${pa.name}' is not in the logical model`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const byName = new Map((model.entities ?? []).map(e => [e.name, e]));
|
|
661
|
+
for (const pe of profile.entities) {
|
|
662
|
+
const e = byName.get(pe.name);
|
|
663
|
+
if (!e) {
|
|
664
|
+
problems.push(`${at}: entity '${pe.name}' is not in the logical model`);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (e.abstract) {
|
|
668
|
+
problems.push(`${at}: entity '${pe.name}' is abstract, so a profile cannot ` +
|
|
669
|
+
`bind it`);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const declared = new Set(e.fields.map(f => f.name));
|
|
673
|
+
const inherited = inheritedNamesOf(model, e.name, byName);
|
|
674
|
+
for (const pf of pe.fields ?? []) {
|
|
675
|
+
if (!declared.has(pf.name) && !inherited.has(pf.name)) {
|
|
676
|
+
problems.push(`${at}: field '${pe.name}.${pf.name}' is not in the logical model`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
for (const name of pe.fieldsExclude ?? []) {
|
|
680
|
+
if (!declared.has(name) && !inherited.has(name)) {
|
|
681
|
+
problems.push(`${at}: field '${pe.name}.${name}' in 'fields_exclude' is not in the logical model`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const relationships = new Set((model.relationships ?? []).map(r => r.name));
|
|
686
|
+
for (const pr of profile.relationships) {
|
|
687
|
+
if (!relationships.has(pr.name)) {
|
|
688
|
+
problems.push(`${at}: relationship '${pr.name}' is not in the logical model`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (Array.isArray(profile.metricsExclude)) {
|
|
692
|
+
const metrics = new Set((model.metrics ?? []).map(m => m.name));
|
|
693
|
+
for (const name of profile.metricsExclude) {
|
|
694
|
+
if (!metrics.has(name)) {
|
|
695
|
+
problems.push(`${at}: metric '${name}' in 'metrics_exclude' is not in the logical model`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return problems;
|
|
700
|
+
}
|
|
701
|
+
// Why `profile` cannot apply to `model`, or undefined: the first of
|
|
702
|
+
// profileProblems, then the join columns the profile states for each
|
|
703
|
+
// relationship. One list without the other gets the completeness check's
|
|
704
|
+
// error, and two lists must have equal length. A relationship the profile
|
|
705
|
+
// gives neither list keeps the model file's join columns, which the
|
|
706
|
+
// completeness check reports.
|
|
707
|
+
function profileApplyError(model, profile) {
|
|
708
|
+
const problems = profileProblems(model, profile);
|
|
709
|
+
if (problems.length)
|
|
710
|
+
return problems[0];
|
|
711
|
+
const at = `profile '${profile.name}'`;
|
|
712
|
+
for (const { name, fromColumns: from, toColumns: to } of profile.relationships) {
|
|
713
|
+
if (!from.length !== !to.length) {
|
|
714
|
+
return `${at}: ${profileJoinMessage(name)}`;
|
|
715
|
+
}
|
|
716
|
+
if (from.length !== to.length) {
|
|
717
|
+
return `${at}: ${(0, loader_1.joinLengthMessage)(name, from.length, to.length)}`;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
// The error for a relationship a named profile does not bind with both
|
|
723
|
+
// 'from_columns' and 'to_columns'.
|
|
724
|
+
function profileJoinMessage(relationship) {
|
|
725
|
+
return `relationship '${relationship}' has no join columns in this ` +
|
|
726
|
+
`profile; a named profile states both 'from_columns' and ` +
|
|
727
|
+
`'to_columns' itself`;
|
|
728
|
+
}
|
|
729
|
+
// What loadProfileFile rejects in a profile's structure, in the order it
|
|
730
|
+
// checks, with its messages: a repeated name, a field entry with nothing to
|
|
731
|
+
// bind, a repeated dialect, a field both bound and excluded, and `"*"` inside
|
|
732
|
+
// a list. A profile file never has these; a profile built in memory may.
|
|
733
|
+
function profileStructureErrors(profile) {
|
|
734
|
+
const at = `profile '${profile.name}'`;
|
|
735
|
+
const errors = [];
|
|
736
|
+
const twice = (names) => names.find((n, i) => names.indexOf(n) !== i);
|
|
737
|
+
const dupEntity = twice(profile.entities.map(e => e.name));
|
|
738
|
+
if (dupEntity)
|
|
739
|
+
errors.push(`${at} 'entities' lists '${dupEntity}' twice`);
|
|
740
|
+
for (const pe of profile.entities) {
|
|
741
|
+
const where = `${at}: entity '${pe.name}'`;
|
|
742
|
+
const bound = (pe.fields ?? []).map(f => f.name);
|
|
743
|
+
const dupField = twice(bound);
|
|
744
|
+
if (dupField)
|
|
745
|
+
errors.push(`${where} 'fields' lists '${dupField}' twice`);
|
|
746
|
+
for (const pf of pe.fields ?? []) {
|
|
747
|
+
const fat = `${where}: field '${pf.name}'`;
|
|
748
|
+
if (!(0, ir_1.isFieldBound)(pf)) {
|
|
749
|
+
errors.push(`${fat} has no 'expression'; bind it with one, or list it in ` +
|
|
750
|
+
`'fields_exclude'`);
|
|
751
|
+
}
|
|
752
|
+
// Checked by the loader's reader, as a profile file's is. A binding
|
|
753
|
+
// with no dialect list, which only a profile built in memory has, is
|
|
754
|
+
// read as parseFieldBinding reads it.
|
|
755
|
+
if ((0, loader_1.expressionDocOf)(pf) !== undefined) {
|
|
756
|
+
try {
|
|
757
|
+
(0, loader_1.parseFieldBinding)(pf, fat, loader_1.DEFAULT_DIALECT, pe.name);
|
|
758
|
+
}
|
|
759
|
+
catch (err) {
|
|
760
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
761
|
+
throw err;
|
|
762
|
+
errors.push(err.message);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const both = (pe.fieldsExclude ?? []).find(n => bound.includes(n));
|
|
767
|
+
if (both) {
|
|
768
|
+
errors.push(`${where}: field '${both}' is in both 'fields' and ` +
|
|
769
|
+
`'fields_exclude'`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const dupRel = twice(profile.relationships.map(r => r.name));
|
|
773
|
+
if (dupRel)
|
|
774
|
+
errors.push(`${at} 'relationships' lists '${dupRel}' twice`);
|
|
775
|
+
if (Array.isArray(profile.metricsExclude) &&
|
|
776
|
+
profile.metricsExclude.includes('*')) {
|
|
777
|
+
errors.push(`${at}: 'metrics_exclude' takes "*" on its own, not inside a list`);
|
|
778
|
+
}
|
|
779
|
+
return errors;
|
|
780
|
+
}
|
|
781
|
+
// The error for a model that declares a `sql` executor when a profile binds
|
|
782
|
+
// it: a statement names one database's tables, so it belongs in the profile.
|
|
783
|
+
function logicalSqlExecutorError(model, profileName) {
|
|
784
|
+
const action = (model.actions ?? []).find(a => a.executor?.kind === 'sql');
|
|
785
|
+
return action && sqlExecutorMessage(profileName, action.name, model.name);
|
|
786
|
+
}
|
|
787
|
+
// The error for a `sql` executor in a model that a profile applies to.
|
|
788
|
+
function sqlExecutorMessage(profileName, action, model) {
|
|
789
|
+
return `profile '${profileName}': action '${action}' in model '${model}' declares a 'sql' executor. A statement names one ` +
|
|
790
|
+
`database's own tables and columns, so it belongs in the profile that ` +
|
|
791
|
+
`binds them, not in the model. Move the executor into each profile ` +
|
|
792
|
+
`that performs this write as DML.`;
|
|
793
|
+
}
|
|
794
|
+
// Applies what `profile` sets on the names `model` declares, and skips any
|
|
795
|
+
// name it does not. applyProfile checks the names first; the profile checks
|
|
796
|
+
// report each unknown name themselves. With `noSqlAsWritten`, a field binding
|
|
797
|
+
// the reader rejects only for holding no SQL is applied as a valid one is.
|
|
798
|
+
function applyBindings(model, profile, opts) {
|
|
799
|
+
const errors = [];
|
|
800
|
+
const out = structuredClone(model);
|
|
801
|
+
const load = opts.load ?? {};
|
|
802
|
+
const preferred = load.dialect ?? loader_1.DEFAULT_DIALECT;
|
|
803
|
+
// A source or expression the loader's readers reject is left unapplied. A
|
|
804
|
+
// rejected source is listed in `errors`. A rejected expression is not:
|
|
805
|
+
// profileStructureErrors reports it first, in the words a profile file gets,
|
|
806
|
+
// and every caller runs it before this.
|
|
807
|
+
const read = (readValue, list) => {
|
|
808
|
+
try {
|
|
809
|
+
return readValue();
|
|
810
|
+
}
|
|
811
|
+
catch (err) {
|
|
812
|
+
if (!(err instanceof loader_1.SemanticModelError))
|
|
813
|
+
throw err;
|
|
814
|
+
if (list)
|
|
815
|
+
errors.push(readerMessage(err));
|
|
816
|
+
return undefined;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
// An executor the profile leaves out stays as the model declares it; `null`
|
|
820
|
+
// withdraws it.
|
|
821
|
+
for (const pa of profile.actions ?? []) {
|
|
822
|
+
const a = (out.actions ?? []).find(x => x.name === pa.name);
|
|
823
|
+
if (!a || pa.executor === undefined)
|
|
824
|
+
continue;
|
|
825
|
+
if (pa.executor === null) {
|
|
826
|
+
delete a.executor;
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
a.executor = structuredClone(pa.executor);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const byName = new Map((out.entities ?? []).map(e => [e.name, e]));
|
|
833
|
+
const extended = entitiesWithDescendants(out);
|
|
834
|
+
for (const pe of profile.entities) {
|
|
835
|
+
const e = byName.get(pe.name);
|
|
836
|
+
if (!e || e.abstract)
|
|
837
|
+
continue;
|
|
838
|
+
const parsed = pe.source === undefined ?
|
|
839
|
+
undefined :
|
|
840
|
+
read(() => (0, loader_1.parseSource)(pe.source, load, `entity '${e.name}'`), true);
|
|
841
|
+
if (parsed) {
|
|
842
|
+
e.dataSource = parsed.dataSource;
|
|
843
|
+
e.authoredSource = parsed.authoredSource;
|
|
844
|
+
// The profile's source replaces the model file's, and so does its
|
|
845
|
+
// looked-up dialect, or the lack of one.
|
|
846
|
+
if (parsed.sourceDialect) {
|
|
847
|
+
e.sourceDialect = parsed.sourceDialect;
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
delete e.sourceDialect;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (pe.primaryKey !== undefined) {
|
|
854
|
+
e.keys = [...pe.primaryKey];
|
|
855
|
+
}
|
|
856
|
+
if (pe.uniqueKeys !== undefined) {
|
|
857
|
+
if (pe.uniqueKeys.length) {
|
|
858
|
+
e.uniqueKeys = pe.uniqueKeys.map(k => [...k]);
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
delete e.uniqueKeys;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const inherited = inheritedNamesOf(out, e.name, byName);
|
|
865
|
+
for (const pf of pe.fields ?? []) {
|
|
866
|
+
// An entry with nothing to bind leaves the field as it is. applyProfile
|
|
867
|
+
// rejects one, and validateProfileCompleteness reports it, through
|
|
868
|
+
// profileStructureErrors.
|
|
869
|
+
if (!(0, ir_1.isFieldBound)(pf))
|
|
870
|
+
continue;
|
|
871
|
+
// Read as a load reads the same expression in a model file. One the
|
|
872
|
+
// reader rejects is left unapplied, also when a profile built in memory
|
|
873
|
+
// gives it with no dialect list. Under `noSqlAsWritten`, one the reader
|
|
874
|
+
// rejects only for holding no SQL is read as the schema accepts it.
|
|
875
|
+
const where = `field '${e.name}.${pf.name}'`;
|
|
876
|
+
const binding = read(() => (0, loader_1.parseFieldBinding)(pf, where, preferred, e.name), false) ??
|
|
877
|
+
(opts.noSqlAsWritten ?
|
|
878
|
+
read(() => (0, loader_1.readFieldExpression)((0, loader_1.expressionDocOf)(pf), where, preferred, () => undefined), false) :
|
|
879
|
+
undefined);
|
|
880
|
+
if (!binding)
|
|
881
|
+
continue;
|
|
882
|
+
let f = e.fields.find(x => x.name === pf.name);
|
|
883
|
+
if (!f) {
|
|
884
|
+
if (!inherited.has(pf.name))
|
|
885
|
+
continue;
|
|
886
|
+
// Binding an inherited field redeclares it on this entity with only a
|
|
887
|
+
// binding, which inheritance merges over the ancestor's definition.
|
|
888
|
+
f = { name: pf.name };
|
|
889
|
+
e.fields.push(f);
|
|
890
|
+
}
|
|
891
|
+
for (const k of ir_1.FIELD_BINDING_KEYS)
|
|
892
|
+
delete f[k];
|
|
893
|
+
if (pf.dialects?.length) {
|
|
894
|
+
Object.assign(f, binding);
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
// A profile built in memory may carry a binding with no dialect list,
|
|
898
|
+
// which is applied as written.
|
|
899
|
+
for (const k of ir_1.FIELD_BINDING_KEYS) {
|
|
900
|
+
if (pf[k] !== undefined)
|
|
901
|
+
f[k] = structuredClone(pf[k]);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// An exclusion applies to this entity only. A field no entity inherits
|
|
906
|
+
// from this one also loses its binding; one a descendant inherits keeps
|
|
907
|
+
// the binding the descendant reads.
|
|
908
|
+
const below = extended.has(e.name);
|
|
909
|
+
for (const name of pe.fieldsExclude ?? []) {
|
|
910
|
+
const f = e.fields.find(x => x.name === name);
|
|
911
|
+
if (!f || below)
|
|
912
|
+
continue;
|
|
913
|
+
if (inherited.has(name)) {
|
|
914
|
+
// A line that only redeclares an inherited field goes with its
|
|
915
|
+
// binding. The entity still inherits the declaration.
|
|
916
|
+
e.fields = e.fields.filter(x => x !== f);
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
for (const k of ir_1.FIELD_BINDING_KEYS)
|
|
920
|
+
delete f[k];
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (pe.fieldsExclude?.length) {
|
|
924
|
+
e.excludedFields =
|
|
925
|
+
[...new Set([...(e.excludedFields ?? []), ...pe.fieldsExclude])];
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// A side the profile leaves empty keeps the model file's columns.
|
|
929
|
+
for (const pr of profile.relationships) {
|
|
930
|
+
const r = (out.relationships ?? []).find(x => x.name === pr.name);
|
|
931
|
+
if (!r)
|
|
932
|
+
continue;
|
|
933
|
+
if (pr.fromColumns.length)
|
|
934
|
+
r.source.columns = [...pr.fromColumns];
|
|
935
|
+
if (pr.toColumns.length)
|
|
936
|
+
r.destination.columns = [...pr.toColumns];
|
|
937
|
+
}
|
|
938
|
+
if (profile.metricsExclude !== undefined) {
|
|
939
|
+
const names = profile.metricsExclude === '*' ?
|
|
940
|
+
(out.metrics ?? []).map(m => m.name) :
|
|
941
|
+
profile.metricsExclude;
|
|
942
|
+
if (names.length) {
|
|
943
|
+
out.excludedMetrics =
|
|
944
|
+
[...new Set([...(out.excludedMetrics ?? []), ...names])];
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
// The load read each ANSI_SQL metric in the dialect the model file's sources
|
|
948
|
+
// imply, or with the generic parser when they imply none. The profile's
|
|
949
|
+
// sources can imply another dialect, so each metric with no authored entity
|
|
950
|
+
// is inferred again, as a load of the model file with these sources would
|
|
951
|
+
// infer it. A metric whose new reading does not pin exactly one entity has
|
|
952
|
+
// none.
|
|
953
|
+
const entityNames = (out.entities ?? []).map(e => e.name);
|
|
954
|
+
const impliedDialect = sourcesDialect(out);
|
|
955
|
+
for (const m of out.metrics ?? []) {
|
|
956
|
+
if (m.authoredEntity !== undefined)
|
|
957
|
+
continue;
|
|
958
|
+
const { referenced } = (0, loader_1.inferMetricEntities)(m, entityNames, preferred, impliedDialect, out.version);
|
|
959
|
+
if (referenced.length === 1) {
|
|
960
|
+
m.entity = referenced[0];
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
delete m.entity;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return { model: out, errors };
|
|
967
|
+
}
|
|
968
|
+
// The message of an error the loader's source or expression reader throws,
|
|
969
|
+
// without the prefix a model file's load gives it.
|
|
970
|
+
function readerMessage(err) {
|
|
971
|
+
return err.message.replace(/^Semantic model load error: /, '');
|
|
972
|
+
}
|
|
973
|
+
// The entities another entity extends, directly or through others: exactly
|
|
974
|
+
// those named in some other entity's `extends`.
|
|
975
|
+
function entitiesWithDescendants(model) {
|
|
976
|
+
return new Set((model.entities ?? []).flatMap(e => (e.extends ?? []).filter(p => p !== e.name)));
|
|
977
|
+
}
|
|
978
|
+
// Reads a parsed profile file into a ProfileSpec, throwing on anything a
|
|
979
|
+
// profile may not say. Shared by loadProfileFile and the loader's reading of a
|
|
980
|
+
// vanilla GOOGLE block's profiles, so the two agree on what a valid profile is.
|
|
981
|
+
function profileSpecOf(doc, profileName) {
|
|
982
|
+
const where = `profile '${profileName}'`;
|
|
983
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc) ||
|
|
984
|
+
doc.semantic_model !== undefined) {
|
|
985
|
+
throw new loader_1.SemanticModelError(`${where} is not a profile file`);
|
|
986
|
+
}
|
|
987
|
+
if (doc.version !== undefined) {
|
|
988
|
+
throw new loader_1.SemanticModelError(`${where} sets 'version'; a profile file takes its version from the ` +
|
|
989
|
+
`model file beside it, so remove the line`);
|
|
990
|
+
}
|
|
991
|
+
if (doc.datasets !== undefined) {
|
|
992
|
+
throw new loader_1.SemanticModelError(`${where} lists its bindings under 'datasets'; a profile lists them ` +
|
|
993
|
+
`under 'entities'`);
|
|
994
|
+
}
|
|
995
|
+
// The profile's own name is checked against the file name below.
|
|
996
|
+
requireKeys(doc, PROFILE_FILE_KEYS, where, false);
|
|
997
|
+
if (doc.name !== profileName) {
|
|
998
|
+
throw new loader_1.SemanticModelError(`${where} declares name '${doc.name ?? ''}', which does not match ` +
|
|
999
|
+
`its file's profile name '${profileName}'`);
|
|
1000
|
+
}
|
|
1001
|
+
const spec = {
|
|
1002
|
+
name: profileName,
|
|
1003
|
+
entities: listOf(doc.entities, `${where} 'entities'`)
|
|
1004
|
+
.map(e => profileEntity(e, where)),
|
|
1005
|
+
relationships: listOf(doc.relationships, `${where} 'relationships'`).map(r => {
|
|
1006
|
+
requireKeys(r, PROFILE_RELATIONSHIP_KEYS, `${where}: relationship`);
|
|
1007
|
+
const at = `${where}: relationship '${r.name}'`;
|
|
1008
|
+
// An empty list is rejected as a load rejects one, so an empty side
|
|
1009
|
+
// here can only mean a side the profile leaves out.
|
|
1010
|
+
for (const key of ['from_columns', 'to_columns']) {
|
|
1011
|
+
if (Array.isArray(r[key]) && !r[key].length) {
|
|
1012
|
+
throw new loader_1.SemanticModelError(`${at} '${key}' lists no column`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
// A side the profile leaves out is empty, which completeness
|
|
1016
|
+
// rejects: a named profile states both join column lists itself.
|
|
1017
|
+
const binding = {
|
|
1018
|
+
name: r.name,
|
|
1019
|
+
fromColumns: stringList(r.from_columns, `${at} 'from_columns'`),
|
|
1020
|
+
toColumns: stringList(r.to_columns, `${at} 'to_columns'`),
|
|
1021
|
+
};
|
|
1022
|
+
return binding;
|
|
1023
|
+
}),
|
|
1024
|
+
};
|
|
1025
|
+
if (doc.metrics_exclude !== undefined) {
|
|
1026
|
+
const ex = doc.metrics_exclude;
|
|
1027
|
+
if (ex !== '*' &&
|
|
1028
|
+
!(Array.isArray(ex) &&
|
|
1029
|
+
ex.every((n) => typeof n === 'string'))) {
|
|
1030
|
+
throw new loader_1.SemanticModelError(`${where}: 'metrics_exclude' must be "*" or a list of metric names`);
|
|
1031
|
+
}
|
|
1032
|
+
spec.metricsExclude = ex;
|
|
1033
|
+
}
|
|
1034
|
+
if (doc.actions !== undefined) {
|
|
1035
|
+
// Actions are out of preview scope and gain no new checks, so when a
|
|
1036
|
+
// profile lists an action twice, the last entry wins.
|
|
1037
|
+
spec.actions = listOf(doc.actions, `${where} 'actions'`).map(a => {
|
|
1038
|
+
requireKeys(a, PROFILE_ACTION_KEYS, `${where}: action`);
|
|
1039
|
+
if (a.executor === undefined)
|
|
1040
|
+
return { name: a.name };
|
|
1041
|
+
return {
|
|
1042
|
+
name: a.name,
|
|
1043
|
+
executor: a.executor === null ?
|
|
1044
|
+
null :
|
|
1045
|
+
(0, loader_1.parseExecutor)(a.executor, `${where}: action '${a.name}'`),
|
|
1046
|
+
};
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
// A repeated name, a field with nothing to bind, a field both bound and
|
|
1050
|
+
// excluded, and "*" inside a list, judged as for a profile held in memory.
|
|
1051
|
+
const problems = profileStructureErrors(spec);
|
|
1052
|
+
if (problems.length)
|
|
1053
|
+
throw new loader_1.SemanticModelError(problems[0]);
|
|
1054
|
+
return spec;
|
|
1055
|
+
}
|
|
1056
|
+
function profileEntity(e, where) {
|
|
1057
|
+
requireKeys(e, PROFILE_ENTITY_KEYS, `${where}: entity`);
|
|
1058
|
+
const at = `${where}: entity '${e.name}'`;
|
|
1059
|
+
const binding = { name: e.name };
|
|
1060
|
+
if (e.source !== undefined) {
|
|
1061
|
+
if (typeof e.source !== 'string') {
|
|
1062
|
+
throw new loader_1.SemanticModelError(`${at} 'source' must be a string`);
|
|
1063
|
+
}
|
|
1064
|
+
binding.source = e.source;
|
|
1065
|
+
}
|
|
1066
|
+
if (e.primary_key !== undefined) {
|
|
1067
|
+
binding.primaryKey = stringList(e.primary_key, `${at} 'primary_key'`);
|
|
1068
|
+
}
|
|
1069
|
+
if (e.unique_keys !== undefined) {
|
|
1070
|
+
binding.uniqueKeys = listOf(e.unique_keys, `${at} 'unique_keys'`)
|
|
1071
|
+
.map(k => stringList(k, `${at} 'unique_keys'`));
|
|
1072
|
+
}
|
|
1073
|
+
if (e.fields !== undefined) {
|
|
1074
|
+
binding.fields = listOf(e.fields, `${at} 'fields'`).map(f => {
|
|
1075
|
+
requireKeys(f, PROFILE_FIELD_KEYS, `${at}: field`);
|
|
1076
|
+
const fat = `${at}: field '${f.name}'`;
|
|
1077
|
+
return f.expression === undefined ?
|
|
1078
|
+
{ name: f.name } :
|
|
1079
|
+
{
|
|
1080
|
+
name: f.name,
|
|
1081
|
+
...(0, loader_1.parseFieldExpression)(f.expression, fat, loader_1.DEFAULT_DIALECT, e.name),
|
|
1082
|
+
};
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
if (e.fields_exclude !== undefined) {
|
|
1086
|
+
binding.fieldsExclude =
|
|
1087
|
+
stringList(e.fields_exclude, `${at} 'fields_exclude'`);
|
|
1088
|
+
}
|
|
1089
|
+
return binding;
|
|
1090
|
+
}
|
|
1091
|
+
// Checks that `obj` is a mapping carrying only `allowed` keys and, unless
|
|
1092
|
+
// `named` is false, a string `name`.
|
|
1093
|
+
function requireKeys(obj, allowed, where, named = true) {
|
|
1094
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
1095
|
+
throw new loader_1.SemanticModelError(`${where} is not a mapping`);
|
|
1096
|
+
}
|
|
1097
|
+
if (named && typeof obj.name !== 'string') {
|
|
1098
|
+
throw new loader_1.SemanticModelError(`${where} has no 'name'`);
|
|
1099
|
+
}
|
|
1100
|
+
for (const k of Object.keys(obj)) {
|
|
1101
|
+
if (!allowed.has(k)) {
|
|
1102
|
+
throw new loader_1.SemanticModelError(notAllowed(named && obj.name !== undefined ? `${where} '${obj.name}'` : where, k));
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
function listOf(value, where) {
|
|
1107
|
+
if (value === undefined)
|
|
1108
|
+
return [];
|
|
1109
|
+
if (!Array.isArray(value)) {
|
|
1110
|
+
throw new loader_1.SemanticModelError(`${where} must be a list`);
|
|
1111
|
+
}
|
|
1112
|
+
return value;
|
|
1113
|
+
}
|
|
1114
|
+
function stringList(value, where) {
|
|
1115
|
+
const list = listOf(value, where);
|
|
1116
|
+
if (!list.every(v => typeof v === 'string')) {
|
|
1117
|
+
throw new loader_1.SemanticModelError(`${where} must be a list of names`);
|
|
1118
|
+
}
|
|
1119
|
+
return list;
|
|
1120
|
+
}
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
// Whether a profile can be deployed.
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
// The dialect each system's sources infer, keyed by the
|
|
1125
|
+
// system databaseOf puts first.
|
|
1126
|
+
const DIALECT_OF_SYSTEM = {
|
|
1127
|
+
bigquery: 'BIGQUERY',
|
|
1128
|
+
spanner: 'SPANNER',
|
|
1129
|
+
alloydb: 'ALLOYDB',
|
|
1130
|
+
cloudsql_mysql: 'MYSQL',
|
|
1131
|
+
cloudsql_postgresql: 'POSTGRES',
|
|
1132
|
+
};
|
|
1133
|
+
// Each text a field's or metric's expression carries, with the dialect it is
|
|
1134
|
+
// written in.
|
|
1135
|
+
function expressionSources(f) {
|
|
1136
|
+
const out = (f.dialects ?? []).map(d => ({ text: d.expression, dialect: d.dialect }));
|
|
1137
|
+
for (const text of [f.expression, f.importedExpression]) {
|
|
1138
|
+
if (text !== undefined && !out.some(o => o.text === text)) {
|
|
1139
|
+
out.push({ text, dialect: dialectOf(f, text) });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return out;
|
|
1143
|
+
}
|
|
1144
|
+
// The texts a profile of `dialect` evaluates for a field or metric, each with
|
|
1145
|
+
// the dialect to read it in. A profile has one dialect, so it evaluates the
|
|
1146
|
+
// entry for that dialect, or the ANSI_SQL entry when there is none, and its
|
|
1147
|
+
// database runs an ANSI_SQL text as its own SQL. A field held without a
|
|
1148
|
+
// dialect list offers its imported text when that is in the profile's dialect,
|
|
1149
|
+
// and otherwise the text the loader picked. With no known dialect, every text
|
|
1150
|
+
// counts, each read in the dialect it is written in.
|
|
1151
|
+
function textsFor(f, dialect) {
|
|
1152
|
+
if (dialect === undefined)
|
|
1153
|
+
return expressionSources(f);
|
|
1154
|
+
// The text a database of this dialect runs: its own entry, or the
|
|
1155
|
+
// ANSI_SQL one.
|
|
1156
|
+
const text = (0, ir_1.expressionForDialect)(f, dialect);
|
|
1157
|
+
return text !== undefined ? [{ text, dialect }] : [];
|
|
1158
|
+
}
|
|
1159
|
+
// Whether a field or metric holds SQL but has no text in any of `dialects`
|
|
1160
|
+
// and no ANSI_SQL text, so no table of those dialects runs it.
|
|
1161
|
+
function noTextFor(f, dialects) {
|
|
1162
|
+
return (0, ir_1.isFieldBound)(f) && dialects.every(d => !textsFor(f, d).length);
|
|
1163
|
+
}
|
|
1164
|
+
// The table of `entity`, or undefined when the load does not know its
|
|
1165
|
+
// dialect. With `withUnread`, a Cloud SQL or third-party table no lookup has
|
|
1166
|
+
// read is given too, and a Cloud SQL name with no schema gives MySQL.
|
|
1167
|
+
function tableDialects(entity, withUnread) {
|
|
1168
|
+
const dialect = sourcesDialect({ entities: [entity] });
|
|
1169
|
+
if (dialect)
|
|
1170
|
+
return { name: dialect, dialects: [dialect] };
|
|
1171
|
+
if (!withUnread)
|
|
1172
|
+
return undefined;
|
|
1173
|
+
const source = (0, loader_1.writtenSource)(entity);
|
|
1174
|
+
const table = (0, loader_1.cloudSqlTableOf)(source);
|
|
1175
|
+
if (table) {
|
|
1176
|
+
// Only a MySQL table's name has no schema.
|
|
1177
|
+
const mysql = DIALECT_OF_SYSTEM.cloudsql_mysql;
|
|
1178
|
+
if (!table.inSchema)
|
|
1179
|
+
return { name: mysql, dialects: [mysql] };
|
|
1180
|
+
const dialects = [...new Set(Object.values(loader_1.DIALECT_OF_ENTRY_TYPE))].filter(d => d !== mysql);
|
|
1181
|
+
return { name: 'Cloud SQL', dialects, unread: true };
|
|
1182
|
+
}
|
|
1183
|
+
if ((0, loader_1.thirdPartyEntryOf)(source)) {
|
|
1184
|
+
const dialects = [...loader_1.DIALECT_OF_FQN_PREFIX.values()];
|
|
1185
|
+
return { name: 'third-party', dialects, unread: true };
|
|
1186
|
+
}
|
|
1187
|
+
return undefined;
|
|
1188
|
+
}
|
|
1189
|
+
// Each field of each entity whose table `tableDialects(entity, withUnread)`
|
|
1190
|
+
// gives, with that table. An inherited field is listed on each entity that
|
|
1191
|
+
// inherits it.
|
|
1192
|
+
function fieldsOnTables(model, withUnread = false) {
|
|
1193
|
+
return resolvedEntities(model).flatMap(entity => {
|
|
1194
|
+
const table = tableDialects(entity, withUnread);
|
|
1195
|
+
if (!table)
|
|
1196
|
+
return [];
|
|
1197
|
+
return (entity.fields ?? []).map(field => ({ entity, field, table }));
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
// The import command that adds BigQuery SQL to a model file. Push reads only
|
|
1201
|
+
// files written for kcmd, so `--input first-party` fits each of them.
|
|
1202
|
+
exports.IMPORT_TRANSPILE_COMMAND = 'kcmd import <file> --input first-party --flavor <ossie|google> --transpile';
|
|
1203
|
+
// Whether `kcmd import --transpile` gives BigQuery SQL to a field or metric
|
|
1204
|
+
// with no text for `dialect`: true when `dialect` is BIGQUERY and its only
|
|
1205
|
+
// text is an imported one, in a dialect the translator reads.
|
|
1206
|
+
function importTranspiles(f, dialect) {
|
|
1207
|
+
return dialect === 'BIGQUERY' && !!f.importedExpression &&
|
|
1208
|
+
(0, transpile_1.transpilesFrom)(f.importedDialect ?? '');
|
|
1209
|
+
}
|
|
1210
|
+
// The dialect a model's sources imply: that of the one database every concrete
|
|
1211
|
+
// entity reads from, or undefined when they name several, or a source names
|
|
1212
|
+
// none. A legacy bare name, which the loader completes into
|
|
1213
|
+
// `project.dataset.table` in `dataSource`, names BigQuery however many parts it
|
|
1214
|
+
// was written with.
|
|
1215
|
+
function sourcesDialect(model) {
|
|
1216
|
+
return dialectOfSources((model.entities ?? [])
|
|
1217
|
+
.filter(e => !e.abstract)
|
|
1218
|
+
.map(e => ({
|
|
1219
|
+
source: (0, loader_1.writtenSource)(e),
|
|
1220
|
+
lookedUp: e.sourceDialect,
|
|
1221
|
+
})));
|
|
1222
|
+
}
|
|
1223
|
+
// The dialect every one of `sources` implies, or undefined when one names no
|
|
1224
|
+
// database in an accepted form or two imply different dialects. Two databases
|
|
1225
|
+
// of one system, such as two Spanner databases, imply that system's dialect.
|
|
1226
|
+
// A three-part dotted name, which is how the loader completes a legacy bare
|
|
1227
|
+
// name, names BigQuery.
|
|
1228
|
+
//
|
|
1229
|
+
// A Cloud SQL table written as its resource name, and a third-party table,
|
|
1230
|
+
// imply the dialect their catalog entry gives, which a lookup read into
|
|
1231
|
+
// `lookedUp` (LoadOptions.sourceDialects).
|
|
1232
|
+
function dialectOfSources(sources) {
|
|
1233
|
+
const dialects = new Set(sources.map(({ source, lookedUp }) => {
|
|
1234
|
+
const s = source.trim();
|
|
1235
|
+
if ((0, loader_1.dialectLookupKey)(s))
|
|
1236
|
+
return lookedUp;
|
|
1237
|
+
const db = (0, loader_1.databaseOf)(s) ??
|
|
1238
|
+
(/^[^./:\s`]+\.[^./:\s`]+\.[^./:\s`]+$/.test(s) ? 'bigquery' :
|
|
1239
|
+
undefined);
|
|
1240
|
+
return db ? DIALECT_OF_SYSTEM[db.split('/')[0]] : undefined;
|
|
1241
|
+
}));
|
|
1242
|
+
if (dialects.size !== 1)
|
|
1243
|
+
return undefined;
|
|
1244
|
+
return [...dialects][0];
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Returns one message per reason `profile` cannot be deployed against
|
|
1248
|
+
* `baseModel`, or an empty list. Checks a single profile: nothing unknown,
|
|
1249
|
+
* every field it binds holding SQL on each entity that inherits it, every
|
|
1250
|
+
* concrete entity bound and all in one database, its own keys where the model
|
|
1251
|
+
* file states keys, every relationship bound, every field the model file
|
|
1252
|
+
* leaves unbound accounted for, every field whose expression reads a column
|
|
1253
|
+
* restated or excluded, and exclusions closed under dependency, metrics
|
|
1254
|
+
* included. The check that join columns cover a key of the target is in
|
|
1255
|
+
* validateProfileConsistency. An expression the SQL parser cannot read
|
|
1256
|
+
* is skipped by the rules that read references, with a message in `warnings`.
|
|
1257
|
+
* `opts` says what the caller checks itself, so that nothing is reported
|
|
1258
|
+
* twice.
|
|
1259
|
+
*/
|
|
1260
|
+
function validateProfileCompleteness(baseModel, profile, warnings = [], opts = {}) {
|
|
1261
|
+
const errors = [];
|
|
1262
|
+
// Each field or metric whose SQL the parser cannot read, reported once, by
|
|
1263
|
+
// the label fieldLabel or metricLabel gives it.
|
|
1264
|
+
const unreadable = new Set();
|
|
1265
|
+
const at = `profile '${profile.name}'`;
|
|
1266
|
+
// What a profile file could not say, for a profile built in memory, and
|
|
1267
|
+
// every name the model does not take, as applyProfile judges them.
|
|
1268
|
+
errors.push(...profileProblems(baseModel, profile));
|
|
1269
|
+
const entities = baseModel.entities ?? [];
|
|
1270
|
+
const byName = new Map(entities.map(e => [e.name, e]));
|
|
1271
|
+
// Declared plus inherited fields, in the model file and as this profile sees
|
|
1272
|
+
// the model: the profile applied as push applies it, and each excluded field
|
|
1273
|
+
// left off the entity that names it. Push reports a broken hierarchy in the
|
|
1274
|
+
// model file. A profile breaks the hierarchy itself when it binds a field on
|
|
1275
|
+
// two unrelated branches. That is reported here once the model file's
|
|
1276
|
+
// hierarchy resolves. The rules below still run on a broken hierarchy, apart
|
|
1277
|
+
// from the check that each inherited field holds SQL, which push also skips
|
|
1278
|
+
// then. An entity whose own ancestors do not resolve shows only its declared
|
|
1279
|
+
// fields, so the rules that need what it inherits leave it out.
|
|
1280
|
+
const fieldsOf = fieldsByEntity(baseModel);
|
|
1281
|
+
// The entities whose own ancestors do not resolve in the model file, so
|
|
1282
|
+
// what they inherit is not known.
|
|
1283
|
+
const unknownAncestry = unresolvedEntities(baseModel);
|
|
1284
|
+
const { model: profiled, errors: unread } = applyBindings(baseModel, profile, { load: { sourceDialects: opts.sourceDialects } });
|
|
1285
|
+
errors.push(...unread.map(e => `${at}: ${e}`));
|
|
1286
|
+
const profiledError = inheritanceErrorOf(profiled);
|
|
1287
|
+
if (profiledError && !inheritanceErrorOf(baseModel)) {
|
|
1288
|
+
errors.push(`${at}: ${profiledError}`);
|
|
1289
|
+
}
|
|
1290
|
+
// A field the profile binds is checked on each entity that inherits it, as
|
|
1291
|
+
// applyProfile checks it. A binding the reader rejects only for holding no
|
|
1292
|
+
// SQL is reported where the profile writes it and left unapplied above.
|
|
1293
|
+
// Applied here, it is also reported on an entity where its expression, read
|
|
1294
|
+
// as a document writes it, has a further problem.
|
|
1295
|
+
if (!profiledError) {
|
|
1296
|
+
const asWritten = applyBindings(baseModel, profile, { noSqlAsWritten: true }).model;
|
|
1297
|
+
const checked = inheritanceErrorOf(asWritten) ? profiled : asWritten;
|
|
1298
|
+
const inherited = (0, loader_1.inheritedNoSqlProblems)(checked.entities, (0, resolve_inheritance_1.declaredConceptFields)(checked));
|
|
1299
|
+
errors.push(...inherited.map(p => `${at}: ${p}`));
|
|
1300
|
+
}
|
|
1301
|
+
const seenOf = fieldsByEntity(profiled);
|
|
1302
|
+
const bindingOf = new Map(profile.entities.map(e => [e.name, e]));
|
|
1303
|
+
// Every concrete entity bound, all in one database. The profile's dialect
|
|
1304
|
+
// is the one its own sources imply, so a source the model file states for an
|
|
1305
|
+
// entity the profile leaves out does not decide it.
|
|
1306
|
+
const databases = new Map();
|
|
1307
|
+
const stated = [];
|
|
1308
|
+
const profiledByName = new Map((profiled.entities ?? []).map(e => [e.name, e]));
|
|
1309
|
+
// The entities whose source the loader's reader rejects.
|
|
1310
|
+
const rejected = new Set();
|
|
1311
|
+
for (const e of entities) {
|
|
1312
|
+
if (e.abstract)
|
|
1313
|
+
continue;
|
|
1314
|
+
const pe = bindingOf.get(e.name);
|
|
1315
|
+
if (pe?.source === undefined) {
|
|
1316
|
+
errors.push(`${at}: entity '${e.name}' has no source in this profile`);
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
// A source the loader's reader rejects is already reported, and its
|
|
1320
|
+
// entity keeps the model file's table and dialect. databaseOf still reads
|
|
1321
|
+
// some rejected sources, such as one with a leading `//`. An entity whose
|
|
1322
|
+
// source is rejected or names no database is left out of the one-database
|
|
1323
|
+
// and dialect rules.
|
|
1324
|
+
const db = (0, loader_1.databaseOf)(pe.source.trim());
|
|
1325
|
+
const profiledEntity = profiledByName.get(e.name);
|
|
1326
|
+
if (!db || profiledEntity?.authoredSource !== pe.source.trim()) {
|
|
1327
|
+
rejected.add(e.name);
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
const lookedUp = profiledEntity.sourceDialect;
|
|
1331
|
+
stated.push({ source: pe.source, lookedUp });
|
|
1332
|
+
// One query reaches every database of a SQL Server instance. A third-party
|
|
1333
|
+
// table is in the database its entry's fullyQualifiedName names.
|
|
1334
|
+
const table = lookedUp === 'SQLSERVER' && (0, loader_1.cloudSqlTableOf)(pe.source);
|
|
1335
|
+
const key = table ? table.resourceName.split('/databases/')[0] :
|
|
1336
|
+
db !== 'thirdparty' ? db :
|
|
1337
|
+
opts.sourceDatabases?.get(pe.source.trim()) ?? `thirdparty/${lookedUp}`;
|
|
1338
|
+
if (!databases.has(key))
|
|
1339
|
+
databases.set(key, e.name);
|
|
1340
|
+
}
|
|
1341
|
+
// A third-party table whose entry names no database is in the database of
|
|
1342
|
+
// the other tables of its system.
|
|
1343
|
+
for (const [prefix, systemDialect] of loader_1.DIALECT_OF_FQN_PREFIX) {
|
|
1344
|
+
if ([...databases.keys()].some(k => k.startsWith(`${prefix}/`))) {
|
|
1345
|
+
databases.delete(`thirdparty/${systemDialect}`);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
if (databases.size > 1) {
|
|
1349
|
+
const named = [...databases.values()].map(n => `'${n}'`).join(', ');
|
|
1350
|
+
errors.push(`${at} binds entities in more than one database (${named}); a ` +
|
|
1351
|
+
`profile reads from one system a single query can reach`);
|
|
1352
|
+
}
|
|
1353
|
+
const dialect = dialectOfSources(stated);
|
|
1354
|
+
// The expressions of each entity the profile may bind.
|
|
1355
|
+
for (const pe of profile.entities) {
|
|
1356
|
+
const e = byName.get(pe.name);
|
|
1357
|
+
if (!e || e.abstract)
|
|
1358
|
+
continue;
|
|
1359
|
+
const known = fieldsOf.get(pe.name);
|
|
1360
|
+
// A profile field's expression may be anything the model file's could be:
|
|
1361
|
+
// its own table's columns and its own entity's fields, never another
|
|
1362
|
+
// entity's fields, with each qualifier in its entity's case.
|
|
1363
|
+
// A name after a dot is a struct path segment, such as `customer` in
|
|
1364
|
+
// `details.customer.id`, and reads no entity.
|
|
1365
|
+
// These rules check every text the profile wrote, since each must be a
|
|
1366
|
+
// valid field expression wherever it runs; the dependency rules below read
|
|
1367
|
+
// only the text the profile's dialect evaluates. An ANSI_SQL text is read
|
|
1368
|
+
// as the profile's own SQL.
|
|
1369
|
+
const others = entities.map(x => x.name).filter(n => n !== pe.name);
|
|
1370
|
+
for (const pf of pe.fields ?? []) {
|
|
1371
|
+
// A text with no SQL is reported above, and has nothing to read.
|
|
1372
|
+
const readable = expressionSources(pf).filter(s => (0, loader_1.noSqlReason)(s.text, s.dialect, pe.name) === undefined);
|
|
1373
|
+
for (const { text, dialect: written } of readable) {
|
|
1374
|
+
const columns = (0, sql_expr_utils_1.columnReferences)(text, written === 'ANSI_SQL' && dialect ? dialect : written);
|
|
1375
|
+
if (!columns) {
|
|
1376
|
+
unreadable.add(fieldLabel(pe.name, pf.name));
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
for (const other of others) {
|
|
1380
|
+
if ((0, sql_expr_utils_1.fieldsReadOn)(columns, other).length) {
|
|
1381
|
+
errors.push(`${at}: field '${pe.name}.${pf.name}' reads entity '${other}'; a field expression reads only its own entity`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
errors.push(...new Set(columns.flatMap(c => (0, names_1.qualifierCaseError)(`${at}: field '${pe.name}.${pf.name}'`, c, entities.map(x => x.name)) ?? [])));
|
|
1385
|
+
for (const ref of (0, sql_expr_utils_1.fieldsReadOn)(columns, pe.name)) {
|
|
1386
|
+
if (!known.has(ref) && !unknownAncestry.has(pe.name)) {
|
|
1387
|
+
errors.push(`${at}: field '${pe.name}.${pf.name}' reads '${pe.name}.${ref}', which is not a field of '${pe.name}'`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
// A profile's key columns are physical column names.
|
|
1394
|
+
for (const pe of profile.entities) {
|
|
1395
|
+
const keyColumns = [...(pe.primaryKey ?? []), ...(pe.uniqueKeys ?? []).flat()];
|
|
1396
|
+
const bad = keyColumns.filter(c => !(0, sql_expr_utils_1.isColumnName)(c));
|
|
1397
|
+
if (bad.length) {
|
|
1398
|
+
errors.push(`${at}: entity '${pe.name}' key column ${bad.map(c => `'${c}'`).join(', ')} is not a physical column ` +
|
|
1399
|
+
`name`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
// The profile's own keys, where the model file states keys: those name
|
|
1403
|
+
// columns of the model file's table.
|
|
1404
|
+
for (const e of entities) {
|
|
1405
|
+
const pe = bindingOf.get(e.name);
|
|
1406
|
+
if (!pe || e.abstract)
|
|
1407
|
+
continue;
|
|
1408
|
+
const missing = [];
|
|
1409
|
+
if (e.keys.length && !pe.primaryKey?.length)
|
|
1410
|
+
missing.push('primary_key');
|
|
1411
|
+
if (nonEmptyKeys(e.uniqueKeys).length &&
|
|
1412
|
+
!nonEmptyKeys(pe.uniqueKeys).length) {
|
|
1413
|
+
missing.push('unique_keys');
|
|
1414
|
+
}
|
|
1415
|
+
if (missing.length) {
|
|
1416
|
+
errors.push(`${at}: entity '${e.name}' has no ${missing.join(' or ')} in this ` +
|
|
1417
|
+
`profile; the model file's keys are columns of its own table, so ` +
|
|
1418
|
+
`state this profile's keys`);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
// Every relationship bound, by the profile itself: a named profile states
|
|
1422
|
+
// both join column lists, and the model file's do not stand in for them.
|
|
1423
|
+
for (const r of baseModel.relationships ?? []) {
|
|
1424
|
+
if (r.association)
|
|
1425
|
+
continue;
|
|
1426
|
+
const pr = profile.relationships.find(x => x.name === r.name);
|
|
1427
|
+
const from = pr?.fromColumns ?? [];
|
|
1428
|
+
const to = pr?.toColumns ?? [];
|
|
1429
|
+
if (!from.length || !to.length) {
|
|
1430
|
+
errors.push(`${at}: ${profileJoinMessage(r.name)}`);
|
|
1431
|
+
}
|
|
1432
|
+
else if (from.length !== to.length) {
|
|
1433
|
+
errors.push(`${at}: ${(0, loader_1.joinLengthMessage)(r.name, from.length, to.length)}`);
|
|
1434
|
+
}
|
|
1435
|
+
else {
|
|
1436
|
+
const bad = [...from, ...to].filter(c => !(0, sql_expr_utils_1.isColumnName)(c));
|
|
1437
|
+
if (bad.length) {
|
|
1438
|
+
errors.push(`${at}: relationship '${r.name}' join column ${bad.map(c => `'${c}'`).join(', ')} is not a physical ` +
|
|
1439
|
+
`column name`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
// Every field the model file leaves unbound is accounted for, and exclusions
|
|
1444
|
+
// are closed under dependency. An exclusion applies to the entity that names
|
|
1445
|
+
// it, not to that entity's descendants.
|
|
1446
|
+
const excluded = new Set();
|
|
1447
|
+
// The entities whose own ancestors do not resolve with the profile applied.
|
|
1448
|
+
// A line on one that restates an inherited field is not judged unbound.
|
|
1449
|
+
const unknownProfiled = unresolvedEntities(profiled);
|
|
1450
|
+
for (const e of entities) {
|
|
1451
|
+
if (e.abstract)
|
|
1452
|
+
continue;
|
|
1453
|
+
const pe = bindingOf.get(e.name);
|
|
1454
|
+
for (const name of pe?.fieldsExclude ?? []) {
|
|
1455
|
+
excluded.add(`${e.name}.${name}`);
|
|
1456
|
+
}
|
|
1457
|
+
// A field the profile lists in `fields`, on this entity or on a concrete
|
|
1458
|
+
// ancestor that has the field, is bound by it. One whose expression the
|
|
1459
|
+
// reader rejected is reported once, as that, where the profile lists it.
|
|
1460
|
+
const listedOn = [e, ...ancestorsOf(e.name, byName).filter(a => !a.abstract)];
|
|
1461
|
+
const rebound = new Set(listedOn.flatMap(x => (bindingOf.get(x.name)?.fields ?? [])
|
|
1462
|
+
.map(f => f.name)
|
|
1463
|
+
.filter(n => x === e || fieldsOf.get(x.name)?.has(n))));
|
|
1464
|
+
for (const f of seenOf.get(e.name)?.values() ?? []) {
|
|
1465
|
+
if (!(0, ir_1.isFieldBound)(f) && !rebound.has(f.name) &&
|
|
1466
|
+
!(unknownProfiled.has(e.name) &&
|
|
1467
|
+
inheritedNamesOf(profiled, e.name).has(f.name))) {
|
|
1468
|
+
errors.push(`${at}: field '${e.name}.${f.name}' has no binding in the model ` +
|
|
1469
|
+
`file; bind it in 'fields' or leave it out in 'fields_exclude'`);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
// A profile restates or excludes each field whose binding reads a column,
|
|
1474
|
+
// since that column belongs to another table. seenOf leaves out excluded
|
|
1475
|
+
// fields.
|
|
1476
|
+
for (const e of entities) {
|
|
1477
|
+
const pe = bindingOf.get(e.name);
|
|
1478
|
+
if (!pe || e.abstract || unknownProfiled.has(e.name))
|
|
1479
|
+
continue;
|
|
1480
|
+
const restated = new Set((pe.fields ?? []).map(f => f.name));
|
|
1481
|
+
for (const name of seenOf.get(e.name)?.keys() ?? []) {
|
|
1482
|
+
const read = !restated.has(name) &&
|
|
1483
|
+
columnBinding(profiled, e.name, name, dialect);
|
|
1484
|
+
if (!read)
|
|
1485
|
+
continue;
|
|
1486
|
+
const through = read.owner === e.name ?
|
|
1487
|
+
'its model file expression' :
|
|
1488
|
+
`the expression it inherits from '${read.owner}'`;
|
|
1489
|
+
errors.push(`${at}: field '${e.name}.${name}' reads column '${read.column}' ` +
|
|
1490
|
+
`through ${through}; bind it in 'fields' for this profile, or ` +
|
|
1491
|
+
`leave it out in 'fields_exclude'`);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
// Each field or metric, as "field 'Entity.field'" or "metric 'name'", with
|
|
1495
|
+
// a text this check cannot read that push reads in another dialect: an
|
|
1496
|
+
// ANSI_SQL text, which push reads in its own dialect. Push reads every
|
|
1497
|
+
// other entry in the dialect it is written in, as this check does.
|
|
1498
|
+
const readOtherwise = new Set();
|
|
1499
|
+
const noteUnread = (what, owner, text) => {
|
|
1500
|
+
const asWritten = text.dialect !== 'ANSI_SQL' &&
|
|
1501
|
+
(owner.dialects ?? []).some(d => d.dialect === text.dialect && d.expression === text.text);
|
|
1502
|
+
// The caller warns about an ANSI_SQL text it cannot read either.
|
|
1503
|
+
const callerFails = !!opts.ownSqlOnly &&
|
|
1504
|
+
!(0, sql_expr_utils_1.columnsForSource)(text.text, 'ANSI_SQL', opts.ownSqlOnly.dialect);
|
|
1505
|
+
if (!asWritten && !callerFails)
|
|
1506
|
+
readOtherwise.add(what);
|
|
1507
|
+
};
|
|
1508
|
+
// Every bound field, and every metric the profile keeps, has a text this
|
|
1509
|
+
// profile's database can run, checked once its sources name one database.
|
|
1510
|
+
if (dialect) {
|
|
1511
|
+
for (const e of entities) {
|
|
1512
|
+
if (e.abstract || rejected.has(e.name))
|
|
1513
|
+
continue;
|
|
1514
|
+
for (const f of seenOf.get(e.name)?.values() ?? []) {
|
|
1515
|
+
// validateInlineDialects reports a field inherited unchanged.
|
|
1516
|
+
const asInModelFile = fieldsOf.get(e.name)?.get(f.name);
|
|
1517
|
+
const reported = !!asInModelFile &&
|
|
1518
|
+
JSON.stringify(expressionSources(f)) ===
|
|
1519
|
+
JSON.stringify(expressionSources(asInModelFile)) &&
|
|
1520
|
+
sourcesDialect({ entities: [e] }) === dialect;
|
|
1521
|
+
if (noTextFor(f, [dialect]) && !reported) {
|
|
1522
|
+
errors.push(`${at}: field '${e.name}.${f.name}' has no expression for ${dialect} or ANSI_SQL, and this profile's sources are ${dialect}`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (profile.metricsExclude !== '*') {
|
|
1527
|
+
const excludedMetrics = new Set(profile.metricsExclude ?? []);
|
|
1528
|
+
for (const m of baseModel.metrics ?? []) {
|
|
1529
|
+
if (!excludedMetrics.has(m.name) && noTextFor(m, [dialect])) {
|
|
1530
|
+
const viaImport = importTranspiles(m, dialect) ?
|
|
1531
|
+
` add one with '${exports.IMPORT_TRANSPILE_COMMAND}',` : '';
|
|
1532
|
+
errors.push(`${at}: metric '${m.name}' has no expression for ${dialect} or ` +
|
|
1533
|
+
`ANSI_SQL, and this profile's sources are ${dialect}; give it ` +
|
|
1534
|
+
`one,${viaImport} or add it to 'metrics_exclude'`);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
// Each field that depends on an excluded one, and the excluded field it
|
|
1540
|
+
// reaches.
|
|
1541
|
+
const dangling = new Map();
|
|
1542
|
+
let grew = true;
|
|
1543
|
+
while (grew) {
|
|
1544
|
+
grew = false;
|
|
1545
|
+
for (const e of entities) {
|
|
1546
|
+
if (e.abstract)
|
|
1547
|
+
continue;
|
|
1548
|
+
for (const f of seenOf.get(e.name)?.values() ?? []) {
|
|
1549
|
+
const key = `${e.name}.${f.name}`;
|
|
1550
|
+
if (excluded.has(key) || dangling.has(key))
|
|
1551
|
+
continue;
|
|
1552
|
+
// A field reads only its own entity's fields. An
|
|
1553
|
+
// inherited field is read in the form its declaring ancestor wrote it,
|
|
1554
|
+
// because inheritance strips the qualifier when it copies one down.
|
|
1555
|
+
for (const dep of fieldDependencies(profiled, e.name, f.name, unreadable, dialect, noteUnread)) {
|
|
1556
|
+
const target = `${e.name}.${dep}`;
|
|
1557
|
+
if (excluded.has(target) || dangling.has(target)) {
|
|
1558
|
+
dangling.set(key, dangling.get(target) ?? target);
|
|
1559
|
+
grew = true;
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
for (const [field, reached] of dangling) {
|
|
1567
|
+
errors.push(`${at}: field '${field}' depends on '${reached}', which this ` +
|
|
1568
|
+
`profile excludes; exclude '${field}' too, or rebind it`);
|
|
1569
|
+
}
|
|
1570
|
+
const why = (target) => excluded.has(target) ?
|
|
1571
|
+
'which this profile excludes' :
|
|
1572
|
+
`which depends on '${dangling.get(target)}', an excluded field`;
|
|
1573
|
+
if (profile.metricsExclude !== '*') {
|
|
1574
|
+
const metricExcluded = new Set(profile.metricsExclude ?? []);
|
|
1575
|
+
for (const m of baseModel.metrics ?? []) {
|
|
1576
|
+
if (metricExcluded.has(m.name))
|
|
1577
|
+
continue;
|
|
1578
|
+
const read = [];
|
|
1579
|
+
for (const text of textsFor(m, dialect)) {
|
|
1580
|
+
// A text with no SQL has nothing to read.
|
|
1581
|
+
if ((0, loader_1.noSqlReason)(text.text, text.dialect) !== undefined)
|
|
1582
|
+
continue;
|
|
1583
|
+
const columns = (0, sql_expr_utils_1.columnReferences)(text.text, text.dialect);
|
|
1584
|
+
if (columns) {
|
|
1585
|
+
read.push(columns);
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
unreadable.add(metricLabel(m.name));
|
|
1589
|
+
noteUnread(metricLabel(m.name), m, text);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
const reached = [...excluded, ...dangling.keys()].filter(target => {
|
|
1593
|
+
const [te, tf] = target.split('.');
|
|
1594
|
+
return read.some(columns => (0, sql_expr_utils_1.fieldsReadOn)(columns, te).includes(tf));
|
|
1595
|
+
});
|
|
1596
|
+
if (reached.length) {
|
|
1597
|
+
errors.push(`${at}: metric '${m.name}' reaches ${reached.map(r => `'${r}', ${why(r)}`).join('; ')}; add '${m.name}' to 'metrics_exclude'`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
// The fields this profile binds, whose SQL the profile writes.
|
|
1602
|
+
const own = new Set(profile.entities.flatMap(pe => (pe.fields ?? []).map(pf => fieldLabel(pe.name, pf.name))));
|
|
1603
|
+
// Whether the caller warns itself about this model file SQL.
|
|
1604
|
+
const caller = opts.ownSqlOnly;
|
|
1605
|
+
const callerWarns = (what) => !!caller &&
|
|
1606
|
+
(caller.dialect === dialect || !readOtherwise.has(what));
|
|
1607
|
+
for (const what of unreadable) {
|
|
1608
|
+
if (!own.has(what) && callerWarns(what)) {
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
warnings.push(`${at}: kcmd could not read the SQL of ${what}, so the fields it ` +
|
|
1612
|
+
`reads were not checked`);
|
|
1613
|
+
}
|
|
1614
|
+
return errors;
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Returns one message for each binding, the model file's included, whose
|
|
1618
|
+
* `to_columns` for a relationship cover no primary or unique key of its
|
|
1619
|
+
* target, or an empty list. A superset of a key covers it (see coversKey).
|
|
1620
|
+
* Keys and join columns are compared as written. A binding whose key or join
|
|
1621
|
+
* columns are not all column names (isColumnName) is left out. `applied` names
|
|
1622
|
+
* the profile the push applies. The "covers no key" check then leaves out that
|
|
1623
|
+
* profile's join, or the model file's when the profile states none.
|
|
1624
|
+
*/
|
|
1625
|
+
function validateProfileConsistency(baseModel, profiles, applied) {
|
|
1626
|
+
const errors = [];
|
|
1627
|
+
const entities = baseModel.entities ?? [];
|
|
1628
|
+
const byName = new Map(entities.map(e => [e.name, e]));
|
|
1629
|
+
for (const r of baseModel.relationships ?? []) {
|
|
1630
|
+
if (r.association)
|
|
1631
|
+
continue;
|
|
1632
|
+
const target = byName.get(r.destination.entity);
|
|
1633
|
+
if (!target || target.abstract)
|
|
1634
|
+
continue;
|
|
1635
|
+
const appliedJoin = profiles.find(p => p.name === applied)
|
|
1636
|
+
?.relationships.find(x => x.name === r.name);
|
|
1637
|
+
// Push's own key check reports this binding's join, so it is left out here.
|
|
1638
|
+
const checkedByPush = applied === undefined ? undefined :
|
|
1639
|
+
appliedJoin?.toColumns.length ? `profile '${applied}'` :
|
|
1640
|
+
'the model file';
|
|
1641
|
+
// A binding that states no keys is checked against the model file's,
|
|
1642
|
+
// since completeness reports the missing keys. A binding with no join
|
|
1643
|
+
// columns is left out; completeness reports it for a named profile.
|
|
1644
|
+
const record = (who, to, pk, uks) => {
|
|
1645
|
+
// The column name checks report a value that is not a column name.
|
|
1646
|
+
if (!to.length || ![...to, ...pk, ...uks.flat()].every(sql_expr_utils_1.isColumnName)) {
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
if (!(0, ir_1.coversKey)(to, pk, uks) && who !== checkedByPush) {
|
|
1650
|
+
errors.push(`relationship '${r.name}': ${who} joins on to_columns ${JSON.stringify(to)}, which cover no primary or unique key ` +
|
|
1651
|
+
`of '${target.name}'`);
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
record('the model file', r.destination.columns, target.keys, target.uniqueKeys ?? []);
|
|
1655
|
+
for (const p of profiles) {
|
|
1656
|
+
const pr = p.relationships.find(x => x.name === r.name);
|
|
1657
|
+
const pe = p.entities.find(e => e.name === target.name);
|
|
1658
|
+
record(`profile '${p.name}'`, pr?.toColumns ?? [], pe?.primaryKey?.length ? pe.primaryKey : target.keys, nonEmptyKeys(pe?.uniqueKeys).length ? pe.uniqueKeys :
|
|
1659
|
+
target.uniqueKeys ?? []);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
return errors;
|
|
1663
|
+
}
|
|
1664
|
+
// Each entity's fields, declared plus inherited, by name. Resolution throws on a
|
|
1665
|
+
// broken hierarchy, which push reports elsewhere. Each entity is then resolved
|
|
1666
|
+
// with only its own ancestors, and one whose own ancestors do not resolve
|
|
1667
|
+
// keeps its declared fields.
|
|
1668
|
+
function fieldsByEntity(model) {
|
|
1669
|
+
let resolved = model.entities ?? [];
|
|
1670
|
+
if (resolved.some(e => e.extends?.length)) {
|
|
1671
|
+
try {
|
|
1672
|
+
resolved = (0, resolve_inheritance_1.resolveInheritance)(model).model.entities ?? resolved;
|
|
1673
|
+
}
|
|
1674
|
+
catch (err) {
|
|
1675
|
+
// Anything but a broken hierarchy is a bug.
|
|
1676
|
+
if (!(err instanceof resolve_inheritance_1.InheritanceError))
|
|
1677
|
+
throw err;
|
|
1678
|
+
resolved = resolved.map(e => resolvedAlone(model, e.name) ?? e);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
// Resolution already leaves excluded fields off; this covers a model with no
|
|
1682
|
+
// inheritance, which is not resolved, and a broken one.
|
|
1683
|
+
return new Map(resolved.map(e => {
|
|
1684
|
+
const excluded = new Set(e.excludedFields ?? []);
|
|
1685
|
+
return [
|
|
1686
|
+
e.name,
|
|
1687
|
+
new Map(e.fields.filter(f => !excluded.has(f.name)).map(f => [f.name, f])),
|
|
1688
|
+
];
|
|
1689
|
+
}));
|
|
1690
|
+
}
|
|
1691
|
+
// The entity `name` resolved with only its ancestors, or undefined when its
|
|
1692
|
+
// own ancestors do not resolve.
|
|
1693
|
+
function resolvedAlone(model, name) {
|
|
1694
|
+
const byName = new Map((model.entities ?? []).map(e => [e.name, e]));
|
|
1695
|
+
const line = new Set();
|
|
1696
|
+
const queue = [name];
|
|
1697
|
+
while (queue.length) {
|
|
1698
|
+
const n = queue.shift();
|
|
1699
|
+
if (line.has(n))
|
|
1700
|
+
continue;
|
|
1701
|
+
line.add(n);
|
|
1702
|
+
queue.push(...(byName.get(n)?.extends ?? []));
|
|
1703
|
+
}
|
|
1704
|
+
try {
|
|
1705
|
+
const entities = (model.entities ?? []).filter(e => line.has(e.name));
|
|
1706
|
+
return (0, resolve_inheritance_1.resolveInheritance)({ ...model, entities })
|
|
1707
|
+
.model.entities?.find(e => e.name === name);
|
|
1708
|
+
}
|
|
1709
|
+
catch (err) {
|
|
1710
|
+
if (!(err instanceof resolve_inheritance_1.InheritanceError))
|
|
1711
|
+
throw err;
|
|
1712
|
+
return undefined;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
// The entities of `model` whose own ancestors do not resolve, so what they
|
|
1716
|
+
// inherit is not known. Empty when the hierarchy resolves.
|
|
1717
|
+
function unresolvedEntities(model) {
|
|
1718
|
+
if (!inheritanceErrorOf(model))
|
|
1719
|
+
return new Set();
|
|
1720
|
+
return new Set((model.entities ?? [])
|
|
1721
|
+
.filter(e => e.extends?.length &&
|
|
1722
|
+
!resolvedAlone(model, e.name))
|
|
1723
|
+
.map(e => e.name));
|
|
1724
|
+
}
|
|
1725
|
+
// The message of the inheritance error resolving `model` throws, or undefined
|
|
1726
|
+
// when it resolves.
|
|
1727
|
+
function inheritanceErrorOf(model) {
|
|
1728
|
+
if (!(model.entities ?? []).some(e => e.extends?.length))
|
|
1729
|
+
return undefined;
|
|
1730
|
+
try {
|
|
1731
|
+
(0, resolve_inheritance_1.resolveInheritance)(model);
|
|
1732
|
+
return undefined;
|
|
1733
|
+
}
|
|
1734
|
+
catch (err) {
|
|
1735
|
+
if (!(err instanceof resolve_inheritance_1.InheritanceError))
|
|
1736
|
+
throw err;
|
|
1737
|
+
return err.message;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
// How a warning names a field, "field 'Entity.field'". The checks that skip a
|
|
1741
|
+
// warning compare these labels, so each label comes from here.
|
|
1742
|
+
function fieldLabel(entity, field) {
|
|
1743
|
+
return `field '${entity}.${field}'`;
|
|
1744
|
+
}
|
|
1745
|
+
// How a warning names a metric, "metric 'name'".
|
|
1746
|
+
function metricLabel(name) {
|
|
1747
|
+
return `metric '${name}'`;
|
|
1748
|
+
}
|
|
1749
|
+
// The declaration of `field` whose binding `entityName` uses, and the entity
|
|
1750
|
+
// that declares it: the entity's own when it binds the field, otherwise, of
|
|
1751
|
+
// the ancestors that bind it, the one with the most ancestors. Undefined when
|
|
1752
|
+
// none binds it.
|
|
1753
|
+
function boundDeclaration(model, entityName, field) {
|
|
1754
|
+
const byName = new Map((model.entities ?? []).map(e => [e.name, e]));
|
|
1755
|
+
const bindingOn = (name) => byName.get(name)?.fields.find(f => f.name === field && (0, ir_1.isFieldBound)(f));
|
|
1756
|
+
let owner = bindingOn(entityName) ? entityName : undefined;
|
|
1757
|
+
if (!owner) {
|
|
1758
|
+
let depth = -1;
|
|
1759
|
+
for (const a of ancestorsOf(entityName, byName)) {
|
|
1760
|
+
const d = bindingOn(a.name) ? ancestorsOf(a.name, byName).length : -1;
|
|
1761
|
+
if (d > depth)
|
|
1762
|
+
[owner, depth] = [a.name, d];
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return owner ? { owner, field: bindingOn(owner) } : undefined;
|
|
1766
|
+
}
|
|
1767
|
+
// The first column `entityName`'s binding of `field` reads, as written, and
|
|
1768
|
+
// the entity that declares it (see boundDeclaration), or undefined when that
|
|
1769
|
+
// entity is abstract. A column is a reference no entity name qualifies, and an
|
|
1770
|
+
// ANSI_SQL text is read in `dialect` if set.
|
|
1771
|
+
function columnBinding(model, entityName, field, dialect) {
|
|
1772
|
+
const byName = new Map((model.entities ?? []).map(e => [e.name, e]));
|
|
1773
|
+
const bound = boundDeclaration(model, entityName, field);
|
|
1774
|
+
if (!bound || byName.get(bound.owner)?.abstract)
|
|
1775
|
+
return undefined;
|
|
1776
|
+
for (const { text, dialect: written } of expressionSources(bound.field)) {
|
|
1777
|
+
const c = (0, sql_expr_utils_1.columnsForSource)(text, written, dialect)
|
|
1778
|
+
?.find(c => !byName.has(c.qualifier ?? ''));
|
|
1779
|
+
if (c) {
|
|
1780
|
+
return {
|
|
1781
|
+
owner: bound.owner,
|
|
1782
|
+
column: c.qualifier ? `${c.qualifier}.${c.name}` : c.name,
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
return undefined;
|
|
1787
|
+
}
|
|
1788
|
+
// The fields of its own entity that `field` reads on `entityName`, from the
|
|
1789
|
+
// texts a profile of `dialect` evaluates (see textsFor) as written. The
|
|
1790
|
+
// declaration read is the one boundDeclaration gives, read with its entity's
|
|
1791
|
+
// qualifier.
|
|
1792
|
+
function fieldDependencies(model, entityName, field, unreadable, dialect, onUnread) {
|
|
1793
|
+
const bound = boundDeclaration(model, entityName, field);
|
|
1794
|
+
if (!bound)
|
|
1795
|
+
return [];
|
|
1796
|
+
const name = bound.owner;
|
|
1797
|
+
const deps = new Set();
|
|
1798
|
+
for (const text of textsFor(bound.field, dialect)) {
|
|
1799
|
+
// A text with no SQL has nothing to read.
|
|
1800
|
+
if ((0, loader_1.noSqlReason)(text.text, text.dialect, name) !== undefined)
|
|
1801
|
+
continue;
|
|
1802
|
+
const columns = (0, sql_expr_utils_1.columnReferences)(text.text, text.dialect);
|
|
1803
|
+
if (!columns) {
|
|
1804
|
+
unreadable.add(fieldLabel(name, field));
|
|
1805
|
+
onUnread?.(fieldLabel(name, field), bound.field, text);
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1808
|
+
for (const dep of (0, sql_expr_utils_1.fieldsReadOn)(columns, name))
|
|
1809
|
+
deps.add(dep);
|
|
1810
|
+
}
|
|
1811
|
+
return [...deps];
|
|
1812
|
+
}
|
|
1813
|
+
// Whether a primary key and unique keys state a key. An empty unique key
|
|
1814
|
+
// counts as none.
|
|
1815
|
+
function statesKey(primaryKey, uniqueKeys) {
|
|
1816
|
+
return !!primaryKey?.length || nonEmptyKeys(uniqueKeys).length > 0;
|
|
1817
|
+
}
|
|
1818
|
+
// The unique keys in `keys` that hold a column. An empty unique key counts as
|
|
1819
|
+
// none.
|
|
1820
|
+
function nonEmptyKeys(keys) {
|
|
1821
|
+
return (keys ?? []).filter(k => k.length > 0);
|
|
1822
|
+
}
|