@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.
- package/LICENSE +203 -0
- package/README.md +266 -2
- package/build/ts/kcmd/cli.js +19578 -0
- package/build/ts/kcmd/gcp/alloydb.d.ts +12 -0
- package/build/ts/kcmd/gcp/alloydb.js +70 -0
- package/build/ts/kcmd/gcp/api.d.ts +29 -0
- package/build/ts/kcmd/gcp/api.js +162 -0
- package/build/ts/kcmd/gcp/biglake.d.ts +16 -0
- package/build/ts/kcmd/gcp/biglake.js +50 -0
- package/build/ts/kcmd/gcp/bigquery.d.ts +57 -0
- package/build/ts/kcmd/gcp/bigquery.js +134 -0
- package/build/ts/kcmd/gcp/context.d.ts +10 -0
- package/build/ts/kcmd/gcp/context.js +74 -0
- package/build/ts/kcmd/gcp/crm.d.ts +13 -0
- package/build/ts/kcmd/gcp/crm.js +91 -0
- package/build/ts/kcmd/gcp/dataplex.d.ts +147 -0
- package/build/ts/kcmd/gcp/dataplex.js +406 -0
- package/build/ts/kcmd/gcp/index.d.ts +3 -0
- package/build/ts/kcmd/gcp/index.js +21 -0
- package/build/ts/kcmd/gcp/spanner.d.ts +63 -0
- package/build/ts/kcmd/gcp/spanner.js +123 -0
- package/build/ts/kcmd/index.d.ts +7 -0
- package/build/ts/kcmd/index.js +46 -0
- package/build/ts/kcmd/layout.d.ts +16 -0
- package/build/ts/kcmd/layout.js +31 -0
- package/build/ts/kcmd/layouts/documents.d.ts +24 -0
- package/build/ts/kcmd/layouts/documents.js +264 -0
- package/build/ts/kcmd/layouts/okf.d.ts +31 -0
- package/build/ts/kcmd/layouts/okf.js +344 -0
- package/build/ts/kcmd/layouts/semantic-model.d.ts +61 -0
- package/build/ts/kcmd/layouts/semantic-model.js +542 -0
- package/build/ts/kcmd/layouts/standard.d.ts +13 -0
- package/build/ts/kcmd/layouts/standard.js +108 -0
- package/build/ts/kcmd/manifest.d.ts +34 -0
- package/build/ts/kcmd/manifest.js +262 -0
- package/build/ts/kcmd/metadata.d.ts +25 -0
- package/build/ts/kcmd/metadata.js +8 -0
- package/build/ts/kcmd/okf/catalog_residue.d.ts +14 -0
- package/build/ts/kcmd/okf/catalog_residue.js +124 -0
- package/build/ts/kcmd/okf/concept.d.ts +14 -0
- package/build/ts/kcmd/okf/concept.js +104 -0
- package/build/ts/kcmd/okf/concept_to_entry.d.ts +8 -0
- package/build/ts/kcmd/okf/concept_to_entry.js +254 -0
- package/build/ts/kcmd/okf/init_okf_workspace.d.ts +15 -0
- package/build/ts/kcmd/okf/init_okf_workspace.js +115 -0
- package/build/ts/kcmd/okf/links.d.ts +23 -0
- package/build/ts/kcmd/okf/links.js +468 -0
- package/build/ts/kcmd/okf/okf_metadata.d.ts +12 -0
- package/build/ts/kcmd/okf/okf_metadata.js +82 -0
- package/build/ts/kcmd/semantic/bigquery.d.ts +22 -0
- package/build/ts/kcmd/semantic/bigquery.js +525 -0
- package/build/ts/kcmd/semantic/binding.d.ts +3 -0
- package/build/ts/kcmd/semantic/binding.js +97 -0
- package/build/ts/kcmd/semantic/catalog_entry.d.ts +24 -0
- package/build/ts/kcmd/semantic/catalog_entry.js +35 -0
- package/build/ts/kcmd/semantic/converters/owl/convert.d.ts +26 -0
- package/build/ts/kcmd/semantic/converters/owl/convert.js +41 -0
- package/build/ts/kcmd/semantic/converters/owl/model.d.ts +98 -0
- package/build/ts/kcmd/semantic/converters/owl/model.js +30 -0
- package/build/ts/kcmd/semantic/converters/owl/parse.d.ts +11 -0
- package/build/ts/kcmd/semantic/converters/owl/parse.js +634 -0
- package/build/ts/kcmd/semantic/converters/owl/to_ir.d.ts +19 -0
- package/build/ts/kcmd/semantic/converters/owl/to_ir.js +390 -0
- package/build/ts/kcmd/semantic/deploy_bigquery.d.ts +20 -0
- package/build/ts/kcmd/semantic/deploy_bigquery.js +226 -0
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.d.ts +129 -0
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.js +1396 -0
- package/build/ts/kcmd/semantic/deploy_spanner.d.ts +15 -0
- package/build/ts/kcmd/semantic/deploy_spanner.js +150 -0
- package/build/ts/kcmd/semantic/deployment_target.d.ts +93 -0
- package/build/ts/kcmd/semantic/deployment_target.js +379 -0
- package/build/ts/kcmd/semantic/graph_ddl.d.ts +34 -0
- package/build/ts/kcmd/semantic/graph_ddl.js +563 -0
- package/build/ts/kcmd/semantic/import_model.d.ts +50 -0
- package/build/ts/kcmd/semantic/import_model.js +1275 -0
- package/build/ts/kcmd/semantic/ir.d.ts +589 -0
- package/build/ts/kcmd/semantic/ir.js +247 -0
- package/build/ts/kcmd/semantic/kc_actions.d.ts +45 -0
- package/build/ts/kcmd/semantic/kc_actions.js +531 -0
- package/build/ts/kcmd/semantic/kc_constraints.d.ts +44 -0
- package/build/ts/kcmd/semantic/kc_constraints.js +279 -0
- package/build/ts/kcmd/semantic/kc_converter.d.ts +31 -0
- package/build/ts/kcmd/semantic/kc_converter.js +824 -0
- package/build/ts/kcmd/semantic/kc_custom_types.d.ts +70 -0
- package/build/ts/kcmd/semantic/kc_custom_types.js +828 -0
- package/build/ts/kcmd/semantic/kc_entries.d.ts +155 -0
- package/build/ts/kcmd/semantic/kc_entries.js +284 -0
- package/build/ts/kcmd/semantic/kc_ids.d.ts +103 -0
- package/build/ts/kcmd/semantic/kc_ids.js +191 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.d.ts +32 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.js +1043 -0
- package/build/ts/kcmd/semantic/loader.d.ts +262 -0
- package/build/ts/kcmd/semantic/loader.js +3440 -0
- package/build/ts/kcmd/semantic/names.d.ts +10 -0
- package/build/ts/kcmd/semantic/names.js +85 -0
- package/build/ts/kcmd/semantic/osi_converter.d.ts +60 -0
- package/build/ts/kcmd/semantic/osi_converter.js +911 -0
- package/build/ts/kcmd/semantic/parameters.d.ts +18 -0
- package/build/ts/kcmd/semantic/parameters.js +152 -0
- package/build/ts/kcmd/semantic/pull_kc.d.ts +14 -0
- package/build/ts/kcmd/semantic/pull_kc.js +346 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.d.ts +54 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.js +330 -0
- package/build/ts/kcmd/semantic/resolve_profiles.d.ts +250 -0
- package/build/ts/kcmd/semantic/resolve_profiles.js +1822 -0
- package/build/ts/kcmd/semantic/runtime/agent_tools.d.ts +93 -0
- package/build/ts/kcmd/semantic/runtime/agent_tools.js +243 -0
- package/build/ts/kcmd/semantic/runtime/dialect.d.ts +13 -0
- package/build/ts/kcmd/semantic/runtime/dialect.js +39 -0
- package/build/ts/kcmd/semantic/runtime/runtime.d.ts +94 -0
- package/build/ts/kcmd/semantic/runtime/runtime.js +318 -0
- package/build/ts/kcmd/semantic/runtime/store.d.ts +112 -0
- package/build/ts/kcmd/semantic/runtime/store.js +344 -0
- package/build/ts/kcmd/semantic/skills.d.ts +110 -0
- package/build/ts/kcmd/semantic/skills.js +697 -0
- package/build/ts/kcmd/semantic/source_dialect.d.ts +10 -0
- package/build/ts/kcmd/semantic/source_dialect.js +261 -0
- package/build/ts/kcmd/semantic/source_entries.d.ts +12 -0
- package/build/ts/kcmd/semantic/source_entries.js +282 -0
- package/build/ts/kcmd/semantic/spanner.d.ts +18 -0
- package/build/ts/kcmd/semantic/spanner.js +60 -0
- package/build/ts/kcmd/semantic/sql_expr_utils.d.ts +17 -0
- package/build/ts/kcmd/semantic/sql_expr_utils.js +221 -0
- package/build/ts/kcmd/semantic/sql_identifiers.d.ts +6 -0
- package/build/ts/kcmd/semantic/sql_identifiers.js +267 -0
- package/build/ts/kcmd/semantic/sql_parser.d.ts +44 -0
- package/build/ts/kcmd/semantic/sql_parser.js +378 -0
- package/build/ts/kcmd/semantic/transpile.d.ts +74 -0
- package/build/ts/kcmd/semantic/transpile.js +563 -0
- package/build/ts/kcmd/semantic/validate.d.ts +35 -0
- package/build/ts/kcmd/semantic/validate.js +1899 -0
- package/build/ts/kcmd/semantic/yaml_options.d.ts +6 -0
- package/build/ts/kcmd/semantic/yaml_options.js +58 -0
- package/build/ts/kcmd/semantic/yaml_update.d.ts +26 -0
- package/build/ts/kcmd/semantic/yaml_update.js +782 -0
- package/build/ts/kcmd/snapshot.d.ts +33 -0
- package/build/ts/kcmd/snapshot.js +379 -0
- package/build/ts/kcmd/source.d.ts +31 -0
- package/build/ts/kcmd/source.js +119 -0
- package/build/ts/kcmd/sources/bq-dataset.d.ts +17 -0
- package/build/ts/kcmd/sources/bq-dataset.js +131 -0
- package/build/ts/kcmd/sources/entrygroup.d.ts +21 -0
- package/build/ts/kcmd/sources/entrygroup.js +83 -0
- package/build/ts/kcmd/sources/kb.d.ts +21 -0
- package/build/ts/kcmd/sources/kb.js +82 -0
- package/build/ts/kcmd/sources/semantic-model.d.ts +18 -0
- package/build/ts/kcmd/sources/semantic-model.js +48 -0
- package/build/ts/kcmd/sync.d.ts +35 -0
- package/build/ts/kcmd/sync.js +697 -0
- package/package.json +62 -4
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// OSI (open, AI-first semantics) <-> Semantic Model IR converter.
|
|
3
|
+
//
|
|
4
|
+
// SCAFFOLD (naming for the end state): this file currently holds only the
|
|
5
|
+
// WRITE direction -- IR -> OSI YAML (`serializeModel`). The READ direction
|
|
6
|
+
// (OSI YAML -> IR) still lives in `loader.ts` and moves here once PR4 (the
|
|
7
|
+
// KC push line) merges, at which point this becomes the full two-way
|
|
8
|
+
// converter. Until then, "where is the OSI parser?" -> `loader.ts`.
|
|
9
|
+
//
|
|
10
|
+
// TODO(#278): fold the OSI READ direction in and drop the scaffold. Once
|
|
11
|
+
// #278 merges, move `loadModels` / `fromDocument` / `loadSemanticModels`
|
|
12
|
+
// (and their Load* types) out of `loader.ts` into this file, delete
|
|
13
|
+
// `loader.ts`, and repoint its importers (`src/tool/commands.ts` and the
|
|
14
|
+
// load / validate / bigquery tests). Then this is the sole OSI<->IR codec
|
|
15
|
+
// and the SCAFFOLD note above comes out.
|
|
16
|
+
//
|
|
17
|
+
// Serializing is the inverse of `loader.ts`: `loader` reads authored YAML
|
|
18
|
+
// into the IR; this module writes the IR out as a YAML document the loader
|
|
19
|
+
// can read back. It is the local-workspace sink for `pull` (Knowledge
|
|
20
|
+
// Catalog -> IR -> YAML), the counterpart to how `push` compiles YAML -> IR
|
|
21
|
+
// -> a destination.
|
|
22
|
+
//
|
|
23
|
+
// Fidelity is at the IR level, not byte-for-byte with a hand-authored file. The
|
|
24
|
+
// loader keeps every entry in `dialects` and records `stringForm`, and also
|
|
25
|
+
// picks a target/canonical `expression` + an `importedExpression` via
|
|
26
|
+
// `pickDialect`:
|
|
27
|
+
// * this serializer writes `dialects` back out when present (or short-form
|
|
28
|
+
// bare strings in GOOGLE_VERSION when `stringForm` is true), and falls back
|
|
29
|
+
// to `expression` and `importedExpression` when `dialects` is absent.
|
|
30
|
+
// * comments and key ordering are not preserved.
|
|
31
|
+
// What the loader DOES keep on the IR round-trips here: names, descriptions,
|
|
32
|
+
// `ai_context` (instructions / synonyms / examples / custom properties), keys
|
|
33
|
+
// and unique keys, data sources, field datatypes (omitting `Opaque`) / labels /
|
|
34
|
+
// dimension flags, expressions, relationship join columns, and
|
|
35
|
+
// `custom_extensions`. Output format follows `model.version` via
|
|
36
|
+
// `FORMAT_VERSION_PLACEMENT`: GOOGLE_VERSION ('0.2.0.dev0/google' or unset)
|
|
37
|
+
// writes native extension keys and one `<model>.profile.<name>.yaml` sidecar
|
|
38
|
+
// file per profile, whereas OSSIE_VERSION ('0.2.0.dev0') writes strict Ossie
|
|
39
|
+
// keys, encodes `profiles`, `deployments`, `extends`, relationship
|
|
40
|
+
// `description`, and metric `authoredEntity` in `GOOGLE` `custom_extensions`
|
|
41
|
+
// blocks, and writes no profile files.
|
|
42
|
+
//
|
|
43
|
+
// An `association` (junction-table) relationship has no open-format syntax (the
|
|
44
|
+
// loader cannot produce one), so only its direct foreign-key view (from/to +
|
|
45
|
+
// columns) is serialized; the junction detail is dropped with a note.
|
|
46
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
47
|
+
if (k2 === undefined) k2 = k;
|
|
48
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
49
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
50
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
51
|
+
}
|
|
52
|
+
Object.defineProperty(o, k2, desc);
|
|
53
|
+
}) : (function(o, m, k, k2) {
|
|
54
|
+
if (k2 === undefined) k2 = k;
|
|
55
|
+
o[k2] = m[k];
|
|
56
|
+
}));
|
|
57
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
58
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
59
|
+
}) : function(o, v) {
|
|
60
|
+
o["default"] = v;
|
|
61
|
+
});
|
|
62
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
63
|
+
var ownKeys = function(o) {
|
|
64
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
65
|
+
var ar = [];
|
|
66
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
67
|
+
return ar;
|
|
68
|
+
};
|
|
69
|
+
return ownKeys(o);
|
|
70
|
+
};
|
|
71
|
+
return function (mod) {
|
|
72
|
+
if (mod && mod.__esModule) return mod;
|
|
73
|
+
var result = {};
|
|
74
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
75
|
+
__setModuleDefault(result, mod);
|
|
76
|
+
return result;
|
|
77
|
+
};
|
|
78
|
+
})();
|
|
79
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
80
|
+
exports.serializeModelFiles = serializeModelFiles;
|
|
81
|
+
exports.serializeModel = serializeModel;
|
|
82
|
+
exports.modelDocument = modelDocument;
|
|
83
|
+
exports.compact = compact;
|
|
84
|
+
exports.nonEmpty = nonEmpty;
|
|
85
|
+
const yaml = __importStar(require("yaml"));
|
|
86
|
+
const deployment_target_1 = require("./deployment_target");
|
|
87
|
+
const ir_1 = require("./ir");
|
|
88
|
+
const loader_1 = require("./loader");
|
|
89
|
+
const names_1 = require("./names");
|
|
90
|
+
const resolve_inheritance_1 = require("./resolve_inheritance");
|
|
91
|
+
const resolve_profiles_1 = require("./resolve_profiles");
|
|
92
|
+
// The vendor tag for Google-specific extension blocks on the IR (kept in sync
|
|
93
|
+
// with loader.GOOGLE_VENDOR). In the Google flavor, a model-level deployment
|
|
94
|
+
// target list rides in one as a folded shim alongside `model.deployments`.
|
|
95
|
+
const GOOGLE_VENDOR = 'GOOGLE';
|
|
96
|
+
// The dialect label for the IR's target/canonical `expression` when `dialects`
|
|
97
|
+
// is absent and `stringForm` is not set. BIGQUERY is the loader's default
|
|
98
|
+
// target dialect, so labeling the canonical form BIGQUERY makes the loader
|
|
99
|
+
// re-pick it exactly on reload.
|
|
100
|
+
const CANONICAL_DIALECT = 'BIGQUERY';
|
|
101
|
+
// Only dialect labels the loader accepts are ever written out.
|
|
102
|
+
const ALLOWED_DIALECT_SET = new Set(ir_1.ALLOWED_DIALECTS);
|
|
103
|
+
/**
|
|
104
|
+
* Serializes a semantic model and its sidecar profile files. The returned
|
|
105
|
+
* `files` list always starts with `<model.name>.yaml`. Under `GOOGLE_VERSION`,
|
|
106
|
+
* each entry in `model.profiles` follows as
|
|
107
|
+
* `<model.name>.profile.<profile.name>.yaml`, except that a reserved
|
|
108
|
+
* (`default`), invalid, or case-insensitively repeated name is skipped with a
|
|
109
|
+
* warning. Under `OSSIE_VERSION`, valid profiles are written into the model's
|
|
110
|
+
* `GOOGLE` `custom_extensions` block and no profile files are emitted.
|
|
111
|
+
*/
|
|
112
|
+
function serializeModelFiles(model, opts = {}) {
|
|
113
|
+
const warnings = [];
|
|
114
|
+
const version = formatVersionOf(model);
|
|
115
|
+
const document = modelDocument(model, warnings, opts.logical);
|
|
116
|
+
const files = [
|
|
117
|
+
{ fileName: `${model.name}.yaml`, content: render(document, opts) },
|
|
118
|
+
];
|
|
119
|
+
if (loader_1.FORMAT_VERSION_PLACEMENT[version].model.profiles === 'file') {
|
|
120
|
+
for (const { name, doc } of profileDocuments(model, warnings)) {
|
|
121
|
+
files.push({
|
|
122
|
+
fileName: `${model.name}.profile.${name}.yaml`,
|
|
123
|
+
content: render(doc, opts),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { files, warnings: [...new Set(warnings)] };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Returns the format version to serialize `model` as (`model.version`,
|
|
131
|
+
* defaulting to `GOOGLE_VERSION` when unset).
|
|
132
|
+
*/
|
|
133
|
+
function formatVersionOf(model) {
|
|
134
|
+
return model.version === loader_1.OSSIE_VERSION ? loader_1.OSSIE_VERSION : loader_1.GOOGLE_VERSION;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Validates `model.profiles` and returns the serialized `profileDocument`
|
|
138
|
+
* mapping for each valid profile.
|
|
139
|
+
*
|
|
140
|
+
* Reserved (`default`), invalid, and case-insensitively repeated names are
|
|
141
|
+
* skipped with a warning, keeping the first spelling, so the output satisfies
|
|
142
|
+
* the same name rules the profile-file layout and `readBlockProfiles` enforce
|
|
143
|
+
* on load.
|
|
144
|
+
*/
|
|
145
|
+
function profileDocuments(model, warnings) {
|
|
146
|
+
const seen = new Map();
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const profile of model.profiles ?? []) {
|
|
149
|
+
const lower = profile.name.toLowerCase();
|
|
150
|
+
if (lower === resolve_profiles_1.DEFAULT_PROFILE) {
|
|
151
|
+
warnings.push(`model '${model.name}': profile '${profile.name}' uses the name ` +
|
|
152
|
+
`reserved for the model's inline bindings and is not serialized`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if ((0, names_1.nameProblem)('profile', profile.name)) {
|
|
156
|
+
warnings.push(`model '${model.name}': profile '${profile.name}' is not a valid ` +
|
|
157
|
+
`profile name and is not serialized`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const first = seen.get(lower);
|
|
161
|
+
if (first !== undefined) {
|
|
162
|
+
// Only a case-only repeat needs the ignoring-case rule explained.
|
|
163
|
+
const repeat = first === profile.name ?
|
|
164
|
+
`profile '${first}' is listed more than once` :
|
|
165
|
+
`profiles '${first}' and '${profile.name}' name the same ` +
|
|
166
|
+
`profile (profile names are unique ignoring case)`;
|
|
167
|
+
warnings.push(`model '${model.name}': ${repeat}; only '${first}' is serialized`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
seen.set(lower, profile.name);
|
|
171
|
+
out.push({
|
|
172
|
+
name: profile.name,
|
|
173
|
+
doc: profileDocument(profile, warnings),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Serializes a single semantic model to a YAML document string in the open
|
|
180
|
+
* AI-first semantics format. The document contains exactly this one model;
|
|
181
|
+
* `pull` writes one file per model
|
|
182
|
+
* (catalog/EntryGroups/<entryGroup>/<model>.yaml). It is the first file
|
|
183
|
+
* `serializeModelFiles` returns, with the warnings for every file it builds.
|
|
184
|
+
*
|
|
185
|
+
* Warnings flag IR content that has no loadable representation (an association
|
|
186
|
+
* relationship's junction detail, an imported expression in an unknown dialect,
|
|
187
|
+
* dropped `GOOGLE` blocks under GOOGLE_VERSION, Google-only constructs and
|
|
188
|
+
* missing join columns under OSSIE_VERSION, or loader-invalid gaps), so the
|
|
189
|
+
* caller can surface the lossy edge.
|
|
190
|
+
*
|
|
191
|
+
* `logical` marks the model as a purely logical one (no physical binding).
|
|
192
|
+
* Under GOOGLE_VERSION it suppresses the missing-source and missing-expression
|
|
193
|
+
* warnings, which flag a lossy pull of a bound model. OSSIE_VERSION requires
|
|
194
|
+
* both, so they are still given. An OWL import sets it, and so does a
|
|
195
|
+
* second-generation pull, which loses no binding.
|
|
196
|
+
*
|
|
197
|
+
* `compactFlow` renders leaf collections (scalar sequences, all-scalar maps) in
|
|
198
|
+
* flow style so the output matches the compact convention the semantic-model
|
|
199
|
+
* guides author by hand and is byte-for-byte reproducible there. An OWL import
|
|
200
|
+
* sets it; `pull` does not.
|
|
201
|
+
*/
|
|
202
|
+
function serializeModel(model, opts = {}) {
|
|
203
|
+
const { files, warnings } = serializeModelFiles(model, opts);
|
|
204
|
+
return { yaml: files[0].content, warnings };
|
|
205
|
+
}
|
|
206
|
+
// Renders one document as YAML. `aliasDuplicateObjects: false` writes an IR
|
|
207
|
+
// array that two keys share (an entity's key reused as a relationship's
|
|
208
|
+
// `to_columns`, say) out in full at each key, rather than as an `&a1` anchor
|
|
209
|
+
// and `*a1` alias, so editing one binding never silently edits another.
|
|
210
|
+
// `compat: 'yaml-1.1'` quotes strings that a YAML 1.1 parser (PyYAML,
|
|
211
|
+
// ruamel's 1.1 mode) would read as booleans or dates (`'yes'`, `'on'`,
|
|
212
|
+
// `'2001-12-14'`), so arbitrary values in `custom_extensions` or `ai_context`
|
|
213
|
+
// round-trip across tools.
|
|
214
|
+
function render(doc, opts) {
|
|
215
|
+
return opts.compactFlow ? renderCompact(doc) : yaml.stringify(doc, {
|
|
216
|
+
aliasDuplicateObjects: false,
|
|
217
|
+
compat: 'yaml-1.1',
|
|
218
|
+
lineWidth: opts.lineWidth ?? 80,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
// Renders "leaf" collections in flow style so the output matches the compact
|
|
222
|
+
// convention the semantic-model guides author by hand: a sequence whose items
|
|
223
|
+
// are all scalars (`primary_key: [id]`, `from_columns: [fk]`) and a map whose
|
|
224
|
+
// values are all scalars (an inline field `{name, datatype}` or a column-less
|
|
225
|
+
// relationship `{name, from, to}`). Nested structures -- a dataset, an
|
|
226
|
+
// expression's `dialects`, an `ai_context` -- always stay block.
|
|
227
|
+
//
|
|
228
|
+
// A leaf collection is inlined ONLY while it stays short: a map or sequence
|
|
229
|
+
// whose flow form would exceed FLOW_WIDTH_BUDGET (a field carrying a long
|
|
230
|
+
// `description`, say) is left block, so `--compact` never emits an unreadable
|
|
231
|
+
// multi-hundred-character line. `lineWidth: 0` disables the wrapper so an
|
|
232
|
+
// inlined collection is never broken mid-braces -- the width gate, not the
|
|
233
|
+
// wrapper, is what bounds line length. Flow and block parse identically, so
|
|
234
|
+
// this only affects layout.
|
|
235
|
+
const FLOW_WIDTH_BUDGET = 80;
|
|
236
|
+
// The rendered width of a scalar node (its stringified value length); 0 for a
|
|
237
|
+
// non-scalar, which never contributes because a collection holding one is not
|
|
238
|
+
// inlined in the first place.
|
|
239
|
+
function scalarWidth(node) {
|
|
240
|
+
return yaml.isScalar(node) ?
|
|
241
|
+
String(node.value ?? '').length :
|
|
242
|
+
0;
|
|
243
|
+
}
|
|
244
|
+
function renderCompact(document) {
|
|
245
|
+
const doc = new yaml.Document(document, { aliasDuplicateObjects: false, compat: 'yaml-1.1' });
|
|
246
|
+
yaml.visit(doc, {
|
|
247
|
+
Seq(_, node) {
|
|
248
|
+
if (!node.items.every(item => yaml.isScalar(item)))
|
|
249
|
+
return;
|
|
250
|
+
// `[` + each `item, ` + `]`, approximated for the width gate.
|
|
251
|
+
const width = node.items.reduce((n, item) => n + scalarWidth(item) + 2, 2);
|
|
252
|
+
if (width <= FLOW_WIDTH_BUDGET)
|
|
253
|
+
node.flow = true;
|
|
254
|
+
},
|
|
255
|
+
Map(_, node) {
|
|
256
|
+
// The document's own mapping stays block, as a hand-written file has it.
|
|
257
|
+
if (node === doc.contents)
|
|
258
|
+
return;
|
|
259
|
+
const pairs = node.items;
|
|
260
|
+
if (!pairs.every(pair => yaml.isScalar(pair.value)))
|
|
261
|
+
return;
|
|
262
|
+
// `{` + each `key: value, ` + `}`, approximated for the width gate.
|
|
263
|
+
const width = pairs.reduce((n, pair) => n + scalarWidth(pair.key) + scalarWidth(pair.value) + 4, 2);
|
|
264
|
+
if (width <= FLOW_WIDTH_BUDGET)
|
|
265
|
+
node.flow = true;
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
return doc.toString({ flowCollectionPadding: false, lineWidth: 0 });
|
|
269
|
+
}
|
|
270
|
+
// Builds the plain document object (version + one model) that yaml.stringify
|
|
271
|
+
// renders. Kept separate so tests can assert the structure without parsing
|
|
272
|
+
// YAML.
|
|
273
|
+
function modelDocument(model, warnings = [], logical = false) {
|
|
274
|
+
const version = formatVersionOf(model);
|
|
275
|
+
return {
|
|
276
|
+
version,
|
|
277
|
+
semantic_model: [modelDoc(model, warnings, logical, version)],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// Builds a sidecar profile document matching the schema `loadProfileFile` in
|
|
281
|
+
// `resolve_profiles.ts` reads: a top-level mapping with no `version` or
|
|
282
|
+
// `semantic_model` wrapper. Optional lists (`primary_key`, `unique_keys`,
|
|
283
|
+
// `fields`, `fields_exclude`, `metrics_exclude`, `actions`) are written
|
|
284
|
+
// whenever defined, even when empty, so a ProfileSpec that loadProfileFile read
|
|
285
|
+
// comes back the same from this file.
|
|
286
|
+
function profileDocument(profile, warnings) {
|
|
287
|
+
return compact({
|
|
288
|
+
name: profile.name,
|
|
289
|
+
entities: nonEmpty(profile.entities.map(pe => compact({
|
|
290
|
+
name: pe.name,
|
|
291
|
+
source: pe.source,
|
|
292
|
+
primary_key: pe.primaryKey,
|
|
293
|
+
unique_keys: pe.uniqueKeys,
|
|
294
|
+
fields: pe.fields?.map(pf => {
|
|
295
|
+
const where = `profile '${profile.name}': entity '${pe.name}': field '${pf.name}'`;
|
|
296
|
+
const expression = expressionDoc(pf, /* isGoogleVersion= */ true, where, warnings);
|
|
297
|
+
// loadProfileFile rejects an entry with nothing to bind, and
|
|
298
|
+
// (like a model load) any blank SQL text.
|
|
299
|
+
const texts = typeof expression === 'string' ?
|
|
300
|
+
[expression] :
|
|
301
|
+
(expression?.dialects ?? []).map(d => d.expression);
|
|
302
|
+
if (expression === undefined) {
|
|
303
|
+
warnings.push(`${where}: no expression; the profile file will not load ` +
|
|
304
|
+
`until it is set or the field is listed in ` +
|
|
305
|
+
`'fields_exclude'.`);
|
|
306
|
+
}
|
|
307
|
+
else if (texts.some(t => t.trim() === '')) {
|
|
308
|
+
warnings.push(`${where}: expression is blank; the profile file will not ` +
|
|
309
|
+
`load until its SQL is written.`);
|
|
310
|
+
}
|
|
311
|
+
return compact({ name: pf.name, expression });
|
|
312
|
+
}),
|
|
313
|
+
fields_exclude: pe.fieldsExclude,
|
|
314
|
+
}))),
|
|
315
|
+
relationships: nonEmpty(profile.relationships.map(pr => compact({
|
|
316
|
+
name: pr.name,
|
|
317
|
+
from_columns: nonEmpty(pr.fromColumns),
|
|
318
|
+
to_columns: nonEmpty(pr.toColumns),
|
|
319
|
+
}))),
|
|
320
|
+
metrics_exclude: profile.metricsExclude,
|
|
321
|
+
actions: profile.actions?.map(pa => compact({
|
|
322
|
+
name: pa.name,
|
|
323
|
+
executor: pa.executor === null ?
|
|
324
|
+
null :
|
|
325
|
+
(pa.executor ? executorDoc(pa.executor) : undefined),
|
|
326
|
+
})),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
function modelDoc(model, warnings, logical, version) {
|
|
330
|
+
const isGoogleVersion = version === loader_1.GOOGLE_VERSION;
|
|
331
|
+
const placement = loader_1.FORMAT_VERSION_PLACEMENT[version];
|
|
332
|
+
// `datasets` is required (min 1) by the loader. A reconstructed model with no
|
|
333
|
+
// entities (e.g. every entity fetch failed during a pull) would serialize to
|
|
334
|
+
// a document the loader rejects; emit the (empty) array but flag it so the
|
|
335
|
+
// lossy edge is visible rather than surfacing later as an opaque load error.
|
|
336
|
+
const datasets = (model.entities ??
|
|
337
|
+
[]).map(e => datasetDoc(e, warnings, logical, version));
|
|
338
|
+
if (!datasets.length) {
|
|
339
|
+
warnings.push(`model '${model.name}': no datasets (entities); the document requires ` +
|
|
340
|
+
`at least one and will not load until an entity is present.`);
|
|
341
|
+
}
|
|
342
|
+
const tableKey = isGoogleVersion ? 'entities' : 'datasets';
|
|
343
|
+
// Under GOOGLE_VERSION, deployments come from `model.deployments`, or from a
|
|
344
|
+
// GOOGLE `deploymentTargets` block when that is empty. Under OSSIE_VERSION,
|
|
345
|
+
// named deployments and profiles go in a GOOGLE block on the model.
|
|
346
|
+
const namedDeployments = (model.deployments ?? []).map(d => compact({
|
|
347
|
+
name: d.name,
|
|
348
|
+
target: d.target,
|
|
349
|
+
profile: d.profile
|
|
350
|
+
}));
|
|
351
|
+
const deployments = placement.model.deployments === 'native' ?
|
|
352
|
+
nonEmpty(namedDeployments.length ? namedDeployments :
|
|
353
|
+
deploymentsFromGoogleExtensions(model)) :
|
|
354
|
+
undefined;
|
|
355
|
+
const blockProfiles = placement.model.profiles === 'block' ?
|
|
356
|
+
nonEmpty(profileDocuments(model, warnings).map(p => p.doc)) :
|
|
357
|
+
undefined;
|
|
358
|
+
const blockDeployments = placement.model.deployments === 'block' ?
|
|
359
|
+
nonEmpty(namedDeployments) :
|
|
360
|
+
undefined;
|
|
361
|
+
const blockPayload = compact({
|
|
362
|
+
profiles: blockProfiles,
|
|
363
|
+
deployments: blockDeployments,
|
|
364
|
+
});
|
|
365
|
+
if (!isGoogleVersion)
|
|
366
|
+
warnGoogleOnlyContent(model, warnings);
|
|
367
|
+
const emittedFields = placement.model.actions === 'native' && model.actions?.length ?
|
|
368
|
+
emitted(model) :
|
|
369
|
+
null;
|
|
370
|
+
return compact({
|
|
371
|
+
name: model.name,
|
|
372
|
+
description: model.description,
|
|
373
|
+
ai_context: aiContextDoc(model.aiContext, isGoogleVersion),
|
|
374
|
+
deployments,
|
|
375
|
+
[tableKey]: datasets,
|
|
376
|
+
relationships: nonEmpty((model.relationships ??
|
|
377
|
+
[]).map(r => relationshipDoc(r, warnings, version))),
|
|
378
|
+
metrics: nonEmpty((model.metrics ?? []).map(m => metricDoc(m, warnings, version))),
|
|
379
|
+
// Hoisted out of the map: `emitted` resolves inheritance, which deep-clones
|
|
380
|
+
// the model, and the answer does not vary by action.
|
|
381
|
+
actions: placement.model.actions === 'native' ?
|
|
382
|
+
nonEmpty((model.actions ??
|
|
383
|
+
[]).map(a => actionDoc(a, warnings, emittedFields))) :
|
|
384
|
+
undefined,
|
|
385
|
+
constraints: placement.model.constraints === 'native' ?
|
|
386
|
+
nonEmpty((model.constraints ?? []).map(c => constraintDoc(c))) :
|
|
387
|
+
undefined,
|
|
388
|
+
custom_extensions: customExtensionsDoc(model.customExtensions, `model '${model.name}'`, isGoogleVersion,
|
|
389
|
+
/* isModel= */ true, warnings, /* isField= */ false, blockPayload),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
// Warns about each IR construct OSSIE_VERSION has no representation for,
|
|
393
|
+
// natively or in a GOOGLE block, so a '0.2.0.dev0' write flags dropped
|
|
394
|
+
// content just as a GOOGLE_VERSION write flags a dropped GOOGLE block.
|
|
395
|
+
function warnGoogleOnlyContent(model, warnings) {
|
|
396
|
+
const placement = loader_1.FORMAT_VERSION_PLACEMENT[loader_1.OSSIE_VERSION];
|
|
397
|
+
const drop = (where, what) => warnings.push(`${where}: ${what} has no representation under '${loader_1.OSSIE_VERSION}' and ` +
|
|
398
|
+
`is not serialized`);
|
|
399
|
+
const where = `model '${model.name}'`;
|
|
400
|
+
if (placement.model.actions === 'none' && model.actions?.length) {
|
|
401
|
+
drop(where, `'actions'`);
|
|
402
|
+
}
|
|
403
|
+
if (placement.model.constraints === 'none' && model.constraints?.length) {
|
|
404
|
+
drop(where, `'constraints'`);
|
|
405
|
+
}
|
|
406
|
+
for (const e of model.entities ?? []) {
|
|
407
|
+
if (placement.dataset.abstract === 'none' && e.abstract) {
|
|
408
|
+
drop(`entity '${e.name}'`, `'abstract'`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function datasetDoc(entity, warnings, logical, version) {
|
|
413
|
+
const isGoogleVersion = version === loader_1.GOOGLE_VERSION;
|
|
414
|
+
const placement = loader_1.FORMAT_VERSION_PLACEMENT[version];
|
|
415
|
+
// A concrete (non-abstract) entity with no source cannot be reloaded -- the
|
|
416
|
+
// loader requires `source` unless the dataset is abstract -- so surface the
|
|
417
|
+
// gap at write time instead of emitting a document that fails to load. Under
|
|
418
|
+
// OSSIE_VERSION `abstract` is omitted, so a dataset without `dataSource`
|
|
419
|
+
// cannot reload even if `entity.abstract` was set. Suppressed for a logical
|
|
420
|
+
// model under GOOGLE_VERSION, where a missing source is intended.
|
|
421
|
+
// OSSIE_VERSION requires a source on every dataset.
|
|
422
|
+
if ((!logical || !isGoogleVersion) && !(isGoogleVersion && entity.abstract) &&
|
|
423
|
+
!entity.dataSource) {
|
|
424
|
+
warnings.push(`entity '${entity.name}' has no source${isGoogleVersion ? ' and is not abstract' : ''}; the ` +
|
|
425
|
+
`serialized document will not reload until a source is set`);
|
|
426
|
+
}
|
|
427
|
+
const parents = nonEmpty(entity.extends);
|
|
428
|
+
const nativeExtends = placement.dataset.extends === 'native' ? parents : undefined;
|
|
429
|
+
const nativeAbstract = placement.dataset.abstract === 'native' ?
|
|
430
|
+
(entity.abstract || undefined) :
|
|
431
|
+
undefined;
|
|
432
|
+
const blockPayload = placement.dataset.extends === 'block' ?
|
|
433
|
+
compact({ extends: parents }) :
|
|
434
|
+
undefined;
|
|
435
|
+
return compact({
|
|
436
|
+
name: entity.name,
|
|
437
|
+
// An abstract entity has no physical table, so its source is empty; omit
|
|
438
|
+
// the key rather than emit `source: ""` (which the loader rejects). The
|
|
439
|
+
// source as written survives serialization; a legacy bare name is written
|
|
440
|
+
// as the name the load qualified it to.
|
|
441
|
+
source: (0, loader_1.writtenSource)(entity) || undefined,
|
|
442
|
+
// Supertype entities (entity-level inheritance); native under
|
|
443
|
+
// GOOGLE_VERSION, or in the dataset's GOOGLE block under OSSIE_VERSION.
|
|
444
|
+
extends: nativeExtends,
|
|
445
|
+
// Conceptual (table-less) marker; GOOGLE_VERSION only.
|
|
446
|
+
abstract: nativeAbstract,
|
|
447
|
+
primary_key: nonEmpty(entity.keys),
|
|
448
|
+
unique_keys: nonEmpty(entity.uniqueKeys),
|
|
449
|
+
description: entity.description,
|
|
450
|
+
ai_context: aiContextDoc(entity.aiContext, isGoogleVersion),
|
|
451
|
+
fields: nonEmpty((entity.fields ?? [])
|
|
452
|
+
.map(f => fieldDoc(f, warnings, logical || (isGoogleVersion && !!entity.abstract), isGoogleVersion))),
|
|
453
|
+
custom_extensions: customExtensionsDoc(entity.customExtensions, `entity '${entity.name}'`, isGoogleVersion,
|
|
454
|
+
/* isModel= */ false, warnings, /* isField= */ false, blockPayload),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
// Filters out the IR's `'Opaque'` placeholder so untyped fields and metrics
|
|
458
|
+
// omit the `datatype` key in YAML. `'Opaque'` marks an unknown or unmapped type
|
|
459
|
+
// (e.g. from `kcmd pull` or OWL import); action parameter helpers also use this
|
|
460
|
+
// to tell whether a projected field will emit a concrete datatype on reload.
|
|
461
|
+
function datatypeDoc(type) {
|
|
462
|
+
return type === undefined || type === 'Opaque' ? undefined :
|
|
463
|
+
type;
|
|
464
|
+
}
|
|
465
|
+
function fieldDoc(field, warnings, logical, isGoogleVersion) {
|
|
466
|
+
const expression = expressionDoc(field, isGoogleVersion, `field '${field.name}'`, warnings);
|
|
467
|
+
// Under GOOGLE_VERSION a field with no expression reloads unbound. A logical
|
|
468
|
+
// field, or one on an abstract entity, has none on purpose and is not warned.
|
|
469
|
+
// OSSIE_VERSION requires an expression on every field.
|
|
470
|
+
if ((!logical || !isGoogleVersion) && !expression) {
|
|
471
|
+
warnings.push(isGoogleVersion ?
|
|
472
|
+
`field '${field.name}': no expression; it reloads as an unbound ` +
|
|
473
|
+
`field.` :
|
|
474
|
+
`field '${field.name}': no expression; a '${loader_1.OSSIE_VERSION}' ` +
|
|
475
|
+
`document requires one per field and will not load until it ` +
|
|
476
|
+
`is set.`);
|
|
477
|
+
}
|
|
478
|
+
return compact({
|
|
479
|
+
name: field.name,
|
|
480
|
+
expression,
|
|
481
|
+
datatype: datatypeDoc(field.type),
|
|
482
|
+
label: field.label,
|
|
483
|
+
dimension: dimensionDoc(field),
|
|
484
|
+
description: field.description,
|
|
485
|
+
ai_context: aiContextDoc(field.aiContext, isGoogleVersion),
|
|
486
|
+
custom_extensions: customExtensionsDoc(field.customExtensions, `field '${field.name}'`, isGoogleVersion,
|
|
487
|
+
/* isModel= */ false, warnings, /* isField= */ true),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
function metricDoc(metric, warnings, version) {
|
|
491
|
+
const isGoogleVersion = version === loader_1.GOOGLE_VERSION;
|
|
492
|
+
const placement = loader_1.FORMAT_VERSION_PLACEMENT[version];
|
|
493
|
+
// Emit `entity` only from `authoredEntity` (natively under GOOGLE_VERSION or
|
|
494
|
+
// in the metric's GOOGLE block under OSSIE_VERSION). A derived
|
|
495
|
+
// `metric.entity` is recomputed by the loader from SQL qualifiers.
|
|
496
|
+
const expression = expressionDoc(metric, isGoogleVersion, `metric '${metric.name}'`, warnings);
|
|
497
|
+
if (!expression) {
|
|
498
|
+
warnings.push(`metric '${metric.name}': no expression; the loader requires one per ` +
|
|
499
|
+
`metric and the document will not load until it is set.`);
|
|
500
|
+
}
|
|
501
|
+
const authoredEntity = metric.authoredEntity || undefined;
|
|
502
|
+
const blockPayload = placement.metric.entity === 'block' ?
|
|
503
|
+
compact({ entity: authoredEntity }) :
|
|
504
|
+
undefined;
|
|
505
|
+
return compact({
|
|
506
|
+
name: metric.name,
|
|
507
|
+
entity: placement.metric.entity === 'native' ? authoredEntity : undefined,
|
|
508
|
+
expression,
|
|
509
|
+
datatype: datatypeDoc(metric.type),
|
|
510
|
+
description: metric.description,
|
|
511
|
+
ai_context: aiContextDoc(metric.aiContext, isGoogleVersion),
|
|
512
|
+
custom_extensions: customExtensionsDoc(metric.customExtensions, `metric '${metric.name}'`, isGoogleVersion,
|
|
513
|
+
/* isModel= */ false, warnings, /* isField= */ false, blockPayload),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// The `Concept.field` pairs this document will still resolve on reload. A
|
|
517
|
+
// projection is only worth emitting as a projection if the field it names
|
|
518
|
+
// comes out in the same document -- see `parameterDoc`. It asks the loader's
|
|
519
|
+
// own resolver rather than walking the model here, so the emitter cannot come
|
|
520
|
+
// to a different answer than the load it is writing for. A resolver that
|
|
521
|
+
// throws leaves every projection emitted as authored: the emitter is not the
|
|
522
|
+
// place to discover that a model does not load.
|
|
523
|
+
function emitted(model) {
|
|
524
|
+
let concepts;
|
|
525
|
+
try {
|
|
526
|
+
concepts = (0, resolve_inheritance_1.declaredConceptFields)(model);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
const pairs = new Set();
|
|
532
|
+
for (const [name, concept] of concepts) {
|
|
533
|
+
// A field carrying no emitted datatype resolves to none on reload, so
|
|
534
|
+
// emitting a projection at it would suppress the parameter's resolved
|
|
535
|
+
// `type` and hand back an untyped parameter the loader rejects. Treating
|
|
536
|
+
// the pair as unresolvable falls the parameter back to that resolved type,
|
|
537
|
+
// which is the whole point of the fallback, and warns when there is none.
|
|
538
|
+
for (const [field, def] of concept.fields) {
|
|
539
|
+
if (datatypeDoc(def.type) !== undefined)
|
|
540
|
+
pairs.add(`${name}.${field}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return pairs;
|
|
544
|
+
}
|
|
545
|
+
// Inverts loader.convertAction. The executor collapses back to the open
|
|
546
|
+
// format's single-key object; parameters emit as their authoring form. Actions
|
|
547
|
+
// only exist under GOOGLE_VERSION, and loader.ts rejects custom_extensions on
|
|
548
|
+
// them.
|
|
549
|
+
function actionDoc(action, warnings, resolvable) {
|
|
550
|
+
if (action.customExtensions?.length) {
|
|
551
|
+
warnings.push(`action '${action.name}': ${action.customExtensions
|
|
552
|
+
.length} custom_extension(s) have no representation ` +
|
|
553
|
+
`under '${loader_1.GOOGLE_VERSION}' and are not serialized`);
|
|
554
|
+
}
|
|
555
|
+
return compact({
|
|
556
|
+
name: action.name,
|
|
557
|
+
description: action.description,
|
|
558
|
+
executor: action.executor ? executorDoc(action.executor) : undefined,
|
|
559
|
+
parameters: nonEmpty((action.parameters ??
|
|
560
|
+
[]).map(p => parameterDoc(p, resolvable, action.name, warnings))),
|
|
561
|
+
guards: nonEmpty(action.guards),
|
|
562
|
+
affects: nonEmpty((action.affects ?? []).map(affectedConceptDoc)),
|
|
563
|
+
ai_context: aiContextDoc(action.aiContext, /* isGoogleVersion= */ true),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// One action parameter, back in the form an author writes.
|
|
567
|
+
//
|
|
568
|
+
// A parameter projected from a field emits the projection and NOT the type.
|
|
569
|
+
// The type is the field's, copied in at load; writing it back out would make
|
|
570
|
+
// the document state a type alongside `concept`/`field`, which is the one
|
|
571
|
+
// thing the loader refuses outright -- so emitting it would produce a document
|
|
572
|
+
// this tool cannot read. The reload resolves it again from the same field and
|
|
573
|
+
// arrives at the same answer.
|
|
574
|
+
//
|
|
575
|
+
// Wording is emitted whether the author wrote it or inherited it, because
|
|
576
|
+
// nothing here can tell those apart and the two reload identically: an
|
|
577
|
+
// authored value that matches the field's is the field's. So this is verbose
|
|
578
|
+
// where the author was terse, and exact either way.
|
|
579
|
+
//
|
|
580
|
+
// `name` is likewise always written, even where the author let it default to
|
|
581
|
+
// the field's name. Same trade, same reason.
|
|
582
|
+
// A projection emits as a projection only where the field it names comes out
|
|
583
|
+
// in this same document. Pruning can take the concept away -- an entity no
|
|
584
|
+
// binding reaches is dropped, and the parameter that projected from it is
|
|
585
|
+
// deliberately kept, because it copied what it needed at load time. Emitting
|
|
586
|
+
// `concept`/`field` anyway would write a document that no longer loads: the
|
|
587
|
+
// pair resolves to nothing, and the type that would have rescued it was
|
|
588
|
+
// suppressed precisely because the projection was supposed to supply it. So
|
|
589
|
+
// where the field is gone, the parameter emits as the declared one it has
|
|
590
|
+
// effectively become -- its resolved `type`, and none of the projection.
|
|
591
|
+
function parameterDoc(p, resolvable, action, warnings) {
|
|
592
|
+
const projects = p.concept !== undefined &&
|
|
593
|
+
(resolvable === null || resolvable.has(`${p.concept}.${p.field}`));
|
|
594
|
+
// The fallback above rests on there BEING a resolved type to fall back to,
|
|
595
|
+
// and a parameter can reach here without one -- a pull of an aspect written
|
|
596
|
+
// before the type was recorded, or a model whose inheritance would not
|
|
597
|
+
// resolve. Both keys then drop and the parameter emits as name and wording
|
|
598
|
+
// alone, which reloads as a typeless parameter and is rejected by validate.
|
|
599
|
+
// Nothing here can invent the type, so say so at write time rather than let
|
|
600
|
+
// it surface as a load error against a document this wrote. `Opaque` is a
|
|
601
|
+
// valid standalone parameter type in the loader (unlike on fields and
|
|
602
|
+
// metrics), so `p.type` is emitted directly without `datatypeDoc`.
|
|
603
|
+
if (!projects && p.concept !== undefined && p.type === undefined) {
|
|
604
|
+
warnings.push(`action '${action}': parameter '${p.name}' projected from '${p.concept}.${p.field}', which this document does not carry, and ` +
|
|
605
|
+
`no resolved datatype was recorded to write in its place. The ` +
|
|
606
|
+
`parameter emits with no type and the document will not load until ` +
|
|
607
|
+
`one is supplied.`);
|
|
608
|
+
}
|
|
609
|
+
return compact({
|
|
610
|
+
name: p.name,
|
|
611
|
+
type: projects ? undefined : p.type,
|
|
612
|
+
concept: projects ? p.concept : undefined,
|
|
613
|
+
field: projects ? p.field : undefined,
|
|
614
|
+
label: p.label,
|
|
615
|
+
description: p.description,
|
|
616
|
+
ai_context: aiContextDoc(p.aiContext, /* isGoogleVersion= */ true),
|
|
617
|
+
required: p.required,
|
|
618
|
+
default: p.default,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
// One affected concept back to the open format. An entry that says nothing
|
|
622
|
+
// beyond which
|
|
623
|
+
// concept it touches emits as the bare name, which is the shorthand the author
|
|
624
|
+
// most likely wrote and the form the proposal uses; anything with an operation
|
|
625
|
+
// or fields needs the record.
|
|
626
|
+
//
|
|
627
|
+
// This NORMALIZES rather than reproducing the document byte for byte: an
|
|
628
|
+
// authored `{concept: Order}` with no operation comes back as `Order`. The two
|
|
629
|
+
// mean the same thing, so the emit is a fixed point after one pass, which is
|
|
630
|
+
// what the round-trip tests assert.
|
|
631
|
+
function affectedConceptDoc(affected) {
|
|
632
|
+
if (!affected.operation && !affected.fields?.length)
|
|
633
|
+
return affected.concept;
|
|
634
|
+
return compact({
|
|
635
|
+
concept: affected.concept,
|
|
636
|
+
operation: affected.operation,
|
|
637
|
+
fields: nonEmpty(affected.fields),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
// Inverts loader.convertConstraint. The judgment is a logical invariant and
|
|
641
|
+
// round-trips verbatim, since nothing about it is derived. The aspect type's
|
|
642
|
+
// reserved `expression` and `evaluation` fields have no counterpart here: one
|
|
643
|
+
// names a body the model no longer has, and the other was computed from which
|
|
644
|
+
// body a constraint stated, so emitting either would put something the author
|
|
645
|
+
// cannot write into an authored document.
|
|
646
|
+
function constraintDoc(constraint) {
|
|
647
|
+
// No custom_extensions call: a constraint carries no custom extensions.
|
|
648
|
+
return compact({
|
|
649
|
+
name: constraint.name,
|
|
650
|
+
judgment: constraint.judgment,
|
|
651
|
+
description: constraint.description,
|
|
652
|
+
on_violation: constraint.onViolation,
|
|
653
|
+
severity: constraint.severity,
|
|
654
|
+
ai_context: aiContextDoc(constraint.aiContext, /* isGoogleVersion= */ true),
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
// Collapses the IR's tagged executor union back to the open format's single-key
|
|
658
|
+
// object ({mcp: {...}} / {rest: {...}} / {grpc: {...}}).
|
|
659
|
+
function executorDoc(ex) {
|
|
660
|
+
switch (ex.kind) {
|
|
661
|
+
case 'mcp':
|
|
662
|
+
return { mcp: { server: ex.mcp.server, tool: ex.mcp.tool } };
|
|
663
|
+
case 'rest':
|
|
664
|
+
return { rest: { endpoint: ex.rest.endpoint, method: ex.rest.method } };
|
|
665
|
+
case 'grpc':
|
|
666
|
+
return { grpc: { service: ex.grpc.service, method: ex.grpc.method } };
|
|
667
|
+
case 'sql':
|
|
668
|
+
return { sql: { statements: [...ex.sql.statements] } };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// Inverts loader.convertRelationship: `from`/`to` are the endpoint entities and
|
|
672
|
+
// `from_columns`/`to_columns` are their positional join columns. An association
|
|
673
|
+
// (junction-table) edge has no open-format syntax, so only this direct-FK view
|
|
674
|
+
// is emitted and the junction detail is flagged.
|
|
675
|
+
function relationshipDoc(rel, warnings, version) {
|
|
676
|
+
const isGoogleVersion = version === loader_1.GOOGLE_VERSION;
|
|
677
|
+
const placement = loader_1.FORMAT_VERSION_PLACEMENT[version];
|
|
678
|
+
if (rel.association) {
|
|
679
|
+
warnings.push(`relationship '${rel.name}': association (junction-table) detail has no ` +
|
|
680
|
+
`open-format representation and is not serialized; only its ` +
|
|
681
|
+
`foreign-key endpoints are written.`);
|
|
682
|
+
}
|
|
683
|
+
if (!isGoogleVersion &&
|
|
684
|
+
(!rel.source.columns.length || !rel.destination.columns.length)) {
|
|
685
|
+
warnings.push(`relationship '${rel.name}': no join columns; a '${loader_1.OSSIE_VERSION}' document requires from_columns and to_columns ` +
|
|
686
|
+
`and will not load until they are set.`);
|
|
687
|
+
}
|
|
688
|
+
const description = rel.description || undefined;
|
|
689
|
+
const blockPayload = placement.relationship.description === 'block' ?
|
|
690
|
+
compact({ description }) :
|
|
691
|
+
undefined;
|
|
692
|
+
return compact({
|
|
693
|
+
name: rel.name,
|
|
694
|
+
from: rel.source.entity,
|
|
695
|
+
to: rel.destination.entity,
|
|
696
|
+
from_columns: nonEmpty(rel.source.columns),
|
|
697
|
+
to_columns: nonEmpty(rel.destination.columns),
|
|
698
|
+
description: placement.relationship.description === 'native' ? description :
|
|
699
|
+
undefined,
|
|
700
|
+
ai_context: aiContextDoc(rel.aiContext, isGoogleVersion),
|
|
701
|
+
custom_extensions: customExtensionsDoc(rel.customExtensions, `relationship '${rel.name}'`, isGoogleVersion,
|
|
702
|
+
/* isModel= */ false, warnings, /* isField= */ false, blockPayload),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
// Renders a field or metric `expression` for the target version. The IR comes
|
|
706
|
+
// from two sources:
|
|
707
|
+
// 1. YAML loaded by `loader.ts`, which populates `dialects` and `stringForm`.
|
|
708
|
+
// 2. Models built by `kcmd pull` or OWL import, which leave `dialects` unset
|
|
709
|
+
// and only populate `expression` (plus optional `importedExpression`).
|
|
710
|
+
function expressionDoc(item, isGoogleVersion, where, warnings) {
|
|
711
|
+
// Path 1 (YAML round-trip): re-emit the original `dialects` list in authored
|
|
712
|
+
// order. Under GOOGLE_VERSION, `stringForm` means the author wrote a bare
|
|
713
|
+
// string shorthand (`expression: "SUM(amt)"`), so emit the bare string back;
|
|
714
|
+
// under OSSIE_VERSION bare strings are forbidden, so emit `{dialects:
|
|
715
|
+
// [...]}`.
|
|
716
|
+
if (item.dialects?.length) {
|
|
717
|
+
const allowed = [];
|
|
718
|
+
for (const d of item.dialects) {
|
|
719
|
+
if (ALLOWED_DIALECT_SET.has(d.dialect)) {
|
|
720
|
+
allowed.push(d);
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
warnings.push(`${where}: expression in dialect '${d.dialect}' is not a dialect ` +
|
|
724
|
+
`the loader accepts and is not serialized`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (allowed.length) {
|
|
728
|
+
if (isGoogleVersion && item.stringForm) {
|
|
729
|
+
return allowed[0].expression;
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
dialects: allowed.map(d => ({ dialect: d.dialect, expression: d.expression })),
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Path 2 (`kcmd pull` / OWL import / fallback): `dialects` is absent, so
|
|
737
|
+
// synthesize it from `expression` and `importedExpression`.
|
|
738
|
+
if (isGoogleVersion && item.stringForm && item.expression !== undefined &&
|
|
739
|
+
item.importedExpression === undefined) {
|
|
740
|
+
return item.expression;
|
|
741
|
+
}
|
|
742
|
+
const dialects = [];
|
|
743
|
+
if (item.expression !== undefined) {
|
|
744
|
+
// `loader.ts` wraps a bare-string expression as ANSI_SQL; otherwise label
|
|
745
|
+
// the canonical expression BIGQUERY so reload picks it back up.
|
|
746
|
+
dialects.push({
|
|
747
|
+
dialect: item.stringForm ? 'ANSI_SQL' : CANONICAL_DIALECT,
|
|
748
|
+
expression: item.expression,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
if (item.importedExpression !== undefined) {
|
|
752
|
+
// `importedExpression` holds third-party vendor SQL, such as SNOWFLAKE or
|
|
753
|
+
// DATABRICKS, kept for transpilation. Only emit `importedDialect` when the
|
|
754
|
+
// loader accepts it; an unknown or missing label would fail to reload.
|
|
755
|
+
const label = item.importedDialect;
|
|
756
|
+
if (label === undefined) {
|
|
757
|
+
warnings.push(`${where}: imported expression has no dialect and is not serialized`);
|
|
758
|
+
}
|
|
759
|
+
else if (!ALLOWED_DIALECT_SET.has(label)) {
|
|
760
|
+
warnings.push(`${where}: imported expression in dialect '${label}' is not a ` +
|
|
761
|
+
`dialect the loader accepts and is not serialized`);
|
|
762
|
+
}
|
|
763
|
+
else if (dialects.some(d => d.dialect === label)) {
|
|
764
|
+
warnings.push(`${where}: imported expression in dialect '${label}' has the same ` +
|
|
765
|
+
`dialect as the expression and is not serialized`);
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
dialects.push({ dialect: label, expression: item.importedExpression });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return dialects.length ? { dialects } : undefined;
|
|
772
|
+
}
|
|
773
|
+
// Emits the field's `dimension` block when present, inverting
|
|
774
|
+
// loader.convertField (which sets `dimension = {}` for a bare marker and copies
|
|
775
|
+
// `is_time`). The key is emitted whenever the IR carries dimension metadata,
|
|
776
|
+
// even for an empty marker, so a dimension field reloads as a dimension.
|
|
777
|
+
function dimensionDoc(field) {
|
|
778
|
+
if (!field.dimension)
|
|
779
|
+
return undefined;
|
|
780
|
+
return compact({ is_time: field.dimension.isTime });
|
|
781
|
+
}
|
|
782
|
+
// Emits the structured `ai_context` for the target version, inverting
|
|
783
|
+
// loader.normalizeAiContext. Custom properties (`additionalProperties`) sit
|
|
784
|
+
// under `custom:` under GOOGLE_VERSION and as sibling keys under OSSIE_VERSION.
|
|
785
|
+
function aiContextDoc(ai, isGoogleVersion) {
|
|
786
|
+
if (!ai)
|
|
787
|
+
return undefined;
|
|
788
|
+
const custom = ai.additionalProperties ? compact(ai.additionalProperties) : undefined;
|
|
789
|
+
const hasCustom = custom !== undefined && Object.keys(custom).length > 0;
|
|
790
|
+
const base = compact({
|
|
791
|
+
instructions: ai.instructions,
|
|
792
|
+
synonyms: nonEmpty(ai.synonyms),
|
|
793
|
+
examples: nonEmpty(ai.examples),
|
|
794
|
+
});
|
|
795
|
+
if (!hasCustom) {
|
|
796
|
+
return Object.keys(base).length ? base : undefined;
|
|
797
|
+
}
|
|
798
|
+
return isGoogleVersion ? { ...base, custom } : { ...custom, ...base };
|
|
799
|
+
}
|
|
800
|
+
// The deployments a vanilla model lists only as targets in its GOOGLE blocks,
|
|
801
|
+
// named as a pull names them. A block whose data is not valid JSON gives none,
|
|
802
|
+
// and customExtensionsDoc warns that it was dropped. The other blocks still
|
|
803
|
+
// give theirs.
|
|
804
|
+
function deploymentsFromGoogleExtensions(model) {
|
|
805
|
+
const targets = (model.customExtensions ??
|
|
806
|
+
[]).flatMap(ext => blockTargets(ext, model.name));
|
|
807
|
+
return (0, deployment_target_1.unnamedDeployments)(targets);
|
|
808
|
+
}
|
|
809
|
+
// Serializes `custom_extensions` for one IR element. Under OSSIE_VERSION,
|
|
810
|
+
// third-party extensions are emitted, followed by a synthesized GOOGLE block
|
|
811
|
+
// when `blockPayload` carries any keys. Any other raw GOOGLE block on the IR
|
|
812
|
+
// is dropped with a warning so it cannot collide with the synthesized block or
|
|
813
|
+
// write keys the loader rejects. Under GOOGLE_VERSION,
|
|
814
|
+
// third-party extensions are written out unchanged without warning, while
|
|
815
|
+
// `GOOGLE` blocks are skipped (silently for the model's `deploymentTargets`
|
|
816
|
+
// block, and with a warning for any other `GOOGLE` block).
|
|
817
|
+
function customExtensionsDoc(exts, where, isGoogleVersion, isModel, warnings, isField = false, blockPayload) {
|
|
818
|
+
const payload = blockPayload ? { ...blockPayload } : {};
|
|
819
|
+
if (!exts?.length && Object.keys(payload).length === 0)
|
|
820
|
+
return undefined;
|
|
821
|
+
if (!isGoogleVersion) {
|
|
822
|
+
const kept = [];
|
|
823
|
+
let droppedGoogle = 0;
|
|
824
|
+
for (const e of exts ?? []) {
|
|
825
|
+
if (e.vendorName !== GOOGLE_VENDOR) {
|
|
826
|
+
kept.push({ vendor_name: e.vendorName, data: e.data });
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
// The loader's copy of the model's `deployments`, which the GOOGLE
|
|
830
|
+
// block below writes in their own form.
|
|
831
|
+
if (isModel && payload.deployments !== undefined &&
|
|
832
|
+
isDeploymentTargetsBlock(e)) {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
droppedGoogle += 1;
|
|
836
|
+
}
|
|
837
|
+
if (droppedGoogle > 0) {
|
|
838
|
+
warnings.push(isField ?
|
|
839
|
+
`${where}: ${droppedGoogle} GOOGLE custom_extension(s) have no ` +
|
|
840
|
+
`representation on a field under '${loader_1.OSSIE_VERSION}' and ` +
|
|
841
|
+
`are not serialized` :
|
|
842
|
+
`${where}: ${droppedGoogle} GOOGLE custom_extension(s) are not ` +
|
|
843
|
+
`serialized under '${loader_1.OSSIE_VERSION}'; its GOOGLE block is ` +
|
|
844
|
+
`written from its own fields`);
|
|
845
|
+
}
|
|
846
|
+
if (Object.keys(payload).length > 0) {
|
|
847
|
+
kept.push({
|
|
848
|
+
vendor_name: GOOGLE_VENDOR,
|
|
849
|
+
data: JSON.stringify(payload),
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
return nonEmpty(kept);
|
|
853
|
+
}
|
|
854
|
+
// No GOOGLE_VERSION construct has 'block' placement, so `blockPayload` is
|
|
855
|
+
// empty here.
|
|
856
|
+
const kept = [];
|
|
857
|
+
let droppedGoogle = 0;
|
|
858
|
+
for (const ext of exts ?? []) {
|
|
859
|
+
if (ext.vendorName !== GOOGLE_VENDOR) {
|
|
860
|
+
kept.push({ vendor_name: ext.vendorName, data: ext.data });
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (isModel && blockTargets(ext, '').length > 0)
|
|
864
|
+
continue;
|
|
865
|
+
droppedGoogle += 1;
|
|
866
|
+
}
|
|
867
|
+
if (droppedGoogle > 0) {
|
|
868
|
+
warnings.push(`${where}: ${droppedGoogle} GOOGLE custom_extension(s) have no ` +
|
|
869
|
+
`representation under '${loader_1.GOOGLE_VERSION}' and are not serialized`);
|
|
870
|
+
}
|
|
871
|
+
return nonEmpty(kept);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Returns true if `e.data` is a JSON object with a `'deploymentTargets'`
|
|
875
|
+
* property.
|
|
876
|
+
*
|
|
877
|
+
* The loader folds a Google-flavor model's `deployments` into such a block.
|
|
878
|
+
*/
|
|
879
|
+
function isDeploymentTargetsBlock(e) {
|
|
880
|
+
try {
|
|
881
|
+
const d = JSON.parse(e.data);
|
|
882
|
+
return !!d && typeof d === 'object' &&
|
|
883
|
+
Object.hasOwn(d, 'deploymentTargets');
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
function blockTargets(ext, modelName) {
|
|
890
|
+
try {
|
|
891
|
+
return (0, deployment_target_1.googleBlockTargets)([ext], modelName);
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return [];
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// Drops undefined-valued keys so the emitted YAML only shows fields the model
|
|
898
|
+
// actually set (matching the emitters' `compact` convention).
|
|
899
|
+
function compact(obj) {
|
|
900
|
+
const out = {};
|
|
901
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
902
|
+
if (v !== undefined)
|
|
903
|
+
out[k] = v;
|
|
904
|
+
}
|
|
905
|
+
return out;
|
|
906
|
+
}
|
|
907
|
+
// Returns the array, or undefined when empty/absent, so an empty list is
|
|
908
|
+
// omitted rather than rendered as `[]`.
|
|
909
|
+
function nonEmpty(items) {
|
|
910
|
+
return items && items.length ? items : undefined;
|
|
911
|
+
}
|