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