@google-cloud/kcmd 0.0.0-stage → 0.2.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 +264 -2
- package/build/ts/kcmd/cli.js +20252 -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 +18 -0
- package/build/ts/kcmd/gcp/biglake.js +59 -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 +73 -0
- package/build/ts/kcmd/gcp/spanner.js +126 -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 +386 -0
- package/build/ts/kcmd/layouts/semantic-model.d.ts +74 -0
- package/build/ts/kcmd/layouts/semantic-model.js +621 -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 +110 -0
- package/build/ts/kcmd/okf/concept_to_entry.d.ts +10 -0
- package/build/ts/kcmd/okf/concept_to_entry.js +281 -0
- package/build/ts/kcmd/okf/init_okf_workspace.d.ts +15 -0
- package/build/ts/kcmd/okf/init_okf_workspace.js +113 -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 +99 -0
- package/build/ts/kcmd/semantic/bigquery.d.ts +22 -0
- package/build/ts/kcmd/semantic/bigquery.js +469 -0
- package/build/ts/kcmd/semantic/binding.d.ts +3 -0
- package/build/ts/kcmd/semantic/binding.js +92 -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 +104 -0
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.js +1367 -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 +44 -0
- package/build/ts/kcmd/semantic/graph_ddl.js +656 -0
- package/build/ts/kcmd/semantic/import_model.d.ts +72 -0
- package/build/ts/kcmd/semantic/import_model.js +1698 -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 +37 -0
- package/build/ts/kcmd/semantic/kc_converter.js +839 -0
- package/build/ts/kcmd/semantic/kc_custom_types.d.ts +63 -0
- package/build/ts/kcmd/semantic/kc_custom_types.js +751 -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 +102 -0
- package/build/ts/kcmd/semantic/kc_ids.js +188 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.d.ts +29 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.js +960 -0
- package/build/ts/kcmd/semantic/loader.d.ts +258 -0
- package/build/ts/kcmd/semantic/loader.js +3576 -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 +910 -0
- package/build/ts/kcmd/semantic/parameters.d.ts +19 -0
- package/build/ts/kcmd/semantic/parameters.js +171 -0
- package/build/ts/kcmd/semantic/pull_kc.d.ts +10 -0
- package/build/ts/kcmd/semantic/pull_kc.js +272 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.d.ts +59 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.js +346 -0
- package/build/ts/kcmd/semantic/resolve_profiles.d.ts +291 -0
- package/build/ts/kcmd/semantic/resolve_profiles.js +2003 -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 +88 -0
- package/build/ts/kcmd/semantic/runtime/runtime.js +301 -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 +17 -0
- package/build/ts/kcmd/semantic/source_dialect.js +311 -0
- package/build/ts/kcmd/semantic/source_entries.d.ts +12 -0
- package/build/ts/kcmd/semantic/source_entries.js +341 -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 +23 -0
- package/build/ts/kcmd/semantic/sql_expr_utils.js +394 -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 +47 -0
- package/build/ts/kcmd/semantic/sql_parser.js +400 -0
- package/build/ts/kcmd/semantic/transpile.d.ts +76 -0
- package/build/ts/kcmd/semantic/transpile.js +581 -0
- package/build/ts/kcmd/semantic/validate.d.ts +70 -0
- package/build/ts/kcmd/semantic/validate.js +2266 -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 +37 -0
- package/build/ts/kcmd/snapshot.js +386 -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 +734 -0
- package/package.json +62 -4
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Generates Knowledge Catalog resources from the Semantic Model IR.
|
|
3
|
+
//
|
|
4
|
+
// The IR (./ir) is pure semantics. This module is its Knowledge Catalog
|
|
5
|
+
// emitter, the counterpart to `bigquery.ts`: it maps the model to catalog
|
|
6
|
+
// Entries, each carrying the `semantic-*` Aspect(s) that describe it. Like
|
|
7
|
+
// `bigquery.ts` it is a PURE function of the IR: no GCP calls, no I/O. The
|
|
8
|
+
// orchestration layer (`deploy_knowledge_catalog.ts`, the counterpart to
|
|
9
|
+
// `deploy_bigquery.ts`) drives this emitter and writes the resulting resources
|
|
10
|
+
// via the Knowledge Catalog client.
|
|
11
|
+
//
|
|
12
|
+
// Target schema: the `semantic-model`, `semantic-entity`, and `semantic-metric`
|
|
13
|
+
// entry/aspect types are built-in system types under `dataplex-types/global`,
|
|
14
|
+
// alongside the built-in `schema` and `guidelines` aspects. This emitter
|
|
15
|
+
// references them; it never provisions them. Each entry type declares
|
|
16
|
+
// `required_aspects`; the emitted aspect set per entry is those plus the
|
|
17
|
+
// optional `guidelines` aspect (attached only when the object carries
|
|
18
|
+
// `ai_context.instructions`):
|
|
19
|
+
// * semantic-model entry -> { semantic-model, guidelines? }
|
|
20
|
+
// * semantic-entity entry -> { semantic-entity, schema, guidelines? }
|
|
21
|
+
// * semantic-metric entry -> { semantic-metric, guidelines? }
|
|
22
|
+
//
|
|
23
|
+
// An action (a write operation the model defines) and a constraint (an
|
|
24
|
+
// invariant the model states) are published the same way: one entry each,
|
|
25
|
+
// parented to the anchor. Their entry and aspect types are CUSTOM, because
|
|
26
|
+
// Dataplex has no built-in type for either yet, so `kcmd init` provisions the
|
|
27
|
+
// pairs in the destination project. `kc_custom_types.ts` declares those types
|
|
28
|
+
// and `kc_actions.ts` and `kc_constraints.ts` encode the aspects they carry.
|
|
29
|
+
// This module only appends the entries those two return.
|
|
30
|
+
//
|
|
31
|
+
// Aspect data shapes mirror the aspect types' CLOSED metadataTemplates exactly
|
|
32
|
+
// (a server aspect type rejects an undeclared data field):
|
|
33
|
+
// * semantic-model = { deploymentTargets: string[] }
|
|
34
|
+
// * semantic-entity = { source: { resources: string[], importedSystem?,
|
|
35
|
+
// importedResource? } }
|
|
36
|
+
// * semantic-metric = { entity?, dataType (required) }
|
|
37
|
+
// * schema = { fields: [{ name, dataType, metadataType,
|
|
38
|
+
// description?, annotations? }],
|
|
39
|
+
// primaryKey?: { fields }, uniqueConstraints?: [...] }
|
|
40
|
+
// * guidelines = { instructions, userManaged (required) }
|
|
41
|
+
//
|
|
42
|
+
// The SQL-expression fields -- `semantic-metric.expression` and the per-field
|
|
43
|
+
// `schema.semantics` { expression, role } block -- are NOT in the published
|
|
44
|
+
// system-type templates yet, so they are gated behind
|
|
45
|
+
// KcGenerateOptions.emitExpressions (off by default) and omitted above.
|
|
46
|
+
//
|
|
47
|
+
// With the second-generation types (v2Aspects on), the model entry carries
|
|
48
|
+
// semantic-model, each entity entry carries semantic-entity and schema, and
|
|
49
|
+
// each metric entry carries semantic-metric and sql-expressions. An entity
|
|
50
|
+
// entry has sql-expressions too when one of its fields has an expression.
|
|
51
|
+
// Model, entity, metric and relationship entries also carry guidelines,
|
|
52
|
+
// semantic-extensions and overview when the object has content for them. A
|
|
53
|
+
// field has no entry, so its guidelines, semantic-extensions and overview go
|
|
54
|
+
// on its entity's entry at the field's path. `guidelines` holds the object's
|
|
55
|
+
// instructions and, in `data`, its custom `ai_context` members.
|
|
56
|
+
//
|
|
57
|
+
// Relationships become `schema-join` entry links between the two entity entries.
|
|
58
|
+
// schema-join is a built-in, undirected entry link type in `dataplex-types/global`
|
|
59
|
+
// whose required `schema-join` aspect carries the join detail (the paired join
|
|
60
|
+
// columns, JOIN vs FOREIGN_KEY, USER inference). The join's direction -- which
|
|
61
|
+
// side holds the foreign key -- is preserved inside that aspect, not by the link.
|
|
62
|
+
// Many-to-many (association / junction-table) edges are not emitted yet: a
|
|
63
|
+
// junction is two joins through a third table, which schema-join's single
|
|
64
|
+
// source/target pair does not model; the emitter warns and skips them (the edge
|
|
65
|
+
// still lives in the BigQuery property graph, see graph_ddl.ts).
|
|
66
|
+
//
|
|
67
|
+
// With the second-generation types, each relationship is instead an entry,
|
|
68
|
+
// `<model>/relationships/<name>`, that carries semantic-relationship. A
|
|
69
|
+
// `semantic-link` entry link joins each ordered pair of related entities and
|
|
70
|
+
// lists their relationships in declaration order. A `reference` link runs
|
|
71
|
+
// from each relationship entry to each of its two entities, and from each
|
|
72
|
+
// entity entry to the entry of each parent it extends.
|
|
73
|
+
//
|
|
74
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
75
|
+
exports.generateCatalogResources = generateCatalogResources;
|
|
76
|
+
const deployment_target_1 = require("./deployment_target");
|
|
77
|
+
const kc_actions_1 = require("./kc_actions");
|
|
78
|
+
const kc_constraints_1 = require("./kc_constraints");
|
|
79
|
+
const kc_ids_1 = require("./kc_ids");
|
|
80
|
+
const loader_1 = require("./loader");
|
|
81
|
+
const osi_converter_1 = require("./osi_converter");
|
|
82
|
+
const resolve_inheritance_1 = require("./resolve_inheritance");
|
|
83
|
+
const resolve_profiles_1 = require("./resolve_profiles");
|
|
84
|
+
/**
|
|
85
|
+
* Generates the Knowledge Catalog resources for a semantic model.
|
|
86
|
+
*
|
|
87
|
+
* Returns the entries (model anchor first, then entities, metrics and, with
|
|
88
|
+
* v2Aspects, relationships), the entry links of the relationships and, with
|
|
89
|
+
* v2Aspects, of each entity's parents, and any warnings collected while
|
|
90
|
+
* mapping the IR (missing keys, un-typed metrics, skipped M:N relationships).
|
|
91
|
+
* The resources reference the built-in system types; they do not create them.
|
|
92
|
+
*/
|
|
93
|
+
function generateCatalogResources(model, opts) {
|
|
94
|
+
const warnings = [];
|
|
95
|
+
const entities = model.entities ?? [];
|
|
96
|
+
const metrics = model.metrics ?? [];
|
|
97
|
+
if (!entities.length) {
|
|
98
|
+
warnings.push('model has no entities; only the semantic-model entry will be generated');
|
|
99
|
+
}
|
|
100
|
+
const names = new kc_ids_1.Namer(opts);
|
|
101
|
+
const emitExpr = opts.emitExpressions ?? false;
|
|
102
|
+
const v2 = opts.v2Aspects ?? false;
|
|
103
|
+
if (v2)
|
|
104
|
+
warnings.push(...unstorableMemberWarnings(model));
|
|
105
|
+
// With the first-generation aspects, a subtype field that only rebinds an
|
|
106
|
+
// inherited field is published with the definition it inherits.
|
|
107
|
+
const inherited = v2 ? undefined : inheritedFields(model);
|
|
108
|
+
// Entry ids must be unique within the entry group. Two source names that
|
|
109
|
+
// normalize to the same id (see slug), or exact duplicates the loader did not
|
|
110
|
+
// reject, would otherwise emit two entries with the same name and have the
|
|
111
|
+
// later write silently overwrite the earlier. Track ids and skip a collision.
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
// The model entry is the anchor and the parent of every entity/metric entry,
|
|
114
|
+
// so it is created first: it is entries[0] and the publisher writes in array
|
|
115
|
+
// order. Reserve its id up front so nothing else can claim it.
|
|
116
|
+
const modelId = names.modelId(model);
|
|
117
|
+
const modelEntryName = names.entry(modelId);
|
|
118
|
+
claim(seen, modelId, 'entry', `model '${model.name}'`, warnings);
|
|
119
|
+
const entries = [{
|
|
120
|
+
name: modelEntryName,
|
|
121
|
+
entryType: names.typeName('entry', 'semantic-model'),
|
|
122
|
+
entrySource: source(model.name, model.description),
|
|
123
|
+
aspects: v2 ?
|
|
124
|
+
aspectMap(names, {
|
|
125
|
+
'semantic-model': modelAspectDataV2(model),
|
|
126
|
+
...objectAspectsV2(model),
|
|
127
|
+
}) :
|
|
128
|
+
aspectsFor(names, { 'semantic-model': modelAspectData(model) }, model.aiContext),
|
|
129
|
+
}];
|
|
130
|
+
// Maps an entity's model name to its published entry name, so a relationship
|
|
131
|
+
// can resolve its endpoints to the entries the link references. Only entities
|
|
132
|
+
// actually emitted (not skipped for a duplicate id) are recorded.
|
|
133
|
+
const entityEntryName = new Map();
|
|
134
|
+
for (const entity of entities) {
|
|
135
|
+
// The first-generation types do not model inheritance, so an abstract
|
|
136
|
+
// entity is skipped with a warning and an edge naming it gets no link.
|
|
137
|
+
if (entity.abstract && !v2) {
|
|
138
|
+
warnings.push(`entity '${entity.name}' is abstract (no physical table); skipped ` +
|
|
139
|
+
`for Knowledge Catalog (KC does not yet model class hierarchies)`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const entityId = names.entityId(model, entity);
|
|
143
|
+
if (!claim(seen, entityId, 'entry', `entity '${entity.name}'`, warnings))
|
|
144
|
+
continue;
|
|
145
|
+
entityEntryName.set(entity.name, names.entry(entityId));
|
|
146
|
+
if (v2 ? lacksKeys(model, entity) : !entity.keys || !entity.keys.length) {
|
|
147
|
+
warnings.push(`entity '${entity.name}': no keys declared in the source model; ` +
|
|
148
|
+
`declare primary_key or unique_keys in the model file or in a ` +
|
|
149
|
+
`profile to record a key in Knowledge Catalog`);
|
|
150
|
+
}
|
|
151
|
+
entries.push({
|
|
152
|
+
name: names.entry(entityId),
|
|
153
|
+
entryType: names.typeName('entry', 'semantic-entity'),
|
|
154
|
+
parentEntry: modelEntryName,
|
|
155
|
+
entrySource: source(entity.name, entity.description),
|
|
156
|
+
// semantic-entity and schema are required. The file header lists the
|
|
157
|
+
// optional aspects.
|
|
158
|
+
aspects: v2 ? aspectMap(names, entityAspectsV2(model, entity)) :
|
|
159
|
+
aspectsFor(names, {
|
|
160
|
+
'semantic-entity': entityAspectData(entity),
|
|
161
|
+
'schema': schemaAspectData(entity, emitExpr, inherited?.get(entity.name)),
|
|
162
|
+
}, entity.aiContext),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
for (const metric of metrics) {
|
|
166
|
+
const metricId = names.metricId(model, metric);
|
|
167
|
+
if (!claim(seen, metricId, 'entry', `metric '${metric.name}'`, warnings))
|
|
168
|
+
continue;
|
|
169
|
+
entries.push({
|
|
170
|
+
name: names.entry(metricId),
|
|
171
|
+
entryType: names.typeName('entry', 'semantic-metric'),
|
|
172
|
+
parentEntry: modelEntryName,
|
|
173
|
+
entrySource: source(metric.name, metric.description),
|
|
174
|
+
aspects: v2 ? aspectMap(names, metricAspectsV2(model, metric)) :
|
|
175
|
+
aspectsFor(names, { 'semantic-metric': metricAspectData(metric, emitExpr) }, metric.aiContext),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const v2Relationships = v2 ?
|
|
179
|
+
relationshipResourcesV2(names, model, modelEntryName, entityEntryName, warnings) :
|
|
180
|
+
undefined;
|
|
181
|
+
if (v2Relationships)
|
|
182
|
+
entries.push(...v2Relationships.entries);
|
|
183
|
+
// Each entry's sidecar fills the rest of its overview, beside the examples.
|
|
184
|
+
for (const [id, sidecar] of v2 ? opts.overviews ?? [] : []) {
|
|
185
|
+
const entry = entries.find(e => e.name === names.entry(id));
|
|
186
|
+
if (!entry) {
|
|
187
|
+
warnings.push(`sidecar '${id}.overview.md' names no entry of this model; it is ` +
|
|
188
|
+
`not pushed`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const examples = entry.aspects?.[names.aspectRef('overview')]?.data?.examples;
|
|
192
|
+
const overview = compact({ ...sidecar, examples });
|
|
193
|
+
if (Object.keys(overview).length) {
|
|
194
|
+
entry.aspects = { ...entry.aspects, ...aspectMap(names, { overview }) };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// The second-generation schema keeps a field a profile excludes, so
|
|
198
|
+
// actionEntries reads the model without exclusions.
|
|
199
|
+
const actionModel = v2 ? (0, resolve_profiles_1.withoutExclusions)(model) : model;
|
|
200
|
+
// One entry per action, alongside the entities and metrics. Actions have no
|
|
201
|
+
// built-in system type, so they reference the custom pair declared in
|
|
202
|
+
// `kc_custom_types.ts`, and `kc_actions.ts` fills the aspect; it returns
|
|
203
|
+
// nothing when the model declares no actions.
|
|
204
|
+
entries.push(...(0, kc_actions_1.actionEntries)(actionModel, modelId, {
|
|
205
|
+
project: opts.project,
|
|
206
|
+
entry: (id) => names.entry(id),
|
|
207
|
+
anchor: modelEntryName,
|
|
208
|
+
claim: (id, label) => claim(seen, id, 'entry', label, warnings),
|
|
209
|
+
// The entities the loop above gave an entry. With the first-generation
|
|
210
|
+
// types an abstract entity has none.
|
|
211
|
+
publishedEntities: new Set(entityEntryName.keys()),
|
|
212
|
+
}, warnings));
|
|
213
|
+
// One entry per constraint, on the same footing as an action: no built-in
|
|
214
|
+
// system type, so the custom pair declared in `kc_custom_types.ts`, filled by
|
|
215
|
+
// `kc_constraints.ts`. Nothing when the model declares no constraints.
|
|
216
|
+
entries.push(...(0, kc_constraints_1.constraintEntries)(model, modelId, {
|
|
217
|
+
project: opts.project,
|
|
218
|
+
entry: (id) => names.entry(id),
|
|
219
|
+
anchor: modelEntryName,
|
|
220
|
+
claim: (id, label) => claim(seen, id, 'entry', label, warnings),
|
|
221
|
+
}, warnings));
|
|
222
|
+
const entryLinks = v2Relationships ?
|
|
223
|
+
[
|
|
224
|
+
...v2Relationships.links,
|
|
225
|
+
...extendsLinks(names, model, entityEntryName),
|
|
226
|
+
] :
|
|
227
|
+
schemaJoinLinks(names, model, entityEntryName, warnings);
|
|
228
|
+
return {
|
|
229
|
+
entries,
|
|
230
|
+
entryLinks,
|
|
231
|
+
warnings: [...new Set(warnings)],
|
|
232
|
+
ownedPrefixes: (0, kc_ids_1.ownedEntryIdPrefixes)(model.name),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// The schema-join links of the first-generation types, one per relationship
|
|
236
|
+
// that `unpublishedReason` accepts and per pair of entities.
|
|
237
|
+
function schemaJoinLinks(names, model, entityEntryName, warnings) {
|
|
238
|
+
const entryLinks = [];
|
|
239
|
+
const seenLinks = new Set();
|
|
240
|
+
const seenPairs = new Map();
|
|
241
|
+
for (const rel of model.relationships ?? []) {
|
|
242
|
+
// A relationship that cannot be published gets the warning that says why,
|
|
243
|
+
// whatever its place in the model, so this check runs before the pair
|
|
244
|
+
// check below.
|
|
245
|
+
const reason = unpublishedReason(rel, entityEntryName);
|
|
246
|
+
if (reason) {
|
|
247
|
+
warnings.push(`relationship '${rel.name}': ${reason}`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
// Dataplex allows one link of a type between two entries, so the first
|
|
251
|
+
// relationship on a pair gets the link and the rest get a warning.
|
|
252
|
+
const pairKey = [rel.source.entity, rel.destination.entity].sort().join('|');
|
|
253
|
+
const firstRel = seenPairs.get(pairKey);
|
|
254
|
+
if (firstRel !== undefined) {
|
|
255
|
+
warnings.push(`relationship '${rel.name}': shares entities '${rel.source.entity}' and '${rel.destination.entity}' with relationship '${firstRel}'; ` +
|
|
256
|
+
`skipped because the catalog shows one join for those entities.`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// `unpublishedReason` has checked that both endpoint entities have an
|
|
260
|
+
// entry.
|
|
261
|
+
const ends = [
|
|
262
|
+
entityEntryName.get(rel.source.entity),
|
|
263
|
+
entityEntryName.get(rel.destination.entity),
|
|
264
|
+
];
|
|
265
|
+
const link = relationshipLink(names, model, rel, ends, seenLinks, warnings);
|
|
266
|
+
if (link) {
|
|
267
|
+
seenPairs.set(pairKey, rel.name);
|
|
268
|
+
entryLinks.push(link);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return entryLinks;
|
|
272
|
+
}
|
|
273
|
+
// Returns why `rel` cannot be published as a schema-join entry link, or
|
|
274
|
+
// undefined when it can. The reasons, in the order checked: a many-to-many
|
|
275
|
+
// (association) edge, since a junction is two joins, which the single
|
|
276
|
+
// source/target schema-join does not model; an edge whose endpoint entity was
|
|
277
|
+
// not emitted, for example one skipped for a duplicate id; and a column-less
|
|
278
|
+
// (purely logical) edge, whose join columns must be added to the model before
|
|
279
|
+
// it can publish. The BigQuery property graph still carries the association
|
|
280
|
+
// and, once bound, direct edges.
|
|
281
|
+
function unpublishedReason(rel, entityEntryName) {
|
|
282
|
+
if (rel.association)
|
|
283
|
+
return ASSOCIATION_REASON;
|
|
284
|
+
const missing = [rel.source.entity, rel.destination.entity].find(entity => !entityEntryName.get(entity));
|
|
285
|
+
if (missing !== undefined) {
|
|
286
|
+
return `endpoint entity '${missing}' is not a published entity; the ` +
|
|
287
|
+
`relationship link is skipped.`;
|
|
288
|
+
}
|
|
289
|
+
// A purely logical edge (an OWL import) carries no join columns. schema-join
|
|
290
|
+
// is a server-defined CLOSED metadataTemplate whose `fields` requirement we
|
|
291
|
+
// cannot depend on, so rather than risk a rejected aspect on a KC push we skip
|
|
292
|
+
// the link until the edge is bound. Add the relationship's from_columns /
|
|
293
|
+
// to_columns to the model and it publishes; the edge still lives in the model
|
|
294
|
+
// (and, once bound, the BigQuery/Spanner graph).
|
|
295
|
+
if (!rel.source.columns.length || !rel.destination.columns.length) {
|
|
296
|
+
return `no join columns, so it is not published to Knowledge Catalog ` +
|
|
297
|
+
`yet; add its from_columns and to_columns to the relationship in the ` +
|
|
298
|
+
`model to publish the link.`;
|
|
299
|
+
}
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
// Why an association relationship is not published.
|
|
303
|
+
const ASSOCIATION_REASON = `many-to-many (association) edges are not published to ` +
|
|
304
|
+
`Knowledge Catalog yet; the edge lives in the BigQuery property graph.`;
|
|
305
|
+
// The relationship entries and their links under the second-generation
|
|
306
|
+
// types: an entry per relationship except an association, a semantic-link per
|
|
307
|
+
// ordered entity pair listing its relationships in order, and a reference link
|
|
308
|
+
// from each relationship entry to each of its two entities.
|
|
309
|
+
function relationshipResourcesV2(names, model, modelEntryName, entityEntryName, warnings) {
|
|
310
|
+
const entries = [];
|
|
311
|
+
const references = [];
|
|
312
|
+
// The relationships of each ordered entity pair, by link ID.
|
|
313
|
+
const pairs = new Map();
|
|
314
|
+
// Push never reaches a name with no entry: validation rejects an unknown
|
|
315
|
+
// entity, and every entity has an entry under these types.
|
|
316
|
+
for (const rel of model.relationships ?? []) {
|
|
317
|
+
if (rel.association) {
|
|
318
|
+
warnings.push(`relationship '${rel.name}': ${ASSOCIATION_REASON}`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const id = names.relationshipId(model, rel);
|
|
322
|
+
entries.push({
|
|
323
|
+
name: names.entry(id),
|
|
324
|
+
entryType: names.typeName('entry', 'semantic-relationship'),
|
|
325
|
+
parentEntry: modelEntryName,
|
|
326
|
+
entrySource: source(rel.name, rel.description),
|
|
327
|
+
aspects: aspectMap(names, {
|
|
328
|
+
'semantic-relationship': relationshipAspectData(model, rel),
|
|
329
|
+
...objectAspectsV2(rel),
|
|
330
|
+
}),
|
|
331
|
+
});
|
|
332
|
+
const from = rel.source.entity;
|
|
333
|
+
const to = rel.destination.entity;
|
|
334
|
+
for (const [end, entity] of [['source', from], ['target', to]]) {
|
|
335
|
+
references.push(directedLink(names, 'reference', (0, kc_ids_1.semanticLinkId)('relationship', model.name, rel.name, end), names.entry(id), entityEntryName.get(entity)));
|
|
336
|
+
}
|
|
337
|
+
const linkId = (0, kc_ids_1.semanticLinkId)('link', model.name, from, to);
|
|
338
|
+
const pair = pairs.get(linkId) ?? { from, to, relationships: [] };
|
|
339
|
+
pair.relationships.push(rel.name);
|
|
340
|
+
pairs.set(linkId, pair);
|
|
341
|
+
}
|
|
342
|
+
const links = [...pairs].map(([linkId, { from, to, relationships }]) => ({
|
|
343
|
+
...directedLink(names, 'semantic-link', linkId, entityEntryName.get(from), entityEntryName.get(to)),
|
|
344
|
+
aspects: aspectMap(names, { 'semantic-link': { relationships } }),
|
|
345
|
+
}));
|
|
346
|
+
return { entries, links: [...links, ...references] };
|
|
347
|
+
}
|
|
348
|
+
// The reference link from each entity entry to the entry of each parent it
|
|
349
|
+
// extends.
|
|
350
|
+
function extendsLinks(names, model, entityEntryName) {
|
|
351
|
+
return (model.entities ?? []).flatMap(e => (e.extends ?? []).map(parent => directedLink(names, 'reference', (0, kc_ids_1.semanticLinkId)('extends', model.name, e.name, parent), entityEntryName.get(e.name), entityEntryName.get(parent))));
|
|
352
|
+
}
|
|
353
|
+
// A link of type `type` with ID `id` from the entry `from` to the entry `to`.
|
|
354
|
+
function directedLink(names, type, id, from, to) {
|
|
355
|
+
return {
|
|
356
|
+
name: names.entryLink(id),
|
|
357
|
+
entryLinkType: names.typeName('entryLink', type),
|
|
358
|
+
entryReferences: [{ name: from, type: 'SOURCE' }, { name: to, type: 'TARGET' }],
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// The semantic-relationship aspect, with join columns as written.
|
|
362
|
+
function relationshipAspectData(model, rel) {
|
|
363
|
+
const endpoint = (end) => compact({ name: end.entity, fields: (0, osi_converter_1.nonEmpty)(end.columns) });
|
|
364
|
+
const profiles = namedProfiles(model).flatMap(p => {
|
|
365
|
+
const bound = p.relationships?.find(r => r.name === rel.name);
|
|
366
|
+
if (!bound)
|
|
367
|
+
return [];
|
|
368
|
+
return [compact({
|
|
369
|
+
name: p.name,
|
|
370
|
+
sourceFields: (0, osi_converter_1.nonEmpty)(bound.fromColumns),
|
|
371
|
+
targetFields: (0, osi_converter_1.nonEmpty)(bound.toColumns),
|
|
372
|
+
})];
|
|
373
|
+
});
|
|
374
|
+
return compact({
|
|
375
|
+
source: endpoint(rel.source),
|
|
376
|
+
target: endpoint(rel.destination),
|
|
377
|
+
profiles: (0, osi_converter_1.nonEmpty)(profiles),
|
|
378
|
+
synonyms: (0, osi_converter_1.nonEmpty)(rel.aiContext?.synonyms),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
// Builds the schema-join entry link for a relationship that
|
|
382
|
+
// `unpublishedReason` accepts, or undefined when another resource already
|
|
383
|
+
// claimed its link id. `ends` holds the entry names of its source and
|
|
384
|
+
// destination entities.
|
|
385
|
+
function relationshipLink(names, model, rel, ends, seenLinks, warnings) {
|
|
386
|
+
const [src, dst] = ends;
|
|
387
|
+
const linkId = names.linkId(model, rel);
|
|
388
|
+
if (!claim(seenLinks, linkId, 'entry link', `relationship '${rel.name}'`, warnings))
|
|
389
|
+
return undefined;
|
|
390
|
+
// The name lives only in the link id (schema-join's aspect has no name
|
|
391
|
+
// field), and link ids are normalized -- lowercase, hyphens only. A pull that
|
|
392
|
+
// rebuilds the model reads each hyphen back as an underscore, so it returns
|
|
393
|
+
// the name lowercased; warn when that differs from the authored name.
|
|
394
|
+
const normalizedName = (0, kc_ids_1.linkSlug)(rel.name).replace(/-/g, '_');
|
|
395
|
+
if (normalizedName !== rel.name) {
|
|
396
|
+
warnings.push(`relationship '${rel.name}': Knowledge Catalog stores the name only ` +
|
|
397
|
+
`in the normalized link id, so a pull that rebuilds the model returns ` +
|
|
398
|
+
`it as '${normalizedName}'.`);
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
name: names.entryLink(linkId),
|
|
402
|
+
entryLinkType: names.typeName('entryLink', 'schema-join'),
|
|
403
|
+
// schema-join is undirected: both endpoints are UNSPECIFIED references. The
|
|
404
|
+
// join direction lives in the aspect (source is the foreign-key side).
|
|
405
|
+
entryReferences: [
|
|
406
|
+
{ name: src, type: 'UNSPECIFIED' },
|
|
407
|
+
{ name: dst, type: 'UNSPECIFIED' },
|
|
408
|
+
],
|
|
409
|
+
aspects: aspectMap(names, {
|
|
410
|
+
'schema-join': schemaJoinAspectData(model, rel),
|
|
411
|
+
}),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
// The schema-join aspect for a direct foreign key: a single join pairing the
|
|
415
|
+
// source (FK) columns to the destination (key) columns positionally. Each side's
|
|
416
|
+
// `name` is the entity's SQL table representation (its dataSource). `type` is
|
|
417
|
+
// FOREIGN_KEY (only direct FKs are emitted today); `inferenceSource` is USER
|
|
418
|
+
// because the join is author-declared, and `userManaged` stops Dataplex from
|
|
419
|
+
// overwriting it. Field names/nesting mirror the schema-join aspect type's
|
|
420
|
+
// metadataTemplate.
|
|
421
|
+
//
|
|
422
|
+
// Only bound edges reach here -- relationshipLink skips a column-less (logical)
|
|
423
|
+
// edge -- so `fields` is always present. The `name` still falls back to the
|
|
424
|
+
// entity name when the model is logical-only for KC (columns set but no
|
|
425
|
+
// dataSource binding), matching the entity-not-found fallback.
|
|
426
|
+
function schemaJoinAspectData(model, rel) {
|
|
427
|
+
const srcEntity = entityByName(model, rel.source.entity);
|
|
428
|
+
const dstEntity = entityByName(model, rel.destination.entity);
|
|
429
|
+
return {
|
|
430
|
+
joins: [compact({
|
|
431
|
+
source: compact({
|
|
432
|
+
name: srcEntity && srcEntity.dataSource ?
|
|
433
|
+
srcEntity.dataSource :
|
|
434
|
+
rel.source.entity,
|
|
435
|
+
fields: rel.source.columns,
|
|
436
|
+
}),
|
|
437
|
+
target: compact({
|
|
438
|
+
name: dstEntity && dstEntity.dataSource ?
|
|
439
|
+
dstEntity.dataSource :
|
|
440
|
+
rel.destination.entity,
|
|
441
|
+
fields: rel.destination.columns,
|
|
442
|
+
}),
|
|
443
|
+
description: rel.description,
|
|
444
|
+
type: 'FOREIGN_KEY',
|
|
445
|
+
inferenceSource: 'USER',
|
|
446
|
+
})],
|
|
447
|
+
userManaged: true,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function entityByName(model, name) {
|
|
451
|
+
return (model.entities ?? []).find(e => e.name === name);
|
|
452
|
+
}
|
|
453
|
+
// One warning for each custom `ai_context` member that `guidelines` leaves
|
|
454
|
+
// out, on the model, an entity, a field, a metric or a relationship.
|
|
455
|
+
function unstorableMemberWarnings(model) {
|
|
456
|
+
const warnings = [];
|
|
457
|
+
const check = (object, ai) => {
|
|
458
|
+
for (const [member, value] of Object.entries(ai?.additionalProperties ?? {})) {
|
|
459
|
+
const holds = unstorableValue(value);
|
|
460
|
+
if (holds) {
|
|
461
|
+
warnings.push(`${object}: ai_context member '${member}' is not pushed: its ` +
|
|
462
|
+
`value holds ${holds}, which Knowledge Catalog cannot store as ` +
|
|
463
|
+
`written`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
check(`model '${model.name}'`, model.aiContext);
|
|
468
|
+
for (const entity of model.entities ?? []) {
|
|
469
|
+
check(`entity '${entity.name}'`, entity.aiContext);
|
|
470
|
+
for (const field of entity.fields ?? []) {
|
|
471
|
+
check(`field '${entity.name}.${field.name}'`, field.aiContext);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
for (const metric of model.metrics ?? []) {
|
|
475
|
+
check(`metric '${metric.name}'`, metric.aiContext);
|
|
476
|
+
}
|
|
477
|
+
for (const rel of model.relationships ?? []) {
|
|
478
|
+
check(`relationship '${rel.name}'`, rel.aiContext);
|
|
479
|
+
}
|
|
480
|
+
return warnings;
|
|
481
|
+
}
|
|
482
|
+
// Names the first number in `value` that JSON cannot hold as the document
|
|
483
|
+
// wrote it: .inf, -.inf, .nan, or a number past 2^53, which loses digits when
|
|
484
|
+
// the model loads. Undefined when there is none.
|
|
485
|
+
function unstorableValue(value) {
|
|
486
|
+
if (typeof value === 'number') {
|
|
487
|
+
if (Number.isNaN(value))
|
|
488
|
+
return '.nan';
|
|
489
|
+
if (!Number.isFinite(value))
|
|
490
|
+
return value > 0 ? '.inf' : '-.inf';
|
|
491
|
+
return Number.isInteger(value) && !Number.isSafeInteger(value) ?
|
|
492
|
+
'a number past 2^53' :
|
|
493
|
+
undefined;
|
|
494
|
+
}
|
|
495
|
+
if (value === null || typeof value !== 'object')
|
|
496
|
+
return undefined;
|
|
497
|
+
return Object.values(value).map(unstorableValue).find(Boolean);
|
|
498
|
+
}
|
|
499
|
+
// Each entity's fields with inheritance resolved, by entity and field name, or
|
|
500
|
+
// undefined when no entity extends another or inheritance cannot be resolved.
|
|
501
|
+
// A resolved field keeps what the entity's own declaration sets and takes the
|
|
502
|
+
// rest from the ancestor that defines it.
|
|
503
|
+
function inheritedFields(model) {
|
|
504
|
+
if (!(model.entities ?? []).some(e => e.extends?.length))
|
|
505
|
+
return undefined;
|
|
506
|
+
let resolved;
|
|
507
|
+
try {
|
|
508
|
+
resolved = (0, resolve_inheritance_1.resolveInheritance)(model).model;
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
// Push validation reports it. The schema aspect keeps what each entity
|
|
512
|
+
// declares.
|
|
513
|
+
if (err instanceof resolve_inheritance_1.InheritanceError)
|
|
514
|
+
return undefined;
|
|
515
|
+
throw err;
|
|
516
|
+
}
|
|
517
|
+
return new Map((resolved.entities ?? []).map(e => [e.name, new Map((e.fields ?? []).map(f => [f.name, f]))]));
|
|
518
|
+
}
|
|
519
|
+
// The fields `entity` declares, leaving out each line that only rebinds a
|
|
520
|
+
// field it inherits.
|
|
521
|
+
function declaredFields(model, entity) {
|
|
522
|
+
const inherited = (0, resolve_profiles_1.inheritedNamesOf)(model, entity.name);
|
|
523
|
+
return entity.fields.filter(f => !inherited.has(f.name));
|
|
524
|
+
}
|
|
525
|
+
// True when `entity` is concrete and states no primary key and no unique key,
|
|
526
|
+
// in its own bindings or in any named profile.
|
|
527
|
+
function lacksKeys(model, entity) {
|
|
528
|
+
const bindings = namedProfiles(model).flatMap(p => p.entities.filter(b => b.name === entity.name));
|
|
529
|
+
return !entity.abstract && !(0, resolve_profiles_1.statesKey)(entity.keys, entity.uniqueKeys) &&
|
|
530
|
+
!bindings.some(b => (0, resolve_profiles_1.statesKey)(b.primaryKey, b.uniqueKeys));
|
|
531
|
+
}
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
// Aspect-data mappers. Field names/nesting mirror the server aspect types'
|
|
534
|
+
// CLOSED metadataTemplates exactly (see file header); the golden tests pin
|
|
535
|
+
// these shapes so any schema-driven change is a visible, reviewable diff.
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
// semantic-model: every deployment target this model declares, in declaration
|
|
538
|
+
// order. Recording all declared targets keeps the entry faithful so a later
|
|
539
|
+
// `kcmd pull` reconstructs `deployments` in the order the author wrote them.
|
|
540
|
+
// Empty data is valid; the aspect is still attached to satisfy
|
|
541
|
+
// required_aspects.
|
|
542
|
+
function modelAspectData(model) {
|
|
543
|
+
const { uris } = (0, deployment_target_1.googleDeploymentTargets)(model);
|
|
544
|
+
return compact({
|
|
545
|
+
deploymentTargets: uris.length ? uris : undefined,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
// semantic-entity: the base table backing the entity. A source is stored as
|
|
549
|
+
// written, its GCP resource name, in `resources`.
|
|
550
|
+
function entityAspectData(entity) {
|
|
551
|
+
// A logical-only entity has no physical table (empty dataSource). Still emit
|
|
552
|
+
// the `source` block with an empty `resources` array rather than a bogus
|
|
553
|
+
// ['']: the semantic-entity template requires `source` (with its `resources`
|
|
554
|
+
// list), so omitting it is rejected server-side. An empty list is the honest
|
|
555
|
+
// "no binding yet"; the reader treats it the same as an absent source
|
|
556
|
+
// (dataSource becomes '').
|
|
557
|
+
const authored = entity.authoredSource?.trim();
|
|
558
|
+
if (authored !== undefined && (0, loader_1.isAcceptedSource)(authored)) {
|
|
559
|
+
// Stored as written, so pull returns it unchanged.
|
|
560
|
+
return { source: { resources: [authored] } };
|
|
561
|
+
}
|
|
562
|
+
// A legacy instance-based AlloyDB URI is stored as written too.
|
|
563
|
+
const legacy = authored !== undefined &&
|
|
564
|
+
(0, loader_1.asResourceUri)(authored) === entity.dataSource &&
|
|
565
|
+
entity.dataSource.startsWith('//');
|
|
566
|
+
const path = resourcePath(legacy ? authored : entity.dataSource);
|
|
567
|
+
return compact({
|
|
568
|
+
source: { resources: path ? [path] : [] },
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
// The built-in schema aspect, carrying each field's column type and display
|
|
572
|
+
// label, the entity's primary/unique keys, plus the gated per-field `semantics`
|
|
573
|
+
// block (expression / role). name, dataType, and metadataType are required per
|
|
574
|
+
// column. `inherited` holds the entity's fields with inheritance resolved; a
|
|
575
|
+
// field that sets no datatype, description or label of its own takes the one
|
|
576
|
+
// it inherits from there.
|
|
577
|
+
function schemaAspectData(entity, emitExpressions, inherited) {
|
|
578
|
+
// Key column lists ride the schema aspect verbatim -- they need NOT be
|
|
579
|
+
// declared fields (the closed template accepts and round-trips columns absent
|
|
580
|
+
// from fields[], live-verified). Keep only non-empty strings, matching the
|
|
581
|
+
// reader's stringList, so a degenerate '' member does not round-trip
|
|
582
|
+
// asymmetrically; a set that is empty after filtering is then dropped.
|
|
583
|
+
const keyFields = (cols) => (cols ?? []).filter(c => typeof c === 'string' && c !== '');
|
|
584
|
+
const primaryKey = keyFields(entity.keys);
|
|
585
|
+
const uniqueConstraints = (entity.uniqueKeys ?? []).map(keyFields).filter(set => set.length);
|
|
586
|
+
// A field a binding profile excludes on this entity is not published for it
|
|
587
|
+
// (see Entity.excludedFields).
|
|
588
|
+
const excluded = new Set(entity.excludedFields ?? []);
|
|
589
|
+
// A subtype's redeclaration of an inherited field only rebinds it, so its
|
|
590
|
+
// definition is the inherited one.
|
|
591
|
+
const withDefinition = (f) => {
|
|
592
|
+
const from = inherited?.get(f.name);
|
|
593
|
+
return {
|
|
594
|
+
...f,
|
|
595
|
+
type: f.type ?? from?.type,
|
|
596
|
+
description: f.description ?? from?.description,
|
|
597
|
+
label: f.label ?? from?.label,
|
|
598
|
+
};
|
|
599
|
+
};
|
|
600
|
+
return compact({
|
|
601
|
+
fields: (entity.fields ?? [])
|
|
602
|
+
.filter(f => !excluded.has(f.name))
|
|
603
|
+
.map(withDefinition)
|
|
604
|
+
.map(f => compact({
|
|
605
|
+
name: f.name,
|
|
606
|
+
// An untyped field is published as Opaque (STRING +
|
|
607
|
+
// metadataType OTHER), the explicit "type unknown"
|
|
608
|
+
// marker, so a pull recovers it as Opaque rather than
|
|
609
|
+
// dropping the type. Authored `String` maps to STRING.
|
|
610
|
+
dataType: columnDataType(f.type ?? 'Opaque'),
|
|
611
|
+
metadataType: columnMetadataType(f.type ?? 'Opaque'),
|
|
612
|
+
description: f.description,
|
|
613
|
+
// A field's display label rides in the schema field's
|
|
614
|
+
// `annotations` map (the template's free-form
|
|
615
|
+
// string->string map) -- the one slot for metadata the
|
|
616
|
+
// template has no dedicated column for. Omitted when the
|
|
617
|
+
// field has no label.
|
|
618
|
+
annotations: f.label ? { label: f.label } : undefined,
|
|
619
|
+
// The per-field `semantics` block (expression + role) is
|
|
620
|
+
// not in the published `schema` aspect template yet, so
|
|
621
|
+
// it is gated off by default (see
|
|
622
|
+
// KcGenerateOptions.emitExpressions); compact() then
|
|
623
|
+
// drops the undefined key.
|
|
624
|
+
semantics: emitExpressions ? compact({
|
|
625
|
+
expression: f.expression,
|
|
626
|
+
// A field with any dimension metadata is a dimension;
|
|
627
|
+
// otherwise DEFAULT.
|
|
628
|
+
role: f.dimension ? 'DIMENSION' : 'DEFAULT',
|
|
629
|
+
}) : undefined,
|
|
630
|
+
})),
|
|
631
|
+
// The entity's grain / primary key -> the schema aspect's primaryKey.fields
|
|
632
|
+
// (ordered, so a composite key's ordinal positions round-trip). Omitted
|
|
633
|
+
// when the entity declares no (non-empty) keys.
|
|
634
|
+
primaryKey: primaryKey.length ? { fields: primaryKey } : undefined,
|
|
635
|
+
// Additional uniqueness constraints beyond the primary key -> one
|
|
636
|
+
// uniqueConstraints entry per non-empty unique column set. Omitted when
|
|
637
|
+
// there are none.
|
|
638
|
+
uniqueConstraints: uniqueConstraints.length ?
|
|
639
|
+
uniqueConstraints.map(fields => ({ fields })) :
|
|
640
|
+
undefined,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
// The built-in guidelines aspect: routes an object's `ai_context.instructions`
|
|
644
|
+
// -- free-form guidance for AI consumers -- to the aspect's `instructions`
|
|
645
|
+
// richText field. `userManaged` is required and always true: these guidelines
|
|
646
|
+
// are author-declared, so Dataplex must not overwrite them. Only `instructions`
|
|
647
|
+
// is routed here; `ai_context.synonyms`/`examples` have no home in this aspect.
|
|
648
|
+
// Returns undefined (attach no aspect) when there are no instructions.
|
|
649
|
+
function guidelinesAspectData(ai) {
|
|
650
|
+
const instructions = ai?.instructions;
|
|
651
|
+
if (instructions === undefined || instructions === '')
|
|
652
|
+
return undefined;
|
|
653
|
+
return { instructions, userManaged: true };
|
|
654
|
+
}
|
|
655
|
+
// semantic-metric: the model-level aggregate. `dataType` is required by the
|
|
656
|
+
// aspect type; a metric the author left untyped is published as Opaque -- the
|
|
657
|
+
// explicit "type unknown" marker -- rather than guessing a numeric type. (The
|
|
658
|
+
// metric aspect template carries only `dataType`, not a metadataType, so Opaque
|
|
659
|
+
// serializes as STRING and a pull recovers the metric untyped; once the
|
|
660
|
+
// template gains a metadataType it can round-trip as an explicit Opaque.)
|
|
661
|
+
function metricAspectData(metric, emitExpressions) {
|
|
662
|
+
const dataType = columnDataType(metric.type ?? 'Opaque');
|
|
663
|
+
return compact({
|
|
664
|
+
entity: metric.entity,
|
|
665
|
+
dataType,
|
|
666
|
+
// `expression` is not in the published semantic-metric aspect template yet;
|
|
667
|
+
// gated off by default (see KcGenerateOptions.emitExpressions).
|
|
668
|
+
expression: emitExpressions ? metric.expression : undefined,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
// The guidelines, semantic-extensions and overview aspects of an object's
|
|
672
|
+
// entry, each left out when the object has nothing for it. For a field's
|
|
673
|
+
// aspects, `at` is `@Schema.<field>`, and each key ends in it.
|
|
674
|
+
function objectAspectsV2(object, at = '') {
|
|
675
|
+
const examples = object.aiContext?.examples ?? [];
|
|
676
|
+
return compact({
|
|
677
|
+
[`guidelines${at}`]: guidelinesAspectDataV2(object.aiContext),
|
|
678
|
+
[`semantic-extensions${at}`]: extensionsAspectData(object.customExtensions),
|
|
679
|
+
[`overview${at}`]: examples.length ?
|
|
680
|
+
{ examples: examples.map(content => ({ content })) } :
|
|
681
|
+
undefined,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
// The guidelines aspect under the second-generation types: the object's
|
|
685
|
+
// instructions, and in `data` its custom ai_context members by name, leaving
|
|
686
|
+
// out those `unstorableValue` names. Undefined when the object has neither.
|
|
687
|
+
function guidelinesAspectDataV2(ai) {
|
|
688
|
+
const members = Object.entries(ai?.additionalProperties ?? {})
|
|
689
|
+
.filter(([, value]) => !unstorableValue(value));
|
|
690
|
+
const data = members.length ? Object.fromEntries(members) : undefined;
|
|
691
|
+
const instructions = ai?.instructions || undefined;
|
|
692
|
+
if (!instructions && !data)
|
|
693
|
+
return undefined;
|
|
694
|
+
return compact({ instructions, data, userManaged: true });
|
|
695
|
+
}
|
|
696
|
+
// semantic-extensions: every extension block from a vendor other than GOOGLE,
|
|
697
|
+
// as written and in order. Undefined when there is none.
|
|
698
|
+
function extensionsAspectData(extensions) {
|
|
699
|
+
const blocks = (extensions ?? []).filter(x => x.vendorName !== 'GOOGLE');
|
|
700
|
+
return blocks.length ? { customExtensions: blocks } : undefined;
|
|
701
|
+
}
|
|
702
|
+
// The semantic-model aspect of the second-generation types.
|
|
703
|
+
function modelAspectDataV2(model) {
|
|
704
|
+
const declared = model.deployments ?? [];
|
|
705
|
+
const deployments = declared.map(d => compact({
|
|
706
|
+
name: d.name,
|
|
707
|
+
target: d.target,
|
|
708
|
+
profile: d.profile === resolve_profiles_1.DEFAULT_PROFILE ? undefined : d.profile,
|
|
709
|
+
}));
|
|
710
|
+
return compact({
|
|
711
|
+
specVersion: model.version ?? loader_1.GOOGLE_VERSION,
|
|
712
|
+
synonyms: (0, osi_converter_1.nonEmpty)(model.aiContext?.synonyms),
|
|
713
|
+
profiles: (0, osi_converter_1.nonEmpty)(namedProfiles(model).map(p => ({ name: p.name }))),
|
|
714
|
+
deployments: (0, osi_converter_1.nonEmpty)(deployments),
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
// The aspects of an entity entry under the second-generation types. Its schema
|
|
718
|
+
// lists the fields it declares, each with its own aspects at its path.
|
|
719
|
+
function entityAspectsV2(model, entity) {
|
|
720
|
+
const fields = declaredFields(model, entity);
|
|
721
|
+
return compact({
|
|
722
|
+
'semantic-entity': entityAspectDataV2(model, entity),
|
|
723
|
+
'schema': schemaAspectDataV2(fields),
|
|
724
|
+
...objectAspectsV2(entity),
|
|
725
|
+
...Object.assign({}, ...fields.map(f => objectAspectsV2(f, `@Schema.${f.name}`))),
|
|
726
|
+
'sql-expressions': fieldExpressionsData(model, entity),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
// The built-in schema aspect under the second-generation types, one record per
|
|
730
|
+
// field. `is_time` is written only when the document declares it.
|
|
731
|
+
function schemaAspectDataV2(fields) {
|
|
732
|
+
return {
|
|
733
|
+
fields: fields.map(f => {
|
|
734
|
+
const type = f.type ?? 'Opaque';
|
|
735
|
+
const annotations = compact({
|
|
736
|
+
label: f.label || undefined,
|
|
737
|
+
is_time: f.dimension?.isTime?.toString(),
|
|
738
|
+
});
|
|
739
|
+
return compact({
|
|
740
|
+
name: f.name,
|
|
741
|
+
description: f.description,
|
|
742
|
+
dataType: type,
|
|
743
|
+
metadataType: columnMetadataType(type),
|
|
744
|
+
semantic: f.dimension ? 'DIMENSION' : undefined,
|
|
745
|
+
synonyms: (0, osi_converter_1.nonEmpty)(f.aiContext?.synonyms),
|
|
746
|
+
annotations: Object.keys(annotations).length ? annotations : undefined,
|
|
747
|
+
});
|
|
748
|
+
}),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
// semantic-entity under the second-generation types: the entity's source and
|
|
752
|
+
// keys as written, its direct parents, whether it is abstract, its synonyms,
|
|
753
|
+
// and one record per named profile that lists it, holding what that profile
|
|
754
|
+
// states. Anything empty is left out.
|
|
755
|
+
function entityAspectDataV2(model, entity) {
|
|
756
|
+
const profiles = namedProfiles(model).flatMap(p => {
|
|
757
|
+
const bound = p.entities.find(b => b.name === entity.name);
|
|
758
|
+
if (!bound)
|
|
759
|
+
return [];
|
|
760
|
+
return [compact({
|
|
761
|
+
name: p.name,
|
|
762
|
+
source: bound.source ? { resource: bound.source } : undefined,
|
|
763
|
+
excludedFields: (0, osi_converter_1.nonEmpty)(bound.fieldsExclude),
|
|
764
|
+
...keysData(bound.primaryKey, bound.uniqueKeys),
|
|
765
|
+
})];
|
|
766
|
+
});
|
|
767
|
+
return compact({
|
|
768
|
+
source: entity.authoredSource ? { resource: entity.authoredSource } :
|
|
769
|
+
undefined,
|
|
770
|
+
...keysData(entity.keys, entity.uniqueKeys),
|
|
771
|
+
profiles: (0, osi_converter_1.nonEmpty)(profiles),
|
|
772
|
+
extends: (0, osi_converter_1.nonEmpty)(entity.extends),
|
|
773
|
+
abstract: entity.abstract || undefined,
|
|
774
|
+
synonyms: (0, osi_converter_1.nonEmpty)(entity.aiContext?.synonyms),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
// The `primaryKey` and `uniqueConstraints` of semantic-entity or a profile
|
|
778
|
+
// record, each undefined when empty. A unique key with no column is left out.
|
|
779
|
+
function keysData(primaryKey, uniqueKeys) {
|
|
780
|
+
return {
|
|
781
|
+
primaryKey: primaryKey?.length ? { fields: [...primaryKey] } : undefined,
|
|
782
|
+
uniqueConstraints: (0, osi_converter_1.nonEmpty)(uniqueKeys?.filter(k => k.length).map(k => ({ fields: [...k] }))),
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
// The sql-expressions aspect of an entity entry: the records of the entity's
|
|
786
|
+
// own fields, then those of each named profile that lists it, with the
|
|
787
|
+
// profile's name in `qualifier`. Returns undefined when there is no record.
|
|
788
|
+
function fieldExpressionsData(model, entity) {
|
|
789
|
+
const records = (fields, qualifier) => fields.flatMap(f => expressionRecords(f).map(r => compact({ name: f.name, qualifier, ...r })));
|
|
790
|
+
const fieldExpressions = [
|
|
791
|
+
...records(entity.fields),
|
|
792
|
+
...namedProfiles(model).flatMap(p => records(p.entities.find(b => b.name === entity.name)?.fields ?? [], p.name)),
|
|
793
|
+
];
|
|
794
|
+
return fieldExpressions.length ? { fieldExpressions } : undefined;
|
|
795
|
+
}
|
|
796
|
+
// The aspects of a metric entry under the second-generation types.
|
|
797
|
+
function metricAspectsV2(model, metric) {
|
|
798
|
+
const expressions = expressionRecords(metric);
|
|
799
|
+
return compact({
|
|
800
|
+
'semantic-metric': metricAspectDataV2(model, metric),
|
|
801
|
+
...objectAspectsV2(metric),
|
|
802
|
+
'sql-expressions': expressions.length ? { expressions } : undefined,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
// The semantic-metric aspect of the second-generation types. `entity` is the
|
|
806
|
+
// metric's entity, declared or derived.
|
|
807
|
+
function metricAspectDataV2(model, metric) {
|
|
808
|
+
const type = metric.type ?? 'Opaque';
|
|
809
|
+
const excludedIn = namedProfiles(model)
|
|
810
|
+
.filter(p => p.metricsExclude === '*' ||
|
|
811
|
+
p.metricsExclude?.includes(metric.name))
|
|
812
|
+
.map(p => p.name);
|
|
813
|
+
return compact({
|
|
814
|
+
entity: metric.entity,
|
|
815
|
+
dataType: type,
|
|
816
|
+
metadataType: columnMetadataType(type),
|
|
817
|
+
synonyms: (0, osi_converter_1.nonEmpty)(metric.aiContext?.synonyms),
|
|
818
|
+
excludedInProfiles: (0, osi_converter_1.nonEmpty)(excludedIn),
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
// The records of one expression, in the order written. A plain string gives
|
|
822
|
+
// one record with no `dialect`, so a pull can tell it from a dialect list.
|
|
823
|
+
function expressionRecords({ dialects = [], stringForm }) {
|
|
824
|
+
return stringForm ?
|
|
825
|
+
[{ sql: dialects[0].expression }] :
|
|
826
|
+
dialects.map(d => ({ dialect: d.dialect, sql: d.expression }));
|
|
827
|
+
}
|
|
828
|
+
// The model's named profiles, in declaration order. The default profile has no
|
|
829
|
+
// record of its own.
|
|
830
|
+
function namedProfiles(model) {
|
|
831
|
+
return (model.profiles ?? []).filter(p => p.name !== resolve_profiles_1.DEFAULT_PROFILE);
|
|
832
|
+
}
|
|
833
|
+
// Maps the IR's logical DataType to a column data-type string for the schema
|
|
834
|
+
// aspect's required `dataType`. A conventional GoogleSQL type name; the field
|
|
835
|
+
// is a free string server-side. Unknown/undefined falls back to STRING.
|
|
836
|
+
function columnDataType(type) {
|
|
837
|
+
switch (type) {
|
|
838
|
+
case 'Integer':
|
|
839
|
+
return 'INT64';
|
|
840
|
+
case 'Decimal':
|
|
841
|
+
return 'NUMERIC';
|
|
842
|
+
case 'Float':
|
|
843
|
+
return 'FLOAT64';
|
|
844
|
+
case 'Boolean':
|
|
845
|
+
return 'BOOL';
|
|
846
|
+
case 'Date':
|
|
847
|
+
return 'DATE';
|
|
848
|
+
case 'Time':
|
|
849
|
+
return 'TIME';
|
|
850
|
+
case 'DateTime':
|
|
851
|
+
return 'DATETIME';
|
|
852
|
+
case 'DateTimeTz':
|
|
853
|
+
return 'TIMESTAMP';
|
|
854
|
+
case 'String':
|
|
855
|
+
case 'Opaque':
|
|
856
|
+
default:
|
|
857
|
+
return 'STRING';
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// Maps the IR's logical DataType to the schema aspect's required `metadataType`
|
|
861
|
+
// enum (BOOLEAN/NUMBER/STRING/BYTES/DATETIME/TIMESTAMP/GEOSPATIAL/STRUCT/RANGE/
|
|
862
|
+
// OTHER). Numeric types collapse to NUMBER; unknown/undefined falls back to
|
|
863
|
+
// STRING (the loader leaves most fields un-typed).
|
|
864
|
+
function columnMetadataType(type) {
|
|
865
|
+
switch (type) {
|
|
866
|
+
case 'Integer':
|
|
867
|
+
case 'Decimal':
|
|
868
|
+
case 'Float':
|
|
869
|
+
return 'NUMBER';
|
|
870
|
+
case 'Boolean':
|
|
871
|
+
return 'BOOLEAN';
|
|
872
|
+
case 'Date':
|
|
873
|
+
case 'Time':
|
|
874
|
+
case 'DateTime':
|
|
875
|
+
return 'DATETIME';
|
|
876
|
+
case 'DateTimeTz':
|
|
877
|
+
return 'TIMESTAMP';
|
|
878
|
+
case 'Opaque':
|
|
879
|
+
return 'OTHER';
|
|
880
|
+
case 'String':
|
|
881
|
+
default:
|
|
882
|
+
return 'STRING';
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// Maps the IR's opaque, canonical `dataSource` to a catalog resource string. A
|
|
886
|
+
// clean three-part `project.dataset.table` becomes the BigQuery table's GCP
|
|
887
|
+
// resource name, the form a source is written as; anything else (a query, an
|
|
888
|
+
// under/over-qualified ref) is passed through verbatim so nothing is lost.
|
|
889
|
+
// TODO: settle the linked-resource form for Iceberg / BigLake tables, which may
|
|
890
|
+
// need a different URI shape than the BigQuery managed-table one below.
|
|
891
|
+
function resourcePath(dataSource) {
|
|
892
|
+
const trimmed = (dataSource ?? '').trim();
|
|
893
|
+
if (!trimmed || /\s/.test(trimmed) || trimmed.includes('/') ||
|
|
894
|
+
trimmed.includes(':')) {
|
|
895
|
+
return trimmed;
|
|
896
|
+
}
|
|
897
|
+
const parts = trimmed.split('.').map(unquote);
|
|
898
|
+
if (parts.length === 3 && parts.every(p => p.length)) {
|
|
899
|
+
return `bigquery.googleapis.com/projects/${parts[0]}/datasets/${parts[1]}/tables/${parts[2]}`;
|
|
900
|
+
}
|
|
901
|
+
return trimmed;
|
|
902
|
+
}
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
// Naming and small helpers.
|
|
905
|
+
// ---------------------------------------------------------------------------
|
|
906
|
+
// Wraps aspect data (keyed by bare type id, with an optional `@<path>`) as the
|
|
907
|
+
// client's aspect map: each key is the `project.location.type` reference form
|
|
908
|
+
// with the same path, each value the fully-qualified aspectType plus its data.
|
|
909
|
+
function aspectMap(names, byType) {
|
|
910
|
+
const out = {};
|
|
911
|
+
for (const [key, data] of Object.entries(byType)) {
|
|
912
|
+
const type = key.split('@')[0];
|
|
913
|
+
out[names.aspectRef(type) + key.slice(type.length)] = {
|
|
914
|
+
aspectType: names.typeName('aspect', type),
|
|
915
|
+
data
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
return out;
|
|
919
|
+
}
|
|
920
|
+
// An entry's aspect map: its required `base` aspects, plus the optional
|
|
921
|
+
// built-in `guidelines` aspect when the object carries
|
|
922
|
+
// `ai_context.instructions`. Kept in one place so every entry kind
|
|
923
|
+
// (model/entity/metric) routes instructions the same way and an object without
|
|
924
|
+
// instructions carries no empty guidelines aspect.
|
|
925
|
+
function aspectsFor(names, base, ai) {
|
|
926
|
+
const guidelines = guidelinesAspectData(ai);
|
|
927
|
+
return aspectMap(names, guidelines ? { ...base, guidelines } : base);
|
|
928
|
+
}
|
|
929
|
+
// The native catalog entry source (display name + description), separate from
|
|
930
|
+
// the semantic-* aspect copy that carries full model fidelity.
|
|
931
|
+
function source(displayName, description) {
|
|
932
|
+
return compact({ displayName, description });
|
|
933
|
+
}
|
|
934
|
+
// Drops undefined-valued keys so the emitted JSON (and its golden) only shows
|
|
935
|
+
// fields the model actually set — matching how the BigQuery emitter omits empty
|
|
936
|
+
// OPTIONS rather than rendering blanks.
|
|
937
|
+
function compact(obj) {
|
|
938
|
+
const out = {};
|
|
939
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
940
|
+
if (v !== undefined)
|
|
941
|
+
out[k] = v;
|
|
942
|
+
}
|
|
943
|
+
return out;
|
|
944
|
+
}
|
|
945
|
+
// Records a generated entry id in `seen`, returning true when it is new. On a
|
|
946
|
+
// repeat it warns and returns false so the caller skips that resource: two
|
|
947
|
+
// entries sharing one name would otherwise have the later write silently
|
|
948
|
+
// overwrite the earlier one on publish.
|
|
949
|
+
function claim(seen, id, kind, label, warnings) {
|
|
950
|
+
if (seen.has(id)) {
|
|
951
|
+
warnings.push(`${label}: generated ${kind} id '${id}' duplicates an earlier one; ` +
|
|
952
|
+
`skipped (rename to avoid overwriting it on publish)`);
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
seen.add(id);
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
function unquote(part) {
|
|
959
|
+
return part.replace(/^[`"]/, '').replace(/[`"]$/, '');
|
|
960
|
+
}
|