@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,656 @@
1
+ "use strict";
2
+ // Generates GoogleSQL property-graph DDL from the Semantic Model IR, for any
3
+ // engine that takes the GoogleSQL `CREATE PROPERTY GRAPH` statement.
4
+ //
5
+ // The IR (./ir) is pure semantics. This module emits a single `CREATE OR
6
+ // REPLACE PROPERTY GRAPH` statement over the entities' existing base tables:
7
+ // node tables, edge tables, labels, properties, and key and join columns. Both
8
+ // BigQuery and Spanner take that same statement. A `GraphEngine` carries what
9
+ // differs between the engines:
10
+ //
11
+ // 1. Names. How a table and the graph itself are named (BigQuery writes
12
+ // `project.dataset.table` and qualifies the graph name; a Spanner graph
13
+ // lives in one database and names both bare).
14
+ // 2. Measures. Which metrics become properties (BigQuery lowers each to a
15
+ // `MEASURE(...)`; Spanner Graph has none).
16
+ // 3. Element metadata. The per-element `OPTIONS` clause carrying
17
+ // descriptions and synonyms (BigQuery has one; Spanner has none).
18
+ // 4. SQL text. Which SQL a field binds to: the engine's own dialect entry
19
+ // (`BIGQUERY` or `SPANNER`), falling back to the `ANSI_SQL` one. A field
20
+ // that names a sibling field reads that sibling's SQL in its place (see
21
+ // fieldSql).
22
+ //
23
+ // ./bigquery supplies the BigQuery engine, and ./spanner the Spanner engine.
24
+ //
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.generateGraphDdl = generateGraphDdl;
27
+ exports.nodeKey = nodeKey;
28
+ exports.physicalColumn = physicalColumn;
29
+ const ir_1 = require("./ir");
30
+ const resolve_inheritance_1 = require("./resolve_inheritance");
31
+ const sql_expr_utils_1 = require("./sql_expr_utils");
32
+ const sql_identifiers_1 = require("./sql_identifiers");
33
+ const sql_parser_1 = require("./sql_parser");
34
+ /**
35
+ * Generates the property-graph DDL for a semantic model on one engine.
36
+ *
37
+ * The generated statement references the entities' existing base tables; it
38
+ * does not create them. Returns the DDL plus any warnings collected while
39
+ * mapping the IR (e.g. metrics that could not be placed on a single table).
40
+ *
41
+ * Throws when the model yields no valid node table -- it declares no entities,
42
+ * or every entity lacks a KEY -- since a property graph with an empty NODE
43
+ * TABLES block is invalid DDL that the engine would reject.
44
+ */
45
+ function generateGraphDdl(model, engine) {
46
+ const warnings = [];
47
+ // Resolve entity-level inheritance (`extends`) before generating: this
48
+ // flattens each subclass's inherited fields down and expands `extends` to the
49
+ // full transitive ancestor set, so labels and property signatures can be read
50
+ // straight off the resolved model. Only the graph legs run this; the KC push
51
+ // consumes the raw model, so its output is unaffected. An inherited field
52
+ // that names a field of its ancestor names the subtype's field of that name,
53
+ // and renders from its SQL (see fieldSql).
54
+ const resolved = (0, resolve_inheritance_1.resolveInheritance)(model, undefined, { requalify: true });
55
+ warnings.push(...resolved.warnings);
56
+ // The IR requires entities/relationships/metrics, but be defensive against a
57
+ // hand-built or partially-deserialized model rather than throwing on `.map`.
58
+ const entities = resolved.model.entities ?? [];
59
+ const relationships = resolved.model.relationships ?? [];
60
+ const metrics = resolved.model.metrics ?? [];
61
+ // Knowledge Catalog is the only system an action or a constraint reaches
62
+ // (kc_actions.ts, kc_constraints.ts), so this leg emits nothing for either.
63
+ // Warn once each, so an author who declared them learns where they go. A push
64
+ // that leaves Knowledge Catalog out warns separately that they reach nothing
65
+ // at all (see commands.ts).
66
+ const actions = resolved.model.actions ?? [];
67
+ if (actions.length) {
68
+ warnings.push(`${actions.length} action(s) reach Knowledge Catalog only; the ` +
69
+ `${engine.name} push deploys none of them.`);
70
+ }
71
+ const constraints = resolved.model.constraints ?? [];
72
+ if (constraints.length) {
73
+ warnings.push(`${constraints.length} constraint(s) reach Knowledge Catalog only; ` +
74
+ `the ${engine.name} push deploys none of them.`);
75
+ }
76
+ // An abstract entity is conceptual: it has no physical table and produces no
77
+ // NODE TABLE, surviving only as a LABEL on its concrete descendants (whose
78
+ // node tables carry its flattened fields). Collect the abstract names so the
79
+ // node-table filter drops them while label emission still sees them.
80
+ const abstractNames = new Set(entities.filter(e => e.abstract).map(e => e.name));
81
+ // A graph node table requires a non-empty KEY: the entity's primary key, else
82
+ // its first unique key (nodeKey). An entity with neither cannot form a valid
83
+ // node, so skip it (and, below, any edge that references it) rather than emit
84
+ // `KEY()` — invalid DDL.
85
+ const skipped = new Set();
86
+ const validEntities = entities.filter(entity => {
87
+ // An abstract entity is intentionally table-less: skip its node table (it
88
+ // still contributes LABELs to descendants) without warning -- unlike a
89
+ // missing KEY, this is by design, not a defect.
90
+ if (abstractNames.has(entity.name)) {
91
+ skipped.add(entity.name);
92
+ return false;
93
+ }
94
+ if (nodeKey(entity).length)
95
+ return true;
96
+ warnings.push(`entity '${entity.name}': empty KEY (no primary key); node table skipped, ` +
97
+ `as a graph node requires a KEY`);
98
+ skipped.add(entity.name);
99
+ return false;
100
+ });
101
+ // The ancestors carried by a table-emitting entity. Two uses. First, measure
102
+ // placement: an engine's measure may be allowed only on a LEAF type -- one
103
+ // that no *table-emitting* entity extends (see the BigQuery engine).
104
+ // Building this from validEntities (not all entities) is deliberate: an
105
+ // abstract or empty-KEY subtype emits no table, so it does not duplicate its
106
+ // parent's label and must not mark the parent non-leaf. Second, the orphan
107
+ // check below: an abstract class that is no concrete entity's ancestor
108
+ // produces no graph element at all, so warn and drop it rather than let it
109
+ // vanish silently.
110
+ const ancestorsUsed = new Set();
111
+ for (const entity of validEntities) {
112
+ for (const a of entity.extends ?? [])
113
+ ancestorsUsed.add(a);
114
+ }
115
+ for (const name of abstractNames) {
116
+ if (!ancestorsUsed.has(name)) {
117
+ warnings.push(`abstract entity '${name}' is not a supertype of any concrete ` +
118
+ `entity; it has no table and no descendant to label, so it produces ` +
119
+ `no graph element`);
120
+ }
121
+ }
122
+ // A property graph must have at least one NODE TABLE; an empty `NODE TABLES
123
+ // ()` block is invalid DDL under any circumstance. When no entity can form a
124
+ // node — the model declares none, every one is abstract (a table-less
125
+ // supertype forms no node), or every one was skipped for an empty KEY — fail
126
+ // loudly rather than emit a graph the engine would reject. The collected skip
127
+ // reasons ride along in the error so the caller sees why each entity dropped.
128
+ if (!validEntities.length) {
129
+ let reason;
130
+ if (!entities.length) {
131
+ reason = 'the model declares no entities';
132
+ }
133
+ else if (entities.every(e => e.abstract)) {
134
+ reason =
135
+ 'every entity is abstract (a table-less supertype forms no node table)';
136
+ }
137
+ else {
138
+ reason =
139
+ 'every entity was skipped because it has no primary key (a graph node requires a KEY)';
140
+ }
141
+ throw new Error(`cannot generate a property graph: ${reason}; a graph requires at ` +
142
+ `least one NODE TABLE` +
143
+ (warnings.length ? `\n${warnings.join('\n')}` : ''));
144
+ }
145
+ // Metrics are model-level; the engine places each on a node table (or skips
146
+ // it). The result is keyed by entity name; entries on a skipped entity simply
147
+ // never render.
148
+ const entityByName = new Map(entities.map(e => [e.name, e]));
149
+ // The SQL `sql` gives, or, when a field on the way has none, what a warning
150
+ // says it reads: each field on `through`, then those `sql` went through.
151
+ const readOf = (entity, sql, through) => 'sql' in sql ? { sql: sql.sql } : {
152
+ reads: missingSibling('through' in sql ? [...through, ...sql.through] : through, entity, engine)
153
+ };
154
+ const measurePropertiesByEntity = engine.placeMetrics(metrics, {
155
+ entityByName,
156
+ skipped,
157
+ ancestorsUsed,
158
+ renderedFieldSql: (entity, field) => readOf(entity, fieldSql(field, entity, engine), [field.name]),
159
+ localSql: (entity, sql) => readOf(entity, siblingsRead(sql, entity, name => fieldSql(entity.fields.find(f => f.name === name), entity, engine)), []),
160
+ }, warnings);
161
+ // A subclass's `LABEL <ancestor>` block lists that ancestor's own (flattened)
162
+ // signature, so the label renderer must reach every LABEL CARRIER by name:
163
+ // node-forming entities plus abstract (table-less) supertypes, which have no
164
+ // node table but still contribute a label signature. A concrete entity
165
+ // dropped for an empty KEY is deliberately excluded -- it was reported as a
166
+ // structural defect, so it must not silently reappear as a shared label on a
167
+ // subclass (which would contradict the drop and expose a half-defined class);
168
+ // a subclass extending it drops that LABEL with a warning (see
169
+ // renderNodeTable).
170
+ const labelByName = new Map(entities.filter(e => e.abstract || !skipped.has(e.name))
171
+ .map(e => [e.name, e]));
172
+ // A field one node table leaves out because this engine has no SQL for it
173
+ // is left out of each abstract ancestor's label on every table too, so the
174
+ // label lists the same properties everywhere it appears.
175
+ const leftOutUnder = new Map();
176
+ for (const e of validEntities) {
177
+ for (const f of e.fields) {
178
+ if (!isLeftOut(f, e, engine))
179
+ continue;
180
+ for (const a of e.extends ?? []) {
181
+ leftOutUnder.set(a, (leftOutUnder.get(a) ?? new Set()).add(f.name));
182
+ }
183
+ }
184
+ }
185
+ const nodeTables = validEntities.map(entity => renderNodeTable(entity, measurePropertiesByEntity.get(entity.name) ?? [], labelByName, leftOutUnder, ancestorsUsed.has(entity.name), engine, warnings));
186
+ const entitiesByName = new Map(validEntities.map(e => [e.name, e]));
187
+ const edgeTables = relationships
188
+ .filter(rel => {
189
+ // An edge REFERENCES both endpoint nodes; if either was skipped the
190
+ // edge cannot resolve, so drop it too.
191
+ const dangling = [rel.source.entity, rel.destination.entity].filter(n => skipped.has(n));
192
+ if (!dangling.length)
193
+ return true;
194
+ warnings.push(`relationship '${rel.name}': references skipped entity ` +
195
+ `${dangling.map(n => `'${n}'`).join(', ')}; edge omitted`);
196
+ return false;
197
+ })
198
+ .map(rel => renderEdgeTable(rel, entitiesByName, engine, warnings));
199
+ const graphName = engine.graphName(resolved.model);
200
+ const blocks = [
201
+ `CREATE OR REPLACE PROPERTY GRAPH ${graphName}`,
202
+ `NODE TABLES (\n${nodeTables.join(',\n')}\n)`,
203
+ ];
204
+ if (edgeTables.length) {
205
+ blocks.push(`EDGE TABLES (\n${edgeTables.join(',\n')}\n)`);
206
+ }
207
+ // The model's own description and AI context go to Knowledge Catalog's model
208
+ // entry (see knowledge_catalog.ts), not the graph; bigquery.ts's
209
+ // optionsClause says why.
210
+ return { ddl: blocks.join('\n') + ';\n', warnings: dedupe(warnings) };
211
+ }
212
+ function renderNodeTable(entity, measureProperties, labelByName, leftOutUnder, isSupertype, engine, warnings) {
213
+ const table = engine.table(entity.dataSource, warnings, `entity '${entity.name}'`);
214
+ // Canonical rendering of every inherited property: its supertype's OWN
215
+ // definition, keyed by property name (nearest ancestor wins, matching field
216
+ // flattening). A property that appears under a label shared across element
217
+ // tables must have one identical definition everywhere it appears, so the
218
+ // supertype is authoritative -- both this node's DEFAULT LABEL and its
219
+ // `LABEL <ancestor>` blocks render an inherited property exactly as the
220
+ // supertype's node table does. resolveInheritance localizes the inherited
221
+ // copies on this entity, so in a well-formed hierarchy `renderOwn` below is a
222
+ // no-op; the map's real job is to catch and neutralize a genuine override.
223
+ const inheritedRender = new Map();
224
+ const inheritedCore = new Map();
225
+ for (const ancestorName of entity.extends ?? []) {
226
+ const ancestor = labelByName.get(ancestorName);
227
+ // Skip an ABSTRACT ancestor: it has no table and no binding of its own, so
228
+ // it cannot be authoritative for an inherited property's rendering. This
229
+ // subtype's own binding is the canonical one (e.g. `c_name AS name`), so we
230
+ // leave the inherited names out of this map and let `renderOwn` render them
231
+ // straight from this table. A concrete ancestor keeps its authority.
232
+ if (!ancestor || ancestor.abstract)
233
+ continue;
234
+ for (const f of ancestor.fields) {
235
+ // The ancestor's node table leaves out a field this engine has no SQL
236
+ // for, so there is no rendering to defer to.
237
+ if (!('sql' in fieldSql(f, ancestor, engine)))
238
+ continue;
239
+ if (!inheritedRender.has(f.name)) {
240
+ inheritedRender.set(f.name, renderFieldProperty(f, ancestor, engine));
241
+ inheritedCore.set(f.name, renderFieldPropertyCore(f, ancestor, engine));
242
+ }
243
+ }
244
+ }
245
+ // Renders one of this entity's own fields, deferring to the supertype's
246
+ // canonical definition for any inherited name. A subclass CANNOT give an
247
+ // inherited property a different definition -- BigQuery requires one
248
+ // definition per property under a shared label -- so a divergent override is
249
+ // warned and dropped in favor of the supertype's, keeping the DDL valid. The
250
+ // warning distinguishes a STRUCTURAL remap (a different column/expression --
251
+ // the property is genuinely rebound) from a METADATA-only refinement (same
252
+ // column, different description/synonyms) so it states precisely what the
253
+ // subclass loses, rather than mislabeling an added description as a redefined
254
+ // column.
255
+ const renderOwn = (f) => {
256
+ const own = renderFieldProperty(f, entity, engine);
257
+ const canonical = inheritedRender.get(f.name);
258
+ if (canonical === undefined)
259
+ return own; // not inherited: render as-is
260
+ if (canonical === own)
261
+ return canonical; // identical: nothing to warn
262
+ if (renderFieldPropertyCore(f, entity, engine) !==
263
+ inheritedCore.get(f.name)) {
264
+ warnings.push(`entity '${entity.name}' remaps inherited property '${f.name}' to a different column/expression than its supertype; ` +
265
+ `${engine.name} allows only one definition per property under a ` +
266
+ `shared label, so the supertype's is used and the subclass's ` +
267
+ `remapping is dropped`);
268
+ }
269
+ else {
270
+ warnings.push(`entity '${entity.name}' overrides the description/synonyms of ` +
271
+ `inherited property '${f.name}'; under a shared label every binding ` +
272
+ `must declare it identically, so the supertype's metadata is used ` +
273
+ `and the subclass's is dropped`);
274
+ }
275
+ return canonical;
276
+ };
277
+ // Order: declared fields, then the engine's measure properties (any operand
278
+ // properties synthesized for measures, then the measures themselves, which
279
+ // reference those operand properties).
280
+ // A field is kept when the engine has SQL for it. That is narrower than
281
+ // isFieldBound, which availability pruning tests: a field bound only by
282
+ // another engine's SQL is bound, yet this engine has nothing to emit for it.
283
+ // Skip such a field with a warning naming the engine's dialect rather than
284
+ // emit that other engine's SQL. A field with no SQL at all is unbound;
285
+ // pruning normally strips it before generation, but a caller that generates
286
+ // DDL straight from a bindingOptional load without pruning could still reach
287
+ // here with one, such as a purely logical field. Skip it too rather than emit
288
+ // `<name>` as a phantom bare column the source table does not have. A field
289
+ // that names a sibling the engine has no SQL for has none either, so it is
290
+ // skipped with a warning naming that sibling.
291
+ const boundFields = entity.fields.filter(f => {
292
+ const sql = fieldSql(f, entity, engine);
293
+ if ('sql' in sql)
294
+ return true;
295
+ const omitted = inheritedRender.has(f.name) ?
296
+ `node table's own label, and the supertype's label keeps the ` +
297
+ `supertype's definition` :
298
+ `node table`;
299
+ warnings.push('through' in sql ?
300
+ `entity '${entity.name}': field '${f.name}' reads ${missingSibling(sql.through, entity, engine)}; omitted from ` +
301
+ `the ${omitted}` :
302
+ (0, ir_1.isFieldBound)(f) ?
303
+ `entity '${entity.name}': field '${f.name}' has no '${engine.dialect}' or 'ANSI_SQL' expression; omitted from the ` +
304
+ omitted :
305
+ `entity '${entity.name}': field '${f.name}' has no column under ` +
306
+ `this binding; omitted from the node table (bind it, or ` +
307
+ `govern the logical model in Knowledge Catalog instead)`);
308
+ return false;
309
+ });
310
+ const properties = [...boundFields.map(renderOwn), ...measureProperties];
311
+ const lines = [
312
+ line(1, `${table} AS ${(0, sql_identifiers_1.quoteIfReserved)(entity.name)}`),
313
+ line(2, `KEY(${physicalColumns(nodeKey(entity), engine, warnings, `entity '${entity.name}'`)
314
+ .join(', ')})`),
315
+ ];
316
+ // Element-table description and synonyms attach to the node's DEFAULT LABEL
317
+ // -- UNLESS this entity is a supertype whose label is shared by subclass
318
+ // tables. BigQuery forbids a label that carries an OPTIONS clause from being
319
+ // bound to more than one element table (verified live -- "the label ... is
320
+ // defined with OPTIONS clause in one of the element tables and cannot be
321
+ // bound to another element table"), so a shared supertype label must be
322
+ // options-free; its description/synonyms are dropped (with a warning). The
323
+ // rule is BigQuery's, but it applies to any engine whose elementOptions
324
+ // returns a clause.
325
+ let labelOpts = engine.elementOptions(entity.description, entity.aiContext);
326
+ if (isSupertype && labelOpts) {
327
+ warnings.push(`entity '${entity.name}' is a supertype in a class hierarchy; its ` +
328
+ `description/synonyms are dropped from the shared '${entity.name}' ` +
329
+ `label (${engine.name} forbids OPTIONS on a label bound by multiple tables)`);
330
+ labelOpts = undefined;
331
+ }
332
+ // A node participating in the hierarchy -- as a subclass (it declares
333
+ // ancestor LABELs) or as a shared supertype (a subclass binds its label) --
334
+ // must use an EXPLICIT `DEFAULT LABEL`: BigQuery rejects a bare (implicit
335
+ // default) PROPERTIES clause immediately followed by explicit LABEL clauses
336
+ // (verified live -- "Expected \")\" ... but got keyword LABEL"), and the
337
+ // validated shared-label shape is `DEFAULT LABEL PROPERTIES(...)`. A node
338
+ // outside any hierarchy keeps the implicit form so existing output is
339
+ // byte-for-byte unchanged.
340
+ const hasAncestors = !!(entity.extends && entity.extends.length);
341
+ if (hasAncestors || isSupertype)
342
+ lines.push(line(2, 'DEFAULT LABEL'));
343
+ if (labelOpts)
344
+ lines.push(line(2, labelOpts));
345
+ lines.push(propertiesClause(properties));
346
+ // Inheritance: declare one LABEL per transitive ancestor (resolveInheritance
347
+ // expanded `extends` to the full ancestor set), each re-listing that
348
+ // ancestor's properties so `MATCH (:Ancestor)` also matches this subclass
349
+ // node. The block is rendered straight from the ANCESTOR's own fields with
350
+ // the ancestor as qualifier, so it is byte-for-byte identical to the
351
+ // ancestor's own DEFAULT LABEL -- the exact match BigQuery requires for a
352
+ // property shared across the element tables that bind a label ("same set of
353
+ // property declarations under the same label"). This node's DEFAULT LABEL
354
+ // renders the same inherited names via `renderOwn`, which also defers to the
355
+ // ancestor, so the label is consistent within this table too.
356
+ for (const ancestorName of entity.extends ?? []) {
357
+ const ancestor = labelByName.get(ancestorName);
358
+ if (!ancestor) {
359
+ // The ancestor exists in the model (an unknown parent hard-fails in
360
+ // resolveInheritance, so it never reaches here) but is not a label
361
+ // carrier -- it was dropped for an
362
+ // empty KEY. Its fields still flattened onto this node (they render as
363
+ // own properties above); it just forms no queryable label, so omit the
364
+ // LABEL and say so rather than reference a class reported as gone.
365
+ warnings.push(`entity '${entity.name}' extends '${ancestorName}', which has no ` +
366
+ `node table and is not abstract (it was dropped, e.g. for an empty ` +
367
+ `KEY); the '${ancestorName}' label is omitted from '${entity.name}'`);
368
+ continue;
369
+ }
370
+ lines.push(line(2, `LABEL ${(0, sql_identifiers_1.quoteIfReserved)(ancestorName)}`));
371
+ let signature;
372
+ if (ancestor.abstract) {
373
+ // Render each inherited field from THIS subtype's own binding, keyed by
374
+ // the abstract ancestor's field names. Skip any the subtype leaves
375
+ // unbound so the block matches the DEFAULT LABEL (which lists only bound
376
+ // fields); a profile that binds the subtype but not an inherited field
377
+ // simply does not expose that property under the shared label.
378
+ signature = [];
379
+ for (const af of ancestor.fields) {
380
+ if (leftOutUnder.get(ancestorName)?.has(af.name))
381
+ continue;
382
+ const child = boundFields.find(cf => cf.name === af.name);
383
+ if (child) {
384
+ signature.push(renderFieldProperty(child, entity, engine));
385
+ }
386
+ }
387
+ }
388
+ else {
389
+ // Only the fields the ancestor's own node table lists.
390
+ signature =
391
+ ancestor.fields.filter(f => 'sql' in fieldSql(f, ancestor, engine))
392
+ .map(f => renderFieldProperty(f, ancestor, engine));
393
+ }
394
+ lines.push(propertiesClause(signature));
395
+ }
396
+ return lines.join('\n');
397
+ }
398
+ // The properties clause of a node table, label or edge. An empty list gives NO
399
+ // PROPERTIES: BigQuery and Spanner read a missing clause as PROPERTIES ARE ALL
400
+ // COLUMNS, which would expose every column of the table, including one a
401
+ // profile leaves out.
402
+ function propertiesClause(properties) {
403
+ return properties.length ? propertiesBlock(properties) :
404
+ line(2, 'NO PROPERTIES');
405
+ }
406
+ // Whether a field has SQL, but the engine has none to render it from: none of
407
+ // its SQL is the engine's, or it names a sibling that has none.
408
+ function isLeftOut(field, owner, engine) {
409
+ return (0, ir_1.isFieldBound)(field) && !('sql' in fieldSql(field, owner, engine));
410
+ }
411
+ // The SQL `field` of `owner` renders from on `engine`. The text is the
412
+ // engine's (fieldExpression), and a field may name a sibling field in it as
413
+ // `<owner>.<field>`. Each sibling it names is replaced with that sibling's
414
+ // own SQL for the engine, in parentheses unless that SQL is a lone column, and
415
+ // every other column loses the owner's qualifier; the rest of the text stays
416
+ // as written. Both engines' SQL is read as BigQuery's.
417
+ // Throws when the field names itself, directly or through its siblings, since
418
+ // no SQL can render it; push validation rejects such a model first
419
+ // (validateExpressionReferences).
420
+ function fieldSql(field, owner, engine, path = []) {
421
+ const expr = engine.fieldExpression(field);
422
+ if (expr === undefined)
423
+ return { noSql: true };
424
+ const chain = [...path, field.name];
425
+ return siblingsRead(expr, owner, name => {
426
+ if (chain.includes(name)) {
427
+ throw new Error((0, sql_expr_utils_1.selfReferenceMessage)(owner.name, chain.slice(chain.indexOf(name))));
428
+ }
429
+ return fieldSql(owner.fields.find(f => f.name === name), owner, engine, chain);
430
+ });
431
+ }
432
+ // `expr`, SQL on `owner`'s table, local to that table, with each sibling it
433
+ // names as `<owner>.<field>` replaced with what `read` gives for that sibling
434
+ // (see fieldSql). When a sibling has no SQL, `through` runs from it to the
435
+ // sibling with none.
436
+ function siblingsRead(expr, owner, read) {
437
+ const named = new Set(((0, sql_expr_utils_1.qualifiedColumns)(expr, owner.name) ?? [])
438
+ .filter(c => owner.fields.some(f => f.name === c)));
439
+ const replacements = new Map();
440
+ for (const name of named) {
441
+ const sibling = read(name);
442
+ if ('noSql' in sibling)
443
+ return { through: [name] };
444
+ if ('through' in sibling)
445
+ return { through: [name, ...sibling.through] };
446
+ // A sibling bound to a reserved column, such as `order`, reads it quoted.
447
+ // One bound to the column of its own name reads the same either way.
448
+ const sql = (0, sql_identifiers_1.quoteIfReserved)(sibling.sql);
449
+ if (sql !== name)
450
+ replacements.set(name, parenthesized(sql));
451
+ }
452
+ return { sql: (0, sql_expr_utils_1.withSiblingsSpliced)(expr, owner.name, replacements) };
453
+ }
454
+ // `sql` in parentheses, unless it is a lone column or already in them.
455
+ function parenthesized(sql) {
456
+ const tree = (0, sql_parser_1.sqlExpressionTree)(sql, 'BIGQUERY');
457
+ const kind = tree && Object.keys(tree).length === 1 ?
458
+ Object.keys(tree)[0] :
459
+ undefined;
460
+ return kind === 'column' || kind === 'paren' ? sql : `(${sql})`;
461
+ }
462
+ // What a warning says a field reads when it names a sibling the engine has no
463
+ // SQL for: each sibling on `through`, and what the last one lacks.
464
+ function missingSibling(through, owner, engine) {
465
+ const last = owner.fields.find(f => f.name === through[through.length - 1]);
466
+ const lacks = last && (0, ir_1.isFieldBound)(last) ?
467
+ `has no '${engine.dialect}' or 'ANSI_SQL' expression` :
468
+ `has no column under this binding`;
469
+ return `${through.map(n => `field '${n}'`).join(', which reads ')}, ` +
470
+ `which ${lacks}`;
471
+ }
472
+ // Renders a field as a graph property: a bare column when the expression is
473
+ // just the column, else `<expr> AS <name>`. Attaches the engine's metadata
474
+ // clause (label, temporal role, description, AI-first annotations) after it
475
+ // per the `derived_property` rule. A node table leaves out a field with no
476
+ // expression, with a warning. An association edge exposes it as a bare column
477
+ // under its name.
478
+ function renderFieldProperty(field, owner, engine) {
479
+ const core = renderFieldPropertyCore(field, owner, engine);
480
+ const opts = engine.fieldOptions(field);
481
+ return opts ? `${core} ${opts}` : core;
482
+ }
483
+ // Renders just the STRUCTURAL core of a field property -- the bare column, or
484
+ // `<expr> AS <name>` when the field maps a non-trivial expression -- with no
485
+ // trailing OPTIONS. This is the part BigQuery requires to be byte-for-byte
486
+ // identical across every element table binding a shared label; metadata
487
+ // (OPTIONS) is compared separately so a subclass's metadata-only refinement is
488
+ // reported distinctly from a structural remap (see renderNodeTable's
489
+ // renderOwn). This is also the single place a field expression is lowered to
490
+ // its table-local form: the resolve-inheritance pass first rewrites an
491
+ // inherited field's expression into the child's frame (localizeInheritedField),
492
+ // then fieldSql strips the entity's own qualifier and reads each sibling the
493
+ // expression names through that sibling's SQL -- one normalization, in one
494
+ // place, applied to own and inherited fields alike.
495
+ function renderFieldPropertyCore(field, owner, engine) {
496
+ const sql = fieldSql(field, owner, engine);
497
+ const local = 'sql' in sql ? sql.sql : field.name;
498
+ const alias = (0, sql_identifiers_1.quoteIfReserved)(field.name);
499
+ // A field bound to the column of its name, such as `order` bound to
500
+ // `` `order` ``, is that column.
501
+ return local === field.name || local === alias ?
502
+ alias :
503
+ `${(0, sql_identifiers_1.quoteIfReserved)(local)} AS ${alias}`;
504
+ }
505
+ // The key an entity's node is keyed by: its primary key, else its first
506
+ // non-empty unique key. Empty when the entity states neither, and then it gets
507
+ // no node table.
508
+ function nodeKey(entity) {
509
+ if (entity.keys?.length)
510
+ return entity.keys;
511
+ return entity.uniqueKeys?.find(k => k.length > 0) ?? [];
512
+ }
513
+ // Writes a key or join column as the column it names. An unquoted name is
514
+ // backtick-quoted when it is reserved or is not a simple identifier, and a
515
+ // backtick-quoted name is written as it is.
516
+ function physicalColumn(column) {
517
+ return /^`[^`]+`$/.test(column) ? column : (0, sql_identifiers_1.quoteIdentifier)(column);
518
+ }
519
+ // Writes each key or join column with physicalColumn. A value isColumnName
520
+ // rejects is an expression rather than a column; push rejects it, and a caller
521
+ // that generates without validating gets it as written, with a warning naming
522
+ // it, rather than opaque DDL the engine rejects at deploy.
523
+ function physicalColumns(columns, engine, warnings, context) {
524
+ return columns.map(c => {
525
+ if ((0, sql_expr_utils_1.isColumnName)(c))
526
+ return physicalColumn(c);
527
+ warnings.push(`${context}: key or join column '${c}' is not a physical column ` +
528
+ `name; a KEY/REFERENCES site requires a column, so ${engine.name} will reject the generated DDL`);
529
+ return c;
530
+ });
531
+ }
532
+ function renderEdgeTable(rel, entitiesByName, engine, warnings) {
533
+ // A many-to-many relationship is backed by its own association table rather
534
+ // than a source entity's foreign key; render it from that block.
535
+ if (rel.association) {
536
+ return renderAssociationEdge(rel, rel.association, entitiesByName, engine, warnings);
537
+ }
538
+ // A relationship is a direct foreign key: the SOURCE entity's own base table
539
+ // backs the edge (one edge row per source row). Its FK columns
540
+ // (`rel.source.columns`) reference the destination's key columns
541
+ // (`rel.destination.columns`); the edge is keyed by, and references its
542
+ // source node through, the key of the source entity's node (nodeKey, looked up
543
+ // here rather than duplicated onto the relationship).
544
+ const sourceEntity = entitiesByName.get(rel.source.entity);
545
+ const relCtx = `relationship '${rel.name}'`;
546
+ let backing;
547
+ let sourceKey;
548
+ if (!sourceEntity) {
549
+ // Only hand-built IR gets here: the loader rejects a relationship whose
550
+ // endpoint is not one of the model's entities. Name the table after the
551
+ // entity.
552
+ warnings.push(`relationship '${rel.name}': unknown source entity '${rel.source.entity}'`);
553
+ backing = engine.table(rel.source.entity, warnings, relCtx);
554
+ sourceKey = rel.source.columns;
555
+ }
556
+ else {
557
+ backing = engine.table(sourceEntity.dataSource, warnings, relCtx);
558
+ sourceKey = nodeKey(sourceEntity);
559
+ }
560
+ // Every key clause names physical columns: the edge is the source entity's own
561
+ // table, so its KEY and SOURCE KEY are the source node's key, and its
562
+ // DESTINATION KEY is the FK, the relationship's `from_columns`, referencing
563
+ // the destination's `to_columns`.
564
+ const key = physicalColumns(sourceKey, engine, warnings, relCtx).join(', ');
565
+ const destFk = physicalColumns(rel.source.columns, engine, warnings, relCtx).join(', ');
566
+ const destRef = physicalColumns(rel.destination.columns, engine, warnings, relCtx)
567
+ .join(', ');
568
+ const lines = [
569
+ line(1, `${backing} AS ${(0, sql_identifiers_1.quoteIfReserved)(rel.name)}`),
570
+ line(2, `KEY(${key})`),
571
+ line(2, `SOURCE KEY(${key}) REFERENCES ${(0, sql_identifiers_1.quoteIfReserved)(rel.source.entity)}(${key})`),
572
+ line(2, `DESTINATION KEY(${destFk}) REFERENCES ${(0, sql_identifiers_1.quoteIfReserved)(rel.destination.entity)}(${destRef})`),
573
+ ];
574
+ // Edge description and synonyms attach to the DEFAULT LABEL: after the
575
+ // SOURCE/DESTINATION clauses, before the properties clause (grammar:
576
+ // element_table_definition).
577
+ const labelOpts = engine.elementOptions(rel.description, rel.aiContext);
578
+ if (labelOpts)
579
+ lines.push(line(2, labelOpts));
580
+ // A foreign-key relationship declares no fields of its own, so its edge
581
+ // lists none.
582
+ lines.push(propertiesClause([]));
583
+ return lines.join('\n');
584
+ }
585
+ // Renders a many-to-many edge backed by an association (junction) table. Unlike
586
+ // a direct FK, the edge has its OWN backing table and KEY, each endpoint's
587
+ // SOURCE/DESTINATION KEY names the junction columns referencing the key of
588
+ // that entity's node, and the junction's own `fields` become edge PROPERTIES.
589
+ function renderAssociationEdge(rel, assoc, entitiesByName, engine, warnings) {
590
+ const relCtx = `relationship '${rel.name}'`;
591
+ const backing = engine.table(assoc.dataSource, warnings, relCtx);
592
+ if (!assoc.keys?.length) {
593
+ warnings.push(`relationship '${rel.name}': association table has no KEY; the edge ` +
594
+ `table will be invalid (an edge requires a KEY)`);
595
+ }
596
+ // The REFERENCES target is the key of each endpoint's node; fall back to
597
+ // the endpoint's own columns (and warn) when the entity cannot be resolved.
598
+ const refColumns = (end) => {
599
+ const entity = entitiesByName.get(end.entity);
600
+ if (!entity) {
601
+ warnings.push(`relationship '${rel.name}': unknown entity '${end.entity}'`);
602
+ }
603
+ return physicalColumns(entity ? nodeKey(entity) : end.columns, engine, warnings, relCtx)
604
+ .join(', ');
605
+ };
606
+ // The junction table's own key and join columns.
607
+ const columns = (names) => physicalColumns(names, engine, warnings, relCtx).join(', ');
608
+ const lines = [
609
+ line(1, `${backing} AS ${(0, sql_identifiers_1.quoteIfReserved)(rel.name)}`),
610
+ line(2, `KEY(${columns(assoc.keys)})`),
611
+ line(2, `SOURCE KEY(${columns(assoc.sourceColumns)}) ` +
612
+ `REFERENCES ${(0, sql_identifiers_1.quoteIfReserved)(rel.source.entity)}(${refColumns(rel.source)})`),
613
+ line(2, `DESTINATION KEY(${columns(assoc.destinationColumns)}) ` +
614
+ `REFERENCES ${(0, sql_identifiers_1.quoteIfReserved)(rel.destination.entity)}(${refColumns(rel.destination)})`),
615
+ ];
616
+ // Edge description and synonyms attach to the DEFAULT LABEL: after the
617
+ // SOURCE/DESTINATION clauses, before PROPERTIES (grammar:
618
+ // element_table_definition).
619
+ const labelOpts = engine.elementOptions(rel.description, rel.aiContext);
620
+ if (labelOpts)
621
+ lines.push(line(2, labelOpts));
622
+ // The junction's own non-key fields are the edge's properties. They name
623
+ // each other under the relationship's name.
624
+ const owner = { name: rel.name, fields: assoc.fields ?? [] };
625
+ const properties = owner.fields
626
+ .filter(f => {
627
+ const sql = fieldSql(f, owner, engine);
628
+ if ('sql' in sql || !(0, ir_1.isFieldBound)(f))
629
+ return true;
630
+ warnings.push(`relationship '${rel.name}': field '${f.name}' ` +
631
+ ('through' in sql ?
632
+ `reads ${missingSibling(sql.through, owner, engine)}` :
633
+ `has no '${engine.dialect}' or 'ANSI_SQL' expression`) +
634
+ `; omitted from the edge table`);
635
+ return false;
636
+ })
637
+ .map(f => renderFieldProperty(f, owner, engine));
638
+ lines.push(propertiesClause(properties));
639
+ return lines.join('\n');
640
+ }
641
+ // All generated indentation flows through this single mechanism: one nesting
642
+ // level == one INDENT. `line` indents one line to a depth; `list` indents a set
643
+ // of lines and joins them comma-separated; `propertiesBlock` is the shared
644
+ // `PROPERTIES( ... )` shape used by both node and edge tables. Keeping every
645
+ // indent derived from `depth` (rather than hardcoded spaces) makes the output
646
+ // indentation consistent by construction.
647
+ const INDENT = ' ';
648
+ const pad = (depth) => INDENT.repeat(depth);
649
+ const line = (depth, text) => `${pad(depth)}${text}`;
650
+ const list = (depth, lines) => lines.map(l => line(depth, l)).join(',\n');
651
+ function propertiesBlock(properties) {
652
+ return `${line(2, 'PROPERTIES(')}\n${list(3, properties)}\n${line(2, ')')}`;
653
+ }
654
+ function dedupe(items) {
655
+ return [...new Set(items)];
656
+ }