@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,824 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Knowledge Catalog <-> Semantic Model IR converter.
|
|
3
|
+
//
|
|
4
|
+
// SCAFFOLD (naming for the end state): this file currently holds only the
|
|
5
|
+
// READ direction -- Knowledge Catalog entries -> IR
|
|
6
|
+
// (`modelsFromCatalogResources` and its helpers). The WRITE direction (IR -> KC
|
|
7
|
+
// entries, `generateCatalogResources`) still lives in `knowledge_catalog.ts`
|
|
8
|
+
// and moves here once PR4 (the KC push line) merges, at which point this
|
|
9
|
+
// becomes the full two-way converter and `knowledge_catalog.ts` goes away.
|
|
10
|
+
// Until then, "where is the KC emit code?" -> `knowledge_catalog.ts`.
|
|
11
|
+
//
|
|
12
|
+
// TODO(#278): fold the KC WRITE direction in and drop the scaffold. Once
|
|
13
|
+
// #278 merges, move `generateCatalogResources` (and the `KcResources` type
|
|
14
|
+
// + emit helpers) out of `knowledge_catalog.ts` into this file, delete
|
|
15
|
+
// `knowledge_catalog.ts`, and repoint its importers
|
|
16
|
+
// (`deploy_knowledge_catalog.ts` and the emitter tests). Then this is the sole
|
|
17
|
+
// KC<->IR codec and the SCAFFOLD note above comes out.
|
|
18
|
+
//
|
|
19
|
+
// The reader is the inverse of `generateCatalogResources`: it reconstructs the
|
|
20
|
+
// IR from the entries a pull hydrated (see `pull_kc.pullKnowledgeCatalog`).
|
|
21
|
+
// `semantic-entity`, `semantic-metric` and `semantic-relationship` entries are
|
|
22
|
+
// grouped under their `semantic-model` anchor via `parentEntry`; entries of
|
|
23
|
+
// other types are ignored. Resources are matched by type-name SUFFIX, so a
|
|
24
|
+
// reader need not know which system-type project/location the emitter used.
|
|
25
|
+
//
|
|
26
|
+
// Fidelity is bounded by what the emitter persisted, so this read is the
|
|
27
|
+
// inverse of the WRITE, not of the authored document. It recovers names,
|
|
28
|
+
// descriptions, data sources, field datatypes and labels (via the schema
|
|
29
|
+
// aspect), entity keys and unique keys (the schema aspect's primaryKey /
|
|
30
|
+
// uniqueConstraints), each object's `ai_context.instructions` (from its
|
|
31
|
+
// `guidelines` aspect, on the model/entity/metric), each metric's attach entity
|
|
32
|
+
// (re-derived from its expression, as the loader does, when the catalog holds
|
|
33
|
+
// one, else the value the emitter persisted), the model's deployment targets
|
|
34
|
+
// (from the semantic-model aspect, back into `deployments` and the GOOGLE
|
|
35
|
+
// `custom_extensions` block), and 1:1 / 1:N relationships (from the
|
|
36
|
+
// `schema-join` entry links a pull fetched -- see
|
|
37
|
+
// `modelsFromCatalogResources`'s `entryLinks` argument).
|
|
38
|
+
// The per-field `semantics` block -- field/metric expressions and the DIMENSION
|
|
39
|
+
// role
|
|
40
|
+
// -- is gated off the catalog by default: the emitter writes it only under
|
|
41
|
+
// `KcGenerateOptions.emitExpressions`, which push leaves off, so those
|
|
42
|
+
// three recover only when the push that wrote them enabled it, and a default
|
|
43
|
+
// push -> pull drops them. It cannot recover what the emitter never writes:
|
|
44
|
+
// `ai_context.synonyms`/`examples` and field-level `ai_context` (only
|
|
45
|
+
// model/entity/metric `instructions` are persisted, via `guidelines`),
|
|
46
|
+
// `importedExpression`/`importedDialect` (the vendor-dialect SQL), and
|
|
47
|
+
// many-to-many (association) relationships (whose edge lives only in the
|
|
48
|
+
// BigQuery property graph). A `String`- or `Opaque`-typed METRIC also reads
|
|
49
|
+
// back un-typed: the metric aspect persists only `dataType` (both collapse to
|
|
50
|
+
// `STRING`, with no metadataType to disambiguate), so -- as with a plain STRING
|
|
51
|
+
// field -- the reader leaves it un-typed rather than guess. Relationship NAMES
|
|
52
|
+
// come back lowercased, since the emitter encodes the name only in the link id
|
|
53
|
+
// (via `linkSlug`), not in the join aspect.
|
|
54
|
+
//
|
|
55
|
+
// A model pushed with the second-generation types is rebuilt from them when
|
|
56
|
+
// the pull reads them (see readModelV2). Every value push stores there comes
|
|
57
|
+
// back, each relationship is read from its own entry, and no entry link is
|
|
58
|
+
// read.
|
|
59
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
60
|
+
exports.modelsFromCatalogResources = modelsFromCatalogResources;
|
|
61
|
+
exports.linkDedupKey = linkDedupKey;
|
|
62
|
+
exports.linkNamePrefix = linkNamePrefix;
|
|
63
|
+
const deployment_target_1 = require("./deployment_target");
|
|
64
|
+
const ir_1 = require("./ir");
|
|
65
|
+
const kc_actions_1 = require("./kc_actions");
|
|
66
|
+
const kc_constraints_1 = require("./kc_constraints");
|
|
67
|
+
const kc_ids_1 = require("./kc_ids");
|
|
68
|
+
const loader_1 = require("./loader");
|
|
69
|
+
const osi_converter_1 = require("./osi_converter");
|
|
70
|
+
const sql_expr_utils_1 = require("./sql_expr_utils");
|
|
71
|
+
// A guard naming a constraint this pull did not recover -- because its entry was
|
|
72
|
+
// absent, or was skipped as unreadable. readAction keeps the name so the
|
|
73
|
+
// author's model is not silently rewritten, which leaves the document one that
|
|
74
|
+
// push will reject. Reporting it here puts the message on the command that
|
|
75
|
+
// produced the document rather than on the next one.
|
|
76
|
+
function warnDanglingGuards(actions, constraints, modelName, warnings) {
|
|
77
|
+
if (!actions.length)
|
|
78
|
+
return;
|
|
79
|
+
const recovered = new Set(constraints.map(c => c.name));
|
|
80
|
+
for (const action of actions) {
|
|
81
|
+
for (const guard of action.guards ?? []) {
|
|
82
|
+
if (recovered.has(guard))
|
|
83
|
+
continue;
|
|
84
|
+
warnings.push(`model '${modelName}': action '${action.name}' is guarded by ` +
|
|
85
|
+
`'${guard}', but no constraint of that name was recovered; the ` +
|
|
86
|
+
`name is kept, and a push rejects it until the constraint is back`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Reconstructs the Semantic Model IR from Knowledge Catalog entries.
|
|
92
|
+
*
|
|
93
|
+
* Returns one model per `semantic-model` anchor plus any warnings (no anchor,
|
|
94
|
+
* an orphaned child, an entry missing its aspect data). Entries must already be
|
|
95
|
+
* hydrated with their `semantic-*` (and, for entities, `schema`) aspect data; a
|
|
96
|
+
* BASIC list omits aspect data, so the puller re-fetches each entry first.
|
|
97
|
+
*
|
|
98
|
+
* `entryLinks` are the `schema-join` links a pull fetched for the group (see
|
|
99
|
+
* `pull_kc`); each link between two of a model's entity entries is
|
|
100
|
+
* reconstructed as a 1:1 / 1:N relationship. Pass `[]` (the default) to
|
|
101
|
+
* reconstruct entities and metrics only.
|
|
102
|
+
*
|
|
103
|
+
* With `opts.v2Aspects` set, a model whose `semantic-model` aspect has
|
|
104
|
+
* `specVersion` is rebuilt from the second-generation types (see
|
|
105
|
+
* readModelV2), which reads no entry link.
|
|
106
|
+
*/
|
|
107
|
+
function modelsFromCatalogResources(entries, entryLinks = [], opts = {}) {
|
|
108
|
+
const warnings = [];
|
|
109
|
+
const anchors = entries.filter(e => semanticType(e) === 'semantic-model');
|
|
110
|
+
const entityEntries = entries.filter(e => semanticType(e) === 'semantic-entity');
|
|
111
|
+
const metricEntries = entries.filter(e => semanticType(e) === 'semantic-metric');
|
|
112
|
+
const relationshipEntries = entries.filter(e => semanticType(e) === 'semantic-relationship');
|
|
113
|
+
// Actions have no built-in system type; `kc_actions.ts` owns the custom one
|
|
114
|
+
// and recognizes an entry carrying it. Constraints are the same, through
|
|
115
|
+
// `kc_constraints.ts`.
|
|
116
|
+
const actionEntries = entries.filter(kc_actions_1.isActionEntry);
|
|
117
|
+
const constraintEntries = entries.filter(kc_constraints_1.isConstraintEntry);
|
|
118
|
+
if (!anchors.length) {
|
|
119
|
+
warnings.push('no semantic-model entry found; nothing to reconstruct');
|
|
120
|
+
return { models: [], warnings: [...new Set(warnings)] };
|
|
121
|
+
}
|
|
122
|
+
// A child belongs to its anchor by parentEntry. When there is exactly one
|
|
123
|
+
// anchor, children whose parentEntry does not resolve (e.g. a project-id
|
|
124
|
+
// normalization mismatch) are still attached to it rather than dropped.
|
|
125
|
+
const anchorNames = new Set(anchors.map(a => a.name));
|
|
126
|
+
const soleAnchor = anchors.length === 1 ? anchors[0].name : undefined;
|
|
127
|
+
const childrenOf = (anchorName, pool) => pool.filter(e => e.parentEntry === anchorName ||
|
|
128
|
+
(soleAnchor === anchorName && !anchorNames.has(e.parentEntry ?? '')));
|
|
129
|
+
let secondGeneration = false;
|
|
130
|
+
const overviews = new Map();
|
|
131
|
+
const models = anchors.map(anchor => {
|
|
132
|
+
const name = anchor.entrySource?.displayName ?? idOf(anchor.name);
|
|
133
|
+
const children = (pool) => childrenOf(anchor.name, pool);
|
|
134
|
+
if (opts.v2Aspects &&
|
|
135
|
+
aspectData(anchor, 'semantic-model').specVersion !== undefined) {
|
|
136
|
+
secondGeneration = true;
|
|
137
|
+
const [entities, metrics, relationships] = [entityEntries, metricEntries, relationshipEntries].map(children);
|
|
138
|
+
overviewSidecars([anchor, ...entities, ...metrics, ...relationships])
|
|
139
|
+
.forEach((sidecar, id) => overviews.set(id, sidecar));
|
|
140
|
+
const model = readModelV2(anchor, name, entities, metrics, relationships);
|
|
141
|
+
readActionsAndConstraints(model, children(actionEntries), children(constraintEntries), warnings);
|
|
142
|
+
return model;
|
|
143
|
+
}
|
|
144
|
+
const entityEntriesForModel = children(entityEntries);
|
|
145
|
+
const entities = entityEntriesForModel.map(e => readEntity(e, warnings));
|
|
146
|
+
const entityNames = entities.map(e => e.name);
|
|
147
|
+
const metrics = children(metricEntries).map(e => readMetric(e, entityNames, warnings));
|
|
148
|
+
// Index this model's entities by their entry id, so a schema-join link can
|
|
149
|
+
// resolve its endpoints from the link's entryReferences. The id segment is
|
|
150
|
+
// stable across the project-number/id normalization that lookupEntry
|
|
151
|
+
// applies to entries but lookupEntryLinks does not apply to link
|
|
152
|
+
// references, so matching on it (not the full resource name) keeps
|
|
153
|
+
// live-fetched links resolvable.
|
|
154
|
+
const entityByEntryId = new Map();
|
|
155
|
+
entityEntriesForModel.forEach((e, i) => entityByEntryId.set((0, kc_ids_1.entryIdOf)(e.name), entities[i]));
|
|
156
|
+
// Relationships come from the schema-join entry links whose two endpoints
|
|
157
|
+
// are both this model's entity entries (M:N edges were never published --
|
|
158
|
+
// they live only in the BigQuery property graph -- so stay absent here).
|
|
159
|
+
const relationships = readRelationships(entryLinks, name, entityByEntryId, warnings);
|
|
160
|
+
const model = { name, entities, relationships, metrics };
|
|
161
|
+
const description = anchor.entrySource?.description;
|
|
162
|
+
if (description !== undefined)
|
|
163
|
+
model.description = description;
|
|
164
|
+
readActionsAndConstraints(model, children(actionEntries), children(constraintEntries), warnings);
|
|
165
|
+
// Deployment targets ride back in both the GOOGLE custom_extensions block
|
|
166
|
+
// and as synthesized `deployments` entries (`default` when one,
|
|
167
|
+
// `deployment_1`, `deployment_2`, ... when several).
|
|
168
|
+
const targets = readDeploymentTargets(anchor, name, warnings);
|
|
169
|
+
if (targets) {
|
|
170
|
+
model.customExtensions = [targets.extension];
|
|
171
|
+
model.deployments = targets.deployments;
|
|
172
|
+
}
|
|
173
|
+
const ai = readAiContext(anchor);
|
|
174
|
+
if (ai)
|
|
175
|
+
model.aiContext = ai;
|
|
176
|
+
return model;
|
|
177
|
+
});
|
|
178
|
+
// Flag children that resolved to no anchor at all (only possible with
|
|
179
|
+
// multiple anchors, where the sole-anchor fallback does not apply).
|
|
180
|
+
if (!soleAnchor) {
|
|
181
|
+
for (const child of [...entityEntries, ...metricEntries, ...actionEntries,
|
|
182
|
+
...constraintEntries]) {
|
|
183
|
+
if (!child.parentEntry || !anchorNames.has(child.parentEntry)) {
|
|
184
|
+
warnings.push(`entry '${child.name}' has no resolvable parent semantic-model; omitted`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
models,
|
|
190
|
+
warnings: [...new Set(warnings)],
|
|
191
|
+
...(secondGeneration ? { secondGeneration, overviews } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// Reads a model's action and constraint entries onto `model`.
|
|
195
|
+
function readActionsAndConstraints(model, actionEntries, constraintEntries, warnings) {
|
|
196
|
+
const actions = actionEntries.map(e => (0, kc_actions_1.readAction)(e, warnings))
|
|
197
|
+
.filter((a) => a !== undefined);
|
|
198
|
+
if (actions.length)
|
|
199
|
+
model.actions = actions;
|
|
200
|
+
const constraints = constraintEntries.map(e => (0, kc_constraints_1.readConstraint)(e, warnings))
|
|
201
|
+
.filter((c) => c !== undefined);
|
|
202
|
+
if (constraints.length)
|
|
203
|
+
model.constraints = constraints;
|
|
204
|
+
warnDanglingGuards(actions, constraints, model.name, warnings);
|
|
205
|
+
}
|
|
206
|
+
// Rebuilds a model from the second-generation types: its model entry and the
|
|
207
|
+
// entity, metric and relationship entries under it, each kind sorted by name.
|
|
208
|
+
function readModelV2(anchor, name, entityEntries, metricEntries, relationshipEntries) {
|
|
209
|
+
const data = aspectData(anchor, 'semantic-model');
|
|
210
|
+
// The named profiles in stored order, which the readers below fill in.
|
|
211
|
+
const profiles = new Map(stringList(asArray(data.profiles).map(p => p?.name))
|
|
212
|
+
.map(p => [p, { name: p, entities: [], relationships: [] }]));
|
|
213
|
+
const entities = sortedByName(entityEntries).map(e => readEntityV2(e, profiles));
|
|
214
|
+
const relationships = sortedByName(relationshipEntries)
|
|
215
|
+
.map(e => readRelationshipV2(e, profiles));
|
|
216
|
+
const sortedMetrics = sortedByName(metricEntries);
|
|
217
|
+
const metrics = sortedMetrics.map(readMetricV2);
|
|
218
|
+
// Each profile excludes '*' when it excludes every metric, and otherwise the
|
|
219
|
+
// metrics it excludes, in name order.
|
|
220
|
+
const excludedIn = sortedMetrics.map(e => stringList(aspectData(e, 'semantic-metric').excludedInProfiles));
|
|
221
|
+
for (const profile of profiles.values()) {
|
|
222
|
+
const excluded = metrics.filter((_, i) => excludedIn[i].includes(profile.name))
|
|
223
|
+
.map(m => m.name);
|
|
224
|
+
if (excluded.length) {
|
|
225
|
+
profile.metricsExclude =
|
|
226
|
+
excluded.length === metrics.length ? '*' : excluded;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return (0, osi_converter_1.compact)({
|
|
230
|
+
name,
|
|
231
|
+
version: data.specVersion,
|
|
232
|
+
entities,
|
|
233
|
+
relationships,
|
|
234
|
+
metrics,
|
|
235
|
+
...objectPropertiesV2(anchor, '', data.synonyms, anchor.entrySource?.description),
|
|
236
|
+
profiles: (0, osi_converter_1.nonEmpty)([...profiles.values()]),
|
|
237
|
+
deployments: (0, osi_converter_1.nonEmpty)(asArray(data.deployments).map(d => (0, osi_converter_1.compact)({ name: d?.name, target: d?.target, profile: d?.profile }))),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// Rebuilds an entity with every field its schema lists, and each field it
|
|
241
|
+
// only rebinds, and adds each named profile's binding of it to `profiles`.
|
|
242
|
+
function readEntityV2(entry, profiles) {
|
|
243
|
+
const name = idOf(entry.name);
|
|
244
|
+
const data = aspectData(entry, 'semantic-entity');
|
|
245
|
+
const records = asArray(aspectData(entry, 'sql-expressions').fieldExpressions);
|
|
246
|
+
for (const p of asArray(data.profiles)) {
|
|
247
|
+
const bound = records.filter(r => r?.qualifier === p?.name);
|
|
248
|
+
profiles.get(p?.name)?.entities.push((0, osi_converter_1.compact)({
|
|
249
|
+
name,
|
|
250
|
+
source: p.source?.resource,
|
|
251
|
+
primaryKey: (0, osi_converter_1.nonEmpty)(stringList(p.primaryKey?.fields)),
|
|
252
|
+
uniqueKeys: (0, osi_converter_1.nonEmpty)(uniqueKeysOf(p)),
|
|
253
|
+
fields: (0, osi_converter_1.nonEmpty)([...new Set(bound.map(r => r.name))].map(f => ({
|
|
254
|
+
name: f,
|
|
255
|
+
...expressionOf(bound.filter(r => r.name === f), `profile '${p.name}': entity '${name}': field '${f}'`),
|
|
256
|
+
}))),
|
|
257
|
+
fieldsExclude: (0, osi_converter_1.nonEmpty)(stringList(p.excludedFields)),
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
const inline = records.filter(r => !r?.qualifier);
|
|
261
|
+
const source = data.source?.resource;
|
|
262
|
+
return (0, osi_converter_1.compact)({
|
|
263
|
+
name,
|
|
264
|
+
dataSource: typeof source === 'string' ? (0, loader_1.sourceToDataSource)(source) : '',
|
|
265
|
+
authoredSource: source,
|
|
266
|
+
keys: stringList(data.primaryKey?.fields),
|
|
267
|
+
uniqueKeys: (0, osi_converter_1.nonEmpty)(uniqueKeysOf(data)),
|
|
268
|
+
extends: (0, osi_converter_1.nonEmpty)(stringList(data.extends)),
|
|
269
|
+
abstract: data.abstract === true || undefined,
|
|
270
|
+
fields: withRebindings(asArray(aspectData(entry, 'schema').fields)
|
|
271
|
+
.filter(fd => typeof fd?.name === 'string')
|
|
272
|
+
.map(fd => readFieldV2(entry, name, fd, inline)), inline, name),
|
|
273
|
+
...objectPropertiesV2(entry, '', data.synonyms, entry.entrySource?.description),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// `fields` plus a field for each name `records` hold that `fields` lack: an
|
|
277
|
+
// inherited field the entity only rebinds. Each keeps the place its records
|
|
278
|
+
// give it.
|
|
279
|
+
function withRebindings(fields, records, entity) {
|
|
280
|
+
const position = (name) => records.findIndex(r => r?.name === name);
|
|
281
|
+
const out = [...fields];
|
|
282
|
+
for (const name of new Set(records.map(r => r?.name))) {
|
|
283
|
+
if (fields.some(f => f.name === name))
|
|
284
|
+
continue;
|
|
285
|
+
const at = out.findIndex(f => position(f.name) > position(name));
|
|
286
|
+
out.splice(at < 0 ? out.length : at, 0, {
|
|
287
|
+
name,
|
|
288
|
+
...expressionOf(records.filter(r => r?.name === name), `field '${entity}.${name}'`),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
// Rebuilds a field from its schema record `fd`, the entity's sql-expressions
|
|
294
|
+
// `records` that name no profile, and its aspects at its path.
|
|
295
|
+
function readFieldV2(entry, entity, fd, records) {
|
|
296
|
+
const isTime = fd.annotations?.is_time;
|
|
297
|
+
return (0, osi_converter_1.compact)({
|
|
298
|
+
name: fd.name,
|
|
299
|
+
...expressionOf(records.filter(r => r?.name === fd.name), `field '${entity}.${fd.name}'`),
|
|
300
|
+
type: dataTypeOf(fd.dataType),
|
|
301
|
+
dimension: fd.semantic === 'DIMENSION' || isTime !== undefined ?
|
|
302
|
+
(0, osi_converter_1.compact)({ isTime: isTime === undefined ? undefined : isTime === 'true' }) :
|
|
303
|
+
undefined,
|
|
304
|
+
label: fd.annotations?.label || undefined,
|
|
305
|
+
...objectPropertiesV2(entry, `Schema.${fd.name}`, fd.synonyms, fd.description),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
// Rebuilds a metric. Its entity is the one push stored, declared or derived,
|
|
309
|
+
// and pull writes it back as declared.
|
|
310
|
+
function readMetricV2(entry) {
|
|
311
|
+
const name = idOf(entry.name);
|
|
312
|
+
const data = aspectData(entry, 'semantic-metric');
|
|
313
|
+
const entity = typeof data.entity === 'string' ? data.entity : undefined;
|
|
314
|
+
return (0, osi_converter_1.compact)({
|
|
315
|
+
name,
|
|
316
|
+
...expressionOf(asArray(aspectData(entry, 'sql-expressions').expressions), `metric '${name}'`),
|
|
317
|
+
type: dataTypeOf(data.dataType),
|
|
318
|
+
entity,
|
|
319
|
+
authoredEntity: entity,
|
|
320
|
+
...objectPropertiesV2(entry, '', data.synonyms, entry.entrySource?.description),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
// Rebuilds a relationship, and adds each named profile's join columns to
|
|
324
|
+
// `profiles`. A name holds no '/', so the last segment of the entry id, after
|
|
325
|
+
// `<model>/relationships/`, is the name as written.
|
|
326
|
+
function readRelationshipV2(entry, profiles) {
|
|
327
|
+
const name = idOf(entry.name);
|
|
328
|
+
const data = aspectData(entry, 'semantic-relationship');
|
|
329
|
+
for (const p of asArray(data.profiles)) {
|
|
330
|
+
profiles.get(p?.name)?.relationships.push({
|
|
331
|
+
name,
|
|
332
|
+
fromColumns: stringList(p.sourceFields),
|
|
333
|
+
toColumns: stringList(p.targetFields),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return (0, osi_converter_1.compact)({
|
|
337
|
+
name,
|
|
338
|
+
source: { entity: data.source?.name, columns: stringList(data.source?.fields) },
|
|
339
|
+
destination: { entity: data.target?.name, columns: stringList(data.target?.fields) },
|
|
340
|
+
...objectPropertiesV2(entry, '', data.synonyms, entry.entrySource?.description),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
// An object's `description`, ai_context and extension blocks, read from its
|
|
344
|
+
// aspects on `entry` at `path`, which is `Schema.<field>` for a field and
|
|
345
|
+
// empty otherwise. `synonyms` come from the object's own aspect. A part with
|
|
346
|
+
// nothing in it is undefined, and custom members are sorted by name.
|
|
347
|
+
function objectPropertiesV2(entry, path, synonyms, description) {
|
|
348
|
+
const guidelines = aspectDataOf(entry.aspects, 'guidelines', path);
|
|
349
|
+
// Knowledge Catalog's own guidelines are not part of the model.
|
|
350
|
+
const authored = guidelines.userManaged !== false ? guidelines : {};
|
|
351
|
+
const members = Object.keys(authored.data ?? {}).sort();
|
|
352
|
+
const ai = (0, osi_converter_1.compact)({
|
|
353
|
+
instructions: authored.instructions || undefined,
|
|
354
|
+
synonyms: (0, osi_converter_1.nonEmpty)(stringList(synonyms)),
|
|
355
|
+
examples: (0, osi_converter_1.nonEmpty)(stringList(asArray(aspectDataOf(entry.aspects, 'overview', path).examples)
|
|
356
|
+
.map(x => x?.content))),
|
|
357
|
+
additionalProperties: members.length ?
|
|
358
|
+
Object.fromEntries(members.map(m => [m, authored.data[m]])) :
|
|
359
|
+
undefined,
|
|
360
|
+
});
|
|
361
|
+
return {
|
|
362
|
+
description,
|
|
363
|
+
aiContext: Object.keys(ai).length ? ai : undefined,
|
|
364
|
+
customExtensions: (0, osi_converter_1.nonEmpty)(asArray(aspectDataOf(entry.aspects, 'semantic-extensions', path)
|
|
365
|
+
.customExtensions)
|
|
366
|
+
.map(x => ({ vendorName: x?.vendorName, data: x?.data }))),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
// The expression `records` hold, read as the loader reads the form they
|
|
370
|
+
// keep: a record with no dialect is the string form, and records with one are
|
|
371
|
+
// a dialect list, in stored order. Empty when there is no record.
|
|
372
|
+
function expressionOf(records, where) {
|
|
373
|
+
if (!records.length)
|
|
374
|
+
return {};
|
|
375
|
+
const short = records.find(r => !r.dialect);
|
|
376
|
+
const doc = short ? short.sql : {
|
|
377
|
+
dialects: records.map(r => ({ dialect: r.dialect, expression: r.sql })),
|
|
378
|
+
};
|
|
379
|
+
return (0, loader_1.readFieldExpression)(doc, where, loader_1.DEFAULT_DIALECT, () => undefined);
|
|
380
|
+
}
|
|
381
|
+
// The overview sidecar each of `entries` holds in its overview, by entry id:
|
|
382
|
+
// its content, contentType and links. An entry with none of the three has
|
|
383
|
+
// none.
|
|
384
|
+
function overviewSidecars(entries) {
|
|
385
|
+
const out = new Map();
|
|
386
|
+
for (const entry of entries) {
|
|
387
|
+
const { content, contentType, links } = aspectData(entry, 'overview');
|
|
388
|
+
const sidecar = (0, osi_converter_1.compact)({
|
|
389
|
+
content: content || undefined,
|
|
390
|
+
contentType,
|
|
391
|
+
links: (0, osi_converter_1.nonEmpty)(asArray(links)),
|
|
392
|
+
});
|
|
393
|
+
if (Object.keys(sidecar).length)
|
|
394
|
+
out.set((0, kc_ids_1.entryIdOf)(entry.name), sidecar);
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
// The unique keys of a semantic-entity aspect or one of its profile records.
|
|
399
|
+
function uniqueKeysOf(data) {
|
|
400
|
+
return asArray(data?.uniqueConstraints)
|
|
401
|
+
.map(u => stringList(u?.fields))
|
|
402
|
+
.filter(k => k.length);
|
|
403
|
+
}
|
|
404
|
+
// The IR datatype a second-generation `dataType` names. Opaque, stored for an
|
|
405
|
+
// object with no datatype, reads as none.
|
|
406
|
+
function dataTypeOf(dataType) {
|
|
407
|
+
return dataType !== 'Opaque' &&
|
|
408
|
+
ir_1.DATA_TYPES.includes(dataType) ?
|
|
409
|
+
dataType :
|
|
410
|
+
undefined;
|
|
411
|
+
}
|
|
412
|
+
// `entries` sorted by the last segment of their entry ids, which is the name
|
|
413
|
+
// of the object each holds.
|
|
414
|
+
function sortedByName(entries) {
|
|
415
|
+
return [...entries].sort((a, b) => {
|
|
416
|
+
const [x, y] = [idOf(a.name), idOf(b.name)];
|
|
417
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// Reconstructs an entity from its `semantic-entity` aspect (the backing
|
|
421
|
+
// source), the built-in `schema` aspect (its fields, primary key, and unique
|
|
422
|
+
// constraints), and the built-in `guidelines` aspect (ai_context instructions).
|
|
423
|
+
function readEntity(entry, warnings) {
|
|
424
|
+
const name = entry.entrySource?.displayName ?? idOf(entry.name);
|
|
425
|
+
const semantic = aspectData(entry, 'semantic-entity');
|
|
426
|
+
const schema = aspectData(entry, 'schema');
|
|
427
|
+
if (!Object.keys(semantic).length) {
|
|
428
|
+
warnings.push(`entity '${name}': no semantic-entity aspect data (fetch with the aspect type)`);
|
|
429
|
+
}
|
|
430
|
+
// A source is stored in `resources`. A model pushed before resource names
|
|
431
|
+
// were required may hold a resource URI there, or a catalog name in
|
|
432
|
+
// `importedResource`; pull returns it as stored and warns, naming the
|
|
433
|
+
// resource name to write.
|
|
434
|
+
const imported = semantic?.source?.importedResource;
|
|
435
|
+
const rawResource = (semantic?.source?.resources?.[0] ?? '').trim() ||
|
|
436
|
+
(typeof imported === 'string' ? imported.trim() : '');
|
|
437
|
+
const oldForm = (0, loader_1.oldSourceForm)(rawResource);
|
|
438
|
+
if (oldForm !== undefined) {
|
|
439
|
+
warnings.push(`entity '${name}': stored source '${rawResource}' is ${oldForm}.`);
|
|
440
|
+
}
|
|
441
|
+
const dataSource = dataSourceFromResource(rawResource);
|
|
442
|
+
if (!dataSource) {
|
|
443
|
+
warnings.push(`entity '${name}': no backing data source in the semantic-entity ` +
|
|
444
|
+
`aspect; 'source' will be empty and the entity may not load`);
|
|
445
|
+
}
|
|
446
|
+
const entity = {
|
|
447
|
+
name,
|
|
448
|
+
dataSource,
|
|
449
|
+
...(rawResource ? { authoredSource: rawResource } : {}),
|
|
450
|
+
// The grain / primary key, from the schema aspect's primaryKey.fields
|
|
451
|
+
// (ordered, so a composite key's ordinal positions round-trip).
|
|
452
|
+
keys: stringList(schema.primaryKey?.fields),
|
|
453
|
+
fields: asArray(schema.fields)
|
|
454
|
+
.map(fd => readField(fd, name, warnings))
|
|
455
|
+
.filter((f) => f !== undefined),
|
|
456
|
+
};
|
|
457
|
+
// Additional unique column sets, from the schema aspect's uniqueConstraints
|
|
458
|
+
// (each constraint's `fields` is one set); dropped when empty.
|
|
459
|
+
const uniqueKeys = uniqueKeysOf(schema);
|
|
460
|
+
if (uniqueKeys.length)
|
|
461
|
+
entity.uniqueKeys = uniqueKeys;
|
|
462
|
+
const description = entry.entrySource?.description;
|
|
463
|
+
if (description !== undefined)
|
|
464
|
+
entity.description = description;
|
|
465
|
+
const ai = readAiContext(entry);
|
|
466
|
+
if (ai)
|
|
467
|
+
entity.aiContext = ai;
|
|
468
|
+
return entity;
|
|
469
|
+
}
|
|
470
|
+
// Recovers ai_context from an entry's `guidelines` aspect. The emitter routes
|
|
471
|
+
// only `ai_context.instructions` to that aspect (see guidelinesAspectData), so
|
|
472
|
+
// synonyms and examples stay absent. Author-declared guidelines are stamped
|
|
473
|
+
// userManaged:true; Dataplex may also attach machine-generated guidelines
|
|
474
|
+
// (userManaged:false), which are not authored model content, so those are
|
|
475
|
+
// skipped. Returns undefined when the entry carries no author guidelines
|
|
476
|
+
// instructions.
|
|
477
|
+
function readAiContext(entry) {
|
|
478
|
+
const guidelines = aspectData(entry, 'guidelines');
|
|
479
|
+
if (guidelines.userManaged === false)
|
|
480
|
+
return undefined;
|
|
481
|
+
const instructions = guidelines.instructions;
|
|
482
|
+
if (typeof instructions !== 'string' || instructions === '')
|
|
483
|
+
return undefined;
|
|
484
|
+
return { instructions };
|
|
485
|
+
}
|
|
486
|
+
// Reconstructs a field from one `schema` aspect field record, inverting
|
|
487
|
+
// schemaAspectData: the datatype from dataType/metadataType, expressions from
|
|
488
|
+
// the nested `semantics` block, and the DIMENSION role back to a dimension
|
|
489
|
+
// marker.
|
|
490
|
+
function readField(fd, entityName, warnings) {
|
|
491
|
+
const name = fd?.name;
|
|
492
|
+
if (name === undefined || name === '') {
|
|
493
|
+
warnings.push(`entity '${entityName}': a schema field is missing its name; the ` +
|
|
494
|
+
`field is skipped`);
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
const field = { name };
|
|
498
|
+
const sem = fd?.semantics ?? {};
|
|
499
|
+
// An expression with no SQL does not load, so the field is read as unbound.
|
|
500
|
+
const noSql = typeof sem.expression === 'string' ?
|
|
501
|
+
(0, loader_1.noSqlReason)(sem.expression, 'ANSI_SQL', entityName) :
|
|
502
|
+
undefined;
|
|
503
|
+
if (noSql) {
|
|
504
|
+
warnings.push(`entity '${entityName}': field '${name}': its expression in the ` +
|
|
505
|
+
`catalog ${noSql}, so the field is read as unbound`);
|
|
506
|
+
}
|
|
507
|
+
else if (sem.expression !== undefined) {
|
|
508
|
+
field.expression = sem.expression;
|
|
509
|
+
}
|
|
510
|
+
const type = irDataType(fd?.dataType, fd?.metadataType);
|
|
511
|
+
if (type !== undefined)
|
|
512
|
+
field.type = type;
|
|
513
|
+
if (sem.role === 'DIMENSION')
|
|
514
|
+
field.dimension = {};
|
|
515
|
+
if (fd?.description !== undefined)
|
|
516
|
+
field.description = fd.description;
|
|
517
|
+
// The display label rides in the schema field's `annotations` map (the
|
|
518
|
+
// inverse of the emitter's `annotations: {label}`).
|
|
519
|
+
const label = fd?.annotations?.label;
|
|
520
|
+
if (typeof label === 'string' && label !== '')
|
|
521
|
+
field.label = label;
|
|
522
|
+
return field;
|
|
523
|
+
}
|
|
524
|
+
// Reconstructs a metric from its `semantic-metric` aspect. The attach `entity`
|
|
525
|
+
// is re-derived from the expression (as the loader does) when the aspect holds
|
|
526
|
+
// one, else it falls back to the entity the emitter persisted. A default push
|
|
527
|
+
// omits the expression entirely (it is gated behind `emitExpressions`), so
|
|
528
|
+
// an absent expression is expected, not an error, and does not warn.
|
|
529
|
+
function readMetric(entry, entityNames, warnings) {
|
|
530
|
+
const name = entry.entrySource?.displayName ?? idOf(entry.name);
|
|
531
|
+
const data = aspectData(entry, 'semantic-metric');
|
|
532
|
+
const metric = { name };
|
|
533
|
+
// An expression with no SQL does not load, so it is read as absent, and the
|
|
534
|
+
// serializer warns that the metric needs one.
|
|
535
|
+
const expression = typeof data.expression === 'string' &&
|
|
536
|
+
(0, loader_1.noSqlReason)(data.expression, 'ANSI_SQL') ?
|
|
537
|
+
undefined :
|
|
538
|
+
data.expression;
|
|
539
|
+
if (expression !== undefined)
|
|
540
|
+
metric.expression = expression;
|
|
541
|
+
const exprForRefs = expression ?? '';
|
|
542
|
+
const cols = exprForRefs ? (0, sql_expr_utils_1.columnReferences)(exprForRefs, 'BIGQUERY') : [];
|
|
543
|
+
const referenced = (0, sql_expr_utils_1.entitiesReached)(cols ?? [], entityNames);
|
|
544
|
+
const persistedEntity = typeof data.entity === 'string' && data.entity !== '' ? data.entity :
|
|
545
|
+
undefined;
|
|
546
|
+
if (cols === undefined && persistedEntity === undefined) {
|
|
547
|
+
warnings.push(`metric '${name}': expression could not be parsed; entity inference ` +
|
|
548
|
+
`was skipped (set 'entity' on the metric, or backtick-quote a ` +
|
|
549
|
+
`reserved qualifier, such as \`Date\`.amount)`);
|
|
550
|
+
}
|
|
551
|
+
if (referenced.length === 1) {
|
|
552
|
+
// The expression pins exactly one known entity; re-derive it (as the loader
|
|
553
|
+
// does) so the attach entity stays consistent with the reconstructed set.
|
|
554
|
+
metric.entity = referenced[0];
|
|
555
|
+
}
|
|
556
|
+
else if (persistedEntity !== undefined) {
|
|
557
|
+
// The expression does not pin a single known entity (none, or several), so
|
|
558
|
+
// fall back to the attach entity the emitter persisted (metricAspectData)
|
|
559
|
+
// rather than dropping it.
|
|
560
|
+
metric.entity = persistedEntity;
|
|
561
|
+
// A recovered expression that cannot re-derive the anchor means the author
|
|
562
|
+
// wrote it; mark it authored so the serializer writes `entity:` back. Not
|
|
563
|
+
// when the anchor names a dataset this pull did not rebuild: a derived
|
|
564
|
+
// anchor is persisted too, and `entity:` naming no dataset would not load.
|
|
565
|
+
// With no expression (a default push) the metric cannot reload either way.
|
|
566
|
+
if (exprForRefs && entityNames.includes(persistedEntity)) {
|
|
567
|
+
metric.authoredEntity = persistedEntity;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
else if (cols !== undefined && exprForRefs && !referenced.length) {
|
|
571
|
+
// Parity with the loader's convertMetric: an expression that qualifies no
|
|
572
|
+
// known entity is flagged as potentially unplaceable downstream.
|
|
573
|
+
warnings.push(`metric '${name}': expression references no known entity; it may not ` +
|
|
574
|
+
`be placeable downstream`);
|
|
575
|
+
}
|
|
576
|
+
// The emitter writes a required dataType, defaulting a typeless metric to
|
|
577
|
+
// Opaque (see metricAspectData). The metric aspect carries no metadataType,
|
|
578
|
+
// so Opaque serializes as a bare STRING, which irDataType reads back as
|
|
579
|
+
// un-typed
|
|
580
|
+
// -- a metric authored without a datatype round-trips un-typed rather than as
|
|
581
|
+
// a guessed numeric type.
|
|
582
|
+
const type = irDataType(data.dataType, undefined);
|
|
583
|
+
if (type !== undefined)
|
|
584
|
+
metric.type = type;
|
|
585
|
+
const description = entry.entrySource?.description;
|
|
586
|
+
if (description !== undefined)
|
|
587
|
+
metric.description = description;
|
|
588
|
+
const ai = readAiContext(entry);
|
|
589
|
+
if (ai)
|
|
590
|
+
metric.aiContext = ai;
|
|
591
|
+
return metric;
|
|
592
|
+
}
|
|
593
|
+
// The inverse of columnDataType/columnMetadataType: maps the schema aspect's
|
|
594
|
+
// dataType (disambiguated by metadataType only for the STRING family) back to
|
|
595
|
+
// the IR's logical DataType. STRING + OTHER is Opaque -- which the emitter
|
|
596
|
+
// writes both for an authored `Opaque` field and for a field the author left
|
|
597
|
+
// untyped; a plain STRING (metadataType STRING) is an authored `String` and is
|
|
598
|
+
// read as un-typed (undefined), the loader's default. (The metric aspect has no
|
|
599
|
+
// metadataType, so a metric's bare STRING always reads back un-typed.)
|
|
600
|
+
function irDataType(dataType, metadataType) {
|
|
601
|
+
switch (dataType) {
|
|
602
|
+
case 'INT64':
|
|
603
|
+
return 'Integer';
|
|
604
|
+
case 'NUMERIC':
|
|
605
|
+
return 'Decimal';
|
|
606
|
+
case 'FLOAT64':
|
|
607
|
+
return 'Float';
|
|
608
|
+
case 'BOOL':
|
|
609
|
+
return 'Boolean';
|
|
610
|
+
case 'DATE':
|
|
611
|
+
return 'Date';
|
|
612
|
+
case 'TIME':
|
|
613
|
+
return 'Time';
|
|
614
|
+
case 'DATETIME':
|
|
615
|
+
return 'DateTime';
|
|
616
|
+
case 'TIMESTAMP':
|
|
617
|
+
return 'DateTimeTz';
|
|
618
|
+
case 'STRING':
|
|
619
|
+
return metadataType === 'OTHER' ? 'Opaque' : undefined;
|
|
620
|
+
default:
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// The inverse of entityAspectData, through the loader's own conversion: a
|
|
625
|
+
// BigQuery or BigLake resource URI or resource name becomes the dot-separated
|
|
626
|
+
// table string. A Spanner or AlloyDB resource
|
|
627
|
+
// name becomes its resource URI. Anything else is returned trimmed. An absent
|
|
628
|
+
// resource reads as ''.
|
|
629
|
+
function dataSourceFromResource(resource) {
|
|
630
|
+
return (0, loader_1.sourceToDataSource)(resource ?? '');
|
|
631
|
+
}
|
|
632
|
+
// The bare `semantic-*` type of an entry, matched by entryType suffix so the
|
|
633
|
+
// system-type project/location need not be known. Returns undefined for entries
|
|
634
|
+
// that are not part of a semantic model.
|
|
635
|
+
function semanticType(entry) {
|
|
636
|
+
for (const t of ['semantic-model', 'semantic-entity', 'semantic-metric',
|
|
637
|
+
'semantic-relationship']) {
|
|
638
|
+
if (entry.entryType?.endsWith(`/entryTypes/${t}`))
|
|
639
|
+
return t;
|
|
640
|
+
}
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
// The `data` payload of an entry's aspect of the given bare type. See
|
|
644
|
+
// aspectDataOf; entries and entry links carry aspects in the same shape.
|
|
645
|
+
function aspectData(entry, type) {
|
|
646
|
+
return aspectDataOf(entry.aspects, type);
|
|
647
|
+
}
|
|
648
|
+
// The `data` payload of an aspect of the given bare type from an aspect map,
|
|
649
|
+
// matched by the aspect key's `.<type>` suffix or the aspectType's
|
|
650
|
+
// `/aspectTypes/<type>` suffix (robust to whichever system-type
|
|
651
|
+
// project/location the emitter used). Only the aspect at `path` matches, which
|
|
652
|
+
// is the entry itself when `path` is empty and a field for `Schema.<field>`.
|
|
653
|
+
// Returns an empty object when the aspect is absent.
|
|
654
|
+
function aspectDataOf(aspects, type, path = '') {
|
|
655
|
+
for (const [key, aspect] of Object.entries(aspects ?? {})) {
|
|
656
|
+
const [ref, keyPath = ''] = key.split('@');
|
|
657
|
+
if ((aspect.path || keyPath) !== path)
|
|
658
|
+
continue;
|
|
659
|
+
if (ref.endsWith(`.${type}`) ||
|
|
660
|
+
aspect.aspectType?.endsWith(`/aspectTypes/${type}`)) {
|
|
661
|
+
return aspect.data ?? {};
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return {};
|
|
665
|
+
}
|
|
666
|
+
// Recovers the model's deployment targets from its semantic-model aspect back
|
|
667
|
+
// into both the GOOGLE custom_extension shim and a `deployments` list (`default`
|
|
668
|
+
// when there is one target, `deployment_1`, `deployment_2`, ... when there are
|
|
669
|
+
// several), warning that the original deployment names were not recovered.
|
|
670
|
+
// Returns undefined when the model has none.
|
|
671
|
+
function readDeploymentTargets(anchor, modelName, warnings) {
|
|
672
|
+
const deployments = (0, deployment_target_1.unnamedDeployments)(asArray(aspectData(anchor, 'semantic-model').deploymentTargets)
|
|
673
|
+
.filter((t) => typeof t === 'string'));
|
|
674
|
+
if (!deployments.length)
|
|
675
|
+
return undefined;
|
|
676
|
+
// A model pushed before resource names were required may hold a target in
|
|
677
|
+
// a form push now rejects; pull returns it as stored and warns.
|
|
678
|
+
for (const { target } of deployments) {
|
|
679
|
+
const problem = (0, deployment_target_1.targetProblem)(`model '${modelName}': stored deployment target '${target}'`, target);
|
|
680
|
+
if (problem !== undefined)
|
|
681
|
+
warnings.push(problem);
|
|
682
|
+
}
|
|
683
|
+
const targets = deployments.map(d => d.target);
|
|
684
|
+
warnings.push(`model '${modelName}': deployment names were not recovered (the ` +
|
|
685
|
+
`catalog stores target URIs only); using ${deployments.map(d => `'${d.name}'`).join(', ')}`);
|
|
686
|
+
return {
|
|
687
|
+
extension: {
|
|
688
|
+
vendorName: 'GOOGLE',
|
|
689
|
+
data: JSON.stringify({ deploymentTargets: targets }),
|
|
690
|
+
},
|
|
691
|
+
deployments,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
// Inverts schemaJoinAspectData (knowledge_catalog.ts): each schema-join link
|
|
695
|
+
// whose two endpoints are both this model's entity entries becomes one
|
|
696
|
+
// direct-FK relationship.
|
|
697
|
+
//
|
|
698
|
+
// Endpoint identity comes from the link's entryReferences (matched by entry id
|
|
699
|
+
// via `entityByEntryId`), NOT from the aspect's table names: the id is unique
|
|
700
|
+
// per entity (two entities can share a table) and survives the
|
|
701
|
+
// project-number/id normalization lookupEntry applies to entries but
|
|
702
|
+
// lookupEntryLinks does not apply to link references. The aspect then supplies
|
|
703
|
+
// the join columns and the direction -- its `source` side is the foreign-key
|
|
704
|
+
// side, named by its data source -- used only to orient the two endpoints. The
|
|
705
|
+
// link id encodes the (normalized) relationship name. Links are deduped --
|
|
706
|
+
// schema-join is undirected, so a per-entry lookup can return the same link
|
|
707
|
+
// once from each endpoint.
|
|
708
|
+
function readRelationships(links, modelName, entityByEntryId, warnings) {
|
|
709
|
+
const prefix = linkNamePrefix(modelName);
|
|
710
|
+
const seen = new Set();
|
|
711
|
+
const out = [];
|
|
712
|
+
for (const link of links) {
|
|
713
|
+
if (!link.entryLinkType?.endsWith('/entryLinkTypes/schema-join'))
|
|
714
|
+
continue;
|
|
715
|
+
const refs = link.entryReferences ?? [];
|
|
716
|
+
if (refs.length !== 2)
|
|
717
|
+
continue;
|
|
718
|
+
// Resolve both endpoints from the link's references. A reference that is
|
|
719
|
+
// not one of this model's entities (another model, or a non-entity) means
|
|
720
|
+
// the link does not belong here -- skip it quietly, as with M:N edges.
|
|
721
|
+
const endA = entityByEntryId.get((0, kc_ids_1.entryIdOf)(refs[0].name));
|
|
722
|
+
const endB = entityByEntryId.get((0, kc_ids_1.entryIdOf)(refs[1].name));
|
|
723
|
+
if (!endA || !endB)
|
|
724
|
+
continue;
|
|
725
|
+
const key = linkDedupKey(link);
|
|
726
|
+
if (seen.has(key))
|
|
727
|
+
continue;
|
|
728
|
+
seen.add(key);
|
|
729
|
+
const join = asArray(aspectDataOf(link.aspects, 'schema-join').joins)[0];
|
|
730
|
+
if (!join) {
|
|
731
|
+
warnings.push(`entry link '${link.name}': no schema-join aspect data; the ` +
|
|
732
|
+
`relationship is skipped`);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
// Direction lives in the aspect: `source` is the foreign-key side, named by
|
|
736
|
+
// its data source. Orient the two endpoints by matching that name, keeping
|
|
737
|
+
// join.source.fields paired with the source side. When neither orientation
|
|
738
|
+
// matches -- or both do, because the endpoints share a data source -- the
|
|
739
|
+
// direction is undecidable, so keep the reference order and warn rather
|
|
740
|
+
// than drop the edge.
|
|
741
|
+
const srcName = (join.source?.name ?? '').trim();
|
|
742
|
+
const tgtName = (join.target?.name ?? '').trim();
|
|
743
|
+
const aSrc = (endA.dataSource ?? '').trim();
|
|
744
|
+
const bSrc = (endB.dataSource ?? '').trim();
|
|
745
|
+
const endAIsSource = aSrc === srcName && bSrc === tgtName;
|
|
746
|
+
const endBIsSource = bSrc === srcName && aSrc === tgtName;
|
|
747
|
+
let [source, destination] = [endA, endB];
|
|
748
|
+
if (endBIsSource && !endAIsSource) {
|
|
749
|
+
[source, destination] = [endB, endA];
|
|
750
|
+
}
|
|
751
|
+
else if (!endAIsSource && !endBIsSource) {
|
|
752
|
+
warnings.push(`entry link '${link.name}': join direction does not match either ` +
|
|
753
|
+
`endpoint's data source; using the reference order`);
|
|
754
|
+
}
|
|
755
|
+
else if (endAIsSource && endBIsSource) {
|
|
756
|
+
warnings.push(`entry link '${link.name}': join direction is ambiguous (endpoints ` +
|
|
757
|
+
`share a data source); using the reference order`);
|
|
758
|
+
}
|
|
759
|
+
const rel = {
|
|
760
|
+
name: relationshipName(link.name, prefix),
|
|
761
|
+
source: { entity: source.name, columns: asArray(join.source?.fields) },
|
|
762
|
+
destination: { entity: destination.name, columns: asArray(join.target?.fields) },
|
|
763
|
+
};
|
|
764
|
+
if (join.description !== undefined)
|
|
765
|
+
rel.description = join.description;
|
|
766
|
+
out.push(rel);
|
|
767
|
+
}
|
|
768
|
+
return out;
|
|
769
|
+
}
|
|
770
|
+
// A stable dedup key for an entry link: its name when present, else its
|
|
771
|
+
// (order-independent) endpoint pair and type. Guards against a per-endpoint
|
|
772
|
+
// lookup returning an unnamed link once from each of its two endpoints.
|
|
773
|
+
function linkDedupKey(link) {
|
|
774
|
+
if (link.name)
|
|
775
|
+
return link.name;
|
|
776
|
+
const refs = (link.entryReferences ?? []).map(r => r.name).sort();
|
|
777
|
+
return `${refs.join('\0')}\0${link.entryLinkType ?? ''}`;
|
|
778
|
+
}
|
|
779
|
+
// Best-effort recovery of the relationship name from the link id, which the
|
|
780
|
+
// emitter built as linkSlug(`<model>-<name>`) (lowercased, with each underscore
|
|
781
|
+
// turned into a hyphen -- see knowledge_catalog.ts). Strips the model prefix
|
|
782
|
+
// when present. A relationship name has no hyphen, so each hyphen left is read
|
|
783
|
+
// back as an underscore; the name comes back lowercased.
|
|
784
|
+
function relationshipName(linkName, modelPrefix) {
|
|
785
|
+
const id = idOf(linkName ?? '');
|
|
786
|
+
const rest = modelPrefix && id.startsWith(`${modelPrefix}-`) ?
|
|
787
|
+
id.slice(modelPrefix.length + 1) :
|
|
788
|
+
'';
|
|
789
|
+
return (rest || id).replace(/-/g, '_');
|
|
790
|
+
}
|
|
791
|
+
// Mirrors the model-name portion of the emitter's linkSlug so the prefix a link
|
|
792
|
+
// id was built with can be stripped. Kept local rather than imported: this
|
|
793
|
+
// read-side module must not depend on the write-side emitter. Exported for the
|
|
794
|
+
// symmetry test, which reuses it to reproduce the normalized relationship name
|
|
795
|
+
// rather than reimplementing the slug rule.
|
|
796
|
+
//
|
|
797
|
+
// It reproduces linkSlug's normalization AND its 63-char cap + trailing-hyphen
|
|
798
|
+
// re-strip, so the prefix stays aligned with the id even when the combined
|
|
799
|
+
// `<model>-<name>` slug was truncated. (linkSlug's `|| 'link'` empty-slug
|
|
800
|
+
// fallback is intentionally omitted: an empty prefix strips nothing and
|
|
801
|
+
// relationshipName then returns the id verbatim, which is already correct. When
|
|
802
|
+
// the cap truncates into the model slug itself the relationship name is
|
|
803
|
+
// unrecoverable regardless -- relationshipName returns the truncated id.)
|
|
804
|
+
function linkNamePrefix(modelName) {
|
|
805
|
+
return modelName.toLowerCase()
|
|
806
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
807
|
+
.replace(/-+/g, '-')
|
|
808
|
+
.replace(/^[^a-z]+/, '')
|
|
809
|
+
.replace(/^-+|-+$/g, '')
|
|
810
|
+
.slice(0, 63)
|
|
811
|
+
.replace(/-+$/, '');
|
|
812
|
+
}
|
|
813
|
+
// The id segment of a full entry resource name (after the last '/').
|
|
814
|
+
function idOf(name) {
|
|
815
|
+
return name.split('/').pop() ?? name;
|
|
816
|
+
}
|
|
817
|
+
function asArray(value) {
|
|
818
|
+
return Array.isArray(value) ? value : [];
|
|
819
|
+
}
|
|
820
|
+
// The non-empty strings of an array value, dropping non-string and empty
|
|
821
|
+
// entries. Used for key / unique-constraint field lists from the schema aspect.
|
|
822
|
+
function stringList(value) {
|
|
823
|
+
return asArray(value).filter((s) => typeof s === 'string' && s !== '');
|
|
824
|
+
}
|