@google-cloud/kcmd 0.0.0-stage → 0.1.0

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