@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,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
+ }