@datashow/super-visual-semantic 0.0.0-stage → 0.1.0-rc.1
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/package.json +24 -4
- package/src/compile-broker-adapter.mjs +109 -0
- package/src/compile-tmdl.mjs +141 -0
- package/src/index.mjs +3 -0
- package/src/semantic-catalog.mjs +75 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datashow/super-visual-semantic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "Governed TMDL and report-specific widget broker adapter compiler for Super Visual.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": "./src/index.mjs",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"license": "Apache-2.0",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.12.0"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/Datashow-Pty-Ltd/super-visual.git",
|
|
17
|
+
"directory": "packages/semantic"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/Datashow-Pty-Ltd/super-visual#readme",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@datashow/super-visual-compliance": "0.1.0-rc.1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { cp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { validateWidgetBrokerContract } from "@datashow/super-visual-compliance";
|
|
4
|
+
|
|
5
|
+
const tmdlIdentifier = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
6
|
+
const daxString = (value) => `"${value.replaceAll('"', '""')}"`;
|
|
7
|
+
const daxReference = (table, object) => `${tmdlIdentifier(table)}[${object.replaceAll("]", "]]" )}]`;
|
|
8
|
+
|
|
9
|
+
function parseObjectReference(semantic, reference, kind) {
|
|
10
|
+
const separator = typeof reference === "string" && reference.endsWith("]")
|
|
11
|
+
? reference.lastIndexOf("[", reference.length - 2)
|
|
12
|
+
: -1;
|
|
13
|
+
const qualified = separator > 0 && separator < reference.length - 2
|
|
14
|
+
? { table: reference.slice(0, separator), object: reference.slice(separator + 1, -1) }
|
|
15
|
+
: null;
|
|
16
|
+
if (qualified) {
|
|
17
|
+
const table = semantic.tables.find((candidate) => candidate.name === qualified.table);
|
|
18
|
+
const object = (kind === "column" ? table?.columns : table?.measures)?.find((candidate) => candidate.name === qualified.object);
|
|
19
|
+
if (!table || !object) throw new Error(`Unknown semantic ${kind} ${reference}`);
|
|
20
|
+
return { table, object, reference: daxReference(table.name, object.name) };
|
|
21
|
+
}
|
|
22
|
+
if (kind === "measure" && reference.startsWith("[") && reference.endsWith("]")) {
|
|
23
|
+
const name = reference.slice(1, -1);
|
|
24
|
+
const matches = semantic.tables.flatMap((table) => (table.measures ?? []).filter((measure) => measure.name === name).map((object) => ({ table, object })));
|
|
25
|
+
if (matches.length !== 1) throw new Error(`Measure ${reference} must resolve uniquely`);
|
|
26
|
+
return { ...matches[0], reference: `[${name.replaceAll("]", "]]" )}]` };
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`${kind} reference must use Table[Object] notation: ${reference}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function axisKeyExpression(axis) {
|
|
32
|
+
if (axis.object.dataType === "String") return axis.reference;
|
|
33
|
+
if (axis.object.dataType === "DateTime") return `FORMAT(${axis.reference}, "yyyy-MM-dd")`;
|
|
34
|
+
return `FORMAT(${axis.reference}, "0.############")`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function typedAxisKey(axis, expression) {
|
|
38
|
+
if (axis.object.dataType === "String") return expression;
|
|
39
|
+
if (axis.object.dataType === "DateTime") return `IFERROR(DATE(VALUE(LEFT(${expression}, 4)), VALUE(MID(${expression}, 6, 2)), VALUE(MID(${expression}, 9, 2))), BLANK())`;
|
|
40
|
+
return `IFERROR(VALUE(${expression}), BLANK())`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sortExpression(semantic, axis) {
|
|
44
|
+
const sortName = axis.object.sortByColumn;
|
|
45
|
+
if (sortName) {
|
|
46
|
+
const sort = parseObjectReference(semantic, `${axis.table.name}[${sortName}]`, "column");
|
|
47
|
+
if (sort.object.dataType === "DateTime") return `VALUE(FORMAT(${sort.reference}, "yyyymmdd"))`;
|
|
48
|
+
if (["Int64", "Decimal"].includes(sort.object.dataType)) return `INT(${sort.reference})`;
|
|
49
|
+
}
|
|
50
|
+
if (axis.object.dataType === "DateTime") return `VALUE(FORMAT(${axis.reference}, "yyyymmdd"))`;
|
|
51
|
+
if (["Int64", "Decimal"].includes(axis.object.dataType)) return `INT(${axis.reference})`;
|
|
52
|
+
return "0";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const measureLabel = (reference) => {
|
|
56
|
+
if (typeof reference !== "string" || !reference.endsWith("]")) return reference;
|
|
57
|
+
const separator = reference.lastIndexOf("[", reference.length - 2);
|
|
58
|
+
return separator >= 0 ? reference.slice(separator + 1, -1) : reference;
|
|
59
|
+
};
|
|
60
|
+
const pathExpression = (dimensions, formatter) => dimensions.map(formatter).join(' & "␟" & ');
|
|
61
|
+
|
|
62
|
+
function widgetRows(semantic, widget, index) {
|
|
63
|
+
const axis = widget.axisObject;
|
|
64
|
+
const sortReference = axis.object.sortByColumn ? parseObjectReference(semantic, `${axis.table.name}[${axis.object.sortByColumn}]`, "column").reference : null;
|
|
65
|
+
const groupBy = [...new Set([...widget.dimensionObjects.flatMap((dimension) => [dimension.keyObject.reference, dimension.labelObject.reference]), ...(sortReference ? [sortReference] : [])])];
|
|
66
|
+
const variable = `_widgetRows${index + 1}`;
|
|
67
|
+
const candidate = `_widgetCandidates${index + 1}`;
|
|
68
|
+
const selected = `_widgetSelected${index + 1}`;
|
|
69
|
+
const order = ["top-by-value", "bottom-by-value"].includes(widget.rowSelection)
|
|
70
|
+
? `[__RankValue], ${widget.rowSelection === "bottom-by-value" ? "ASC" : "DESC"}, [__Sort], ASC`
|
|
71
|
+
: `[__Sort], ${widget.rowSelection === "last-by-axis" ? "DESC" : "ASC"}, [AxisKey], ASC`;
|
|
72
|
+
const seriesRows = widget.measures.map((measure) => `SELECTCOLUMNS(\n ${selected},\n "WidgetId", ${daxString(widget.id)},\n "AxisKey", [AxisKey],\n "Label", [Label],\n "Sort", [__Sort],\n "SeriesKey", ${daxString(measure)},\n "SeriesLabel", ${daxString(measureLabel(measure))}\n )`);
|
|
73
|
+
return {
|
|
74
|
+
variable,
|
|
75
|
+
dax: `VAR ${candidate} =\n FILTER(\n SELECTCOLUMNS(\n SUMMARIZECOLUMNS(${groupBy.join(", ")}, "__RankValue", CALCULATE(${widget.measureObjects[0].reference})),\n "AxisKey", ${pathExpression(widget.dimensionObjects, (dimension) => axisKeyExpression(dimension.keyObject))},\n "Label", ${pathExpression(widget.dimensionObjects, (dimension) => `FORMAT(${dimension.labelObject.reference}, "")`)},\n "__Sort", ${sortExpression(semantic, axis)},\n "__RankValue", [__RankValue]\n ),\n NOT ISBLANK([__RankValue])\n )\n VAR ${selected} = TOPN(${widget.rowLimit}, ${candidate}, ${order})\n VAR ${variable} =\n ${seriesRows.length === 1 ? seriesRows[0] : `UNION(\n ${seriesRows.join(",\n ")}\n )`}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function compileBrokerAdapter({ baseModelDirectory, outputDirectory, broker, semanticModel }) {
|
|
80
|
+
if (broker.adapterMode !== "source-direct-import" || broker.baseModelMutation !== false) throw new Error("Broker contract must preserve a source-direct report adapter without base-model mutation");
|
|
81
|
+
const errors = validateWidgetBrokerContract(broker, semanticModel);
|
|
82
|
+
if (errors.length) throw new Error(`Widget broker validation failed:\n- ${errors.join("\n- ")}`);
|
|
83
|
+
const widgets = broker.widgets.map((widget) => ({
|
|
84
|
+
...widget,
|
|
85
|
+
axisObject: parseObjectReference(semanticModel, widget.axis, "column"),
|
|
86
|
+
dimensionObjects: widget.dimensions.map((dimension) => ({ keyObject: parseObjectReference(semanticModel, dimension.key, "column"), labelObject: parseObjectReference(semanticModel, dimension.label, "column") })),
|
|
87
|
+
measureObjects: widget.measures.map((measure) => parseObjectReference(semanticModel, measure, "measure")),
|
|
88
|
+
}));
|
|
89
|
+
const rows = widgets.map((widget, index) => widgetRows(semanticModel, widget, index));
|
|
90
|
+
const rowSet = rows.length === 1 ? rows[0].variable : `UNION(${rows.map((item) => item.variable).join(", ")})`;
|
|
91
|
+
const valueCases = widgets.flatMap((widget) => {
|
|
92
|
+
const removeFilter = widget.selectable ? `, REMOVEFILTERS(${widget.axisObject.reference})` : "";
|
|
93
|
+
const filters = widget.dimensionObjects.map((dimension, index) => {
|
|
94
|
+
const part = `PATHITEM(SUBSTITUTE(_axisKey, "␟", "|"), ${index + 1})`;
|
|
95
|
+
return `TREATAS({ ${typedAxisKey(dimension.keyObject, part)} }, ${dimension.keyObject.reference})`;
|
|
96
|
+
});
|
|
97
|
+
return widget.measureObjects.map((measureObject, index) => ` ${daxString(`${widget.id}|${widget.measures[index]}`)}, CALCULATE(${measureObject.reference}${removeFilter}${filters.length ? `, ${filters.join(", ")}` : ""})`);
|
|
98
|
+
}).join(",\n");
|
|
99
|
+
const contextParts = widgets.map((widget) => `${daxString(`${widget.id}=`)} & CONCATENATEX(TOPN(5, VALUES(${widget.axisObject.reference}), ${widget.axisObject.reference}, ASC), FORMAT(${widget.axisObject.reference}, ""), ",")`);
|
|
100
|
+
await rm(outputDirectory, { recursive: true, force: true });
|
|
101
|
+
await cp(baseModelDirectory, outputDirectory, { recursive: true });
|
|
102
|
+
const definition = path.join(outputDirectory, "definition");
|
|
103
|
+
const tablePath = path.join(definition, "tables", "Widget Axis.tmdl");
|
|
104
|
+
await writeFile(tablePath, `/// Report-specific disconnected broker generated from the validated widget contract.\ntable 'Widget Axis'\n\n\tmeasure 'Widget Value' = \`\`\`\n\t\t\tVAR _widget = SELECTEDVALUE('Widget Axis'[WidgetId])\n\t\t\tVAR _axisKey = SELECTEDVALUE('Widget Axis'[AxisKey])\n\t\t\tVAR _seriesKey = SELECTEDVALUE('Widget Axis'[SeriesKey])\n\t\t\tRETURN\n\t\t\t\tSWITCH(\n\t\t\t\t\t_widget & "|" & _seriesKey,\n${valueCases}\n\t\t\t\t)\n\t\t\t\`\`\`\n\t\tformatString: $#,##0.00\n\t\tdisplayFolder: Broker\n\n\tmeasure 'Broker Context' = ${contextParts.join(' & ";" & ')}\n\t\tdisplayFolder: Broker\n\n\tcolumn WidgetId\n\t\tdataType: string\n\t\tsummarizeBy: none\n\t\tsourceColumn: [WidgetId]\n\n\tcolumn AxisKey\n\t\tdataType: string\n\t\tsummarizeBy: none\n\t\tsourceColumn: [AxisKey]\n\n\tcolumn Label\n\t\tdataType: string\n\t\tsummarizeBy: none\n\t\tsourceColumn: [Label]\n\n\tcolumn Sort\n\t\tdataType: int64\n\t\tsummarizeBy: none\n\t\tsourceColumn: [Sort]\n\n\tcolumn SeriesKey\n\t\tdataType: string\n\t\tsummarizeBy: none\n\t\tsourceColumn: [SeriesKey]\n\n\tcolumn SeriesLabel\n\t\tdataType: string\n\t\tsummarizeBy: none\n\t\tsourceColumn: [SeriesLabel]\n\n\tpartition 'Widget Axis' = calculated\n\t\tmode: import\n\t\tsource = \`\`\`\n\t\t\t${rows.map((item) => item.dax).join("\n ")}\n\t\t\tRETURN ${rowSet}\n\t\t\t\`\`\`\n`);
|
|
105
|
+
const modelPath = path.join(definition, "model.tmdl");
|
|
106
|
+
const model = await readFile(modelPath, "utf8");
|
|
107
|
+
if (!model.includes("ref table 'Widget Axis'")) await writeFile(modelPath, `${model.trimEnd()}\nref table 'Widget Axis'\n`);
|
|
108
|
+
return { outputDirectory, widgets: widgets.map(({ id, shape, dimensions, axis, measures }) => ({ id, shape, dimensions, axis, measures })) };
|
|
109
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function identifier(value) {
|
|
6
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) ? value : `'${value.replaceAll("'", "''")}'`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function documentation(value) {
|
|
10
|
+
const words = String(value).replaceAll("\r", " ").replaceAll("\n", " ").trim().split(/\s+/);
|
|
11
|
+
const lines = [];
|
|
12
|
+
let line = "";
|
|
13
|
+
for (const word of words) {
|
|
14
|
+
if (line && `${line} ${word}`.length > 76) {
|
|
15
|
+
lines.push(line);
|
|
16
|
+
line = word;
|
|
17
|
+
} else line = line ? `${line} ${word}` : word;
|
|
18
|
+
}
|
|
19
|
+
if (line) lines.push(line);
|
|
20
|
+
return lines.map((item) => `/// ${item}`).join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stableDatabaseId(name) {
|
|
24
|
+
const bytes = createHash("sha256").update(name).digest().subarray(0, 16);
|
|
25
|
+
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
|
|
26
|
+
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
|
|
27
|
+
const hex = bytes.toString("hex");
|
|
28
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mString(value) {
|
|
32
|
+
return `"${String(value).replaceAll('"', '""').replaceAll("\r\n", "#(lf)").replaceAll("\r", "#(cr)").replaceAll("\n", "#(lf)")}"`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderMeasure(measure) {
|
|
36
|
+
const expression = measure.expression.includes("\n")
|
|
37
|
+
? `\`\`\`\n${measure.expression.split("\n").map((line) => `\t\t\t${line}`).join("\n")}\n\t\t\t\`\`\``
|
|
38
|
+
: measure.expression;
|
|
39
|
+
return [
|
|
40
|
+
`\t${documentation(measure.description).replaceAll("\n", "\n\t")}`,
|
|
41
|
+
`\tmeasure ${identifier(measure.name)} = ${expression}`,
|
|
42
|
+
`\t\tformatString: ${measure.formatString}`,
|
|
43
|
+
`\t\tdisplayFolder: ${measure.displayFolder}`,
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderColumn(column, sortKeys, hierarchyLevels) {
|
|
48
|
+
const type = { Int64: "int64", Decimal: "decimal", String: "string", DateTime: "dateTime", Boolean: "boolean" }[column.dataType];
|
|
49
|
+
const lines = [
|
|
50
|
+
`\t${documentation(column.description).replaceAll("\n", "\n\t")}`,
|
|
51
|
+
`\tcolumn ${identifier(column.name)}`,
|
|
52
|
+
`\t\tdataType: ${type}`,
|
|
53
|
+
];
|
|
54
|
+
if (column.formatString) lines.push(`\t\tformatString: ${column.formatString}`);
|
|
55
|
+
if (column.hidden) lines.push("\t\tisHidden");
|
|
56
|
+
lines.push(`\t\tsummarizeBy: ${column.summarizeBy ?? "none"}`);
|
|
57
|
+
lines.push(`\t\tsourceColumn: ${column.sourceColumn}`);
|
|
58
|
+
if (column.dataCategory) lines.push(`\t\tdataCategory: ${column.dataCategory}`);
|
|
59
|
+
if (column.sortByColumn) lines.push(`\t\tsortByColumn: ${identifier(column.sortByColumn)}`);
|
|
60
|
+
if (column.hidden && !column.sortByColumn && !sortKeys.has(column.name) && !hierarchyLevels.has(column.name)) lines.push("\t\tisAvailableInMdx: false");
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderTable(table) {
|
|
65
|
+
const sortKeys = new Set(table.columns.flatMap((column) => column.sortByColumn ? [column.sortByColumn] : []));
|
|
66
|
+
const hierarchyLevels = new Set((table.hierarchies ?? []).flatMap((hierarchy) => hierarchy.levels.map((level) => level.column)));
|
|
67
|
+
const measures = (table.measures ?? []).map(renderMeasure).join("\n\n");
|
|
68
|
+
const columns = table.columns.map((column) => renderColumn(column, sortKeys, hierarchyLevels)).join("\n\n");
|
|
69
|
+
const hierarchies = (table.hierarchies ?? []).map((hierarchy) => [
|
|
70
|
+
`\thierarchy ${identifier(hierarchy.name)}`,
|
|
71
|
+
...hierarchy.levels.flatMap((level) => [`\n\t\tlevel ${identifier(level.name)}`, `\t\t\tcolumn: ${identifier(level.column)}`]),
|
|
72
|
+
].join("\n")).join("\n\n");
|
|
73
|
+
return [
|
|
74
|
+
`${documentation(`${table.description} Grain: ${table.grain}`)}\ntable ${identifier(table.name)}`,
|
|
75
|
+
table.dataCategory ? `\tdataCategory: ${table.dataCategory}` : "",
|
|
76
|
+
measures,
|
|
77
|
+
columns,
|
|
78
|
+
hierarchies,
|
|
79
|
+
`\tpartition ${identifier(table.name)} = m`,
|
|
80
|
+
"\t\tmode: import",
|
|
81
|
+
"\t\tsource =",
|
|
82
|
+
"\t\t\tlet",
|
|
83
|
+
"\t\t\t\tSource = Sql.Database(#\"Server\", #\"Database\"),",
|
|
84
|
+
"\t\t\t\t// Selected and shaped business columns required by the governed model.",
|
|
85
|
+
`\t\t\t\t#\"Queried Rows\" = Value.NativeQuery(Source, ${mString(table.source.sql.trim())}, null, [EnableFolding=true])`,
|
|
86
|
+
"\t\t\tin",
|
|
87
|
+
"\t\t\t\t#\"Queried Rows\"",
|
|
88
|
+
].filter(Boolean).join("\n\n").replaceAll("\n\n\t\t", "\n\t\t") + "\n";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function validateSemanticModel(model) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
if (model?.contractVersion !== 1) errors.push("semantic model contractVersion must be 1");
|
|
94
|
+
if (!model?.name) errors.push("semantic model name is required");
|
|
95
|
+
if (!Array.isArray(model?.tables) || !model.tables.length) errors.push("semantic model requires tables");
|
|
96
|
+
const tableNames = new Set();
|
|
97
|
+
const measures = new Set();
|
|
98
|
+
for (const table of model?.tables ?? []) {
|
|
99
|
+
if (tableNames.has(table.name)) errors.push(`duplicate semantic table ${table.name}`);
|
|
100
|
+
tableNames.add(table.name);
|
|
101
|
+
if (!/^\s*(select|with)\b/i.test(table.source?.sql ?? "") || /\b(insert|update|delete|merge|drop|alter|create|truncate|execute|exec|grant|revoke)\b/i.test(table.source?.sql ?? "")) errors.push(`${table.name}: source SQL must be read-only`);
|
|
102
|
+
const columns = new Set();
|
|
103
|
+
for (const column of table.columns ?? []) {
|
|
104
|
+
if (columns.has(column.name)) errors.push(`${table.name}: duplicate column ${column.name}`);
|
|
105
|
+
columns.add(column.name);
|
|
106
|
+
if (column.sortByColumn && !(table.columns ?? []).some((candidate) => candidate.name === column.sortByColumn)) errors.push(`${table.name}.${column.name}: missing sort column ${column.sortByColumn}`);
|
|
107
|
+
}
|
|
108
|
+
for (const measure of table.measures ?? []) {
|
|
109
|
+
if (measures.has(measure.name)) errors.push(`measure ${measure.name} must be globally unique`);
|
|
110
|
+
measures.add(measure.name);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const relationship of model?.relationships ?? []) {
|
|
114
|
+
for (const [side, tableName, columnName] of [["from", relationship.fromTable, relationship.fromColumn], ["to", relationship.toTable, relationship.toColumn]]) {
|
|
115
|
+
const table = (model.tables ?? []).find((candidate) => candidate.name === tableName);
|
|
116
|
+
if (!table?.columns?.some((column) => column.name === columnName)) errors.push(`${relationship.name}: unresolved ${side} column ${tableName}[${columnName}]`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return errors;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function compileTmdl(model, outputDirectory) {
|
|
123
|
+
const errors = validateSemanticModel(model);
|
|
124
|
+
if (errors.length) throw new Error(`Semantic model validation failed:\n- ${errors.join("\n- ")}`);
|
|
125
|
+
const definition = path.join(outputDirectory, "definition");
|
|
126
|
+
const tables = path.join(definition, "tables");
|
|
127
|
+
await mkdir(tables, { recursive: true });
|
|
128
|
+
await writeFile(path.join(outputDirectory, "definition.pbism"), `${JSON.stringify({ $schema: "https://developer.microsoft.com/json-schemas/fabric/item/semanticModel/definitionProperties/1.0.0/schema.json", version: "4.2", settings: { qnaEnabled: true } }, null, 2)}\n`);
|
|
129
|
+
await writeFile(path.join(definition, "database.tmdl"), `database ${stableDatabaseId(model.name)}\n\tcompatibilityLevel: ${model.compatibilityLevel}\n\tcompatibilityMode: powerBI\n\tlanguage: 3081\n`);
|
|
130
|
+
await writeFile(path.join(definition, "model.tmdl"), `model Model\n\tculture: ${model.culture}\n\tdefaultPowerBIDataSourceVersion: powerBI_V3\n\tsourceQueryCulture: ${model.culture}\n\tdiscourageImplicitMeasures\n\n${model.tables.map((table) => `ref table ${identifier(table.name)}`).join("\n")}\n`);
|
|
131
|
+
await writeFile(path.join(definition, "expressions.tmdl"), `expression Server = ${mString(model.sourceParameters.server)} meta [IsParameterQuery=true, Type="Text", IsParameterQueryRequired=true]\n\nexpression Database = ${mString(model.sourceParameters.database)} meta [IsParameterQuery=true, Type="Text", IsParameterQueryRequired=true]\n`);
|
|
132
|
+
await writeFile(path.join(definition, "relationships.tmdl"), (model.relationships ?? []).map((relationship) => [
|
|
133
|
+
`relationship ${identifier(relationship.name)}`,
|
|
134
|
+
relationship.active === false ? "\tisActive: false" : "",
|
|
135
|
+
`\tcrossFilteringBehavior: ${relationship.crossFilteringBehavior ?? "oneDirection"}`,
|
|
136
|
+
`\tfromColumn: ${identifier(relationship.fromTable)}.${identifier(relationship.fromColumn)}`,
|
|
137
|
+
`\ttoColumn: ${identifier(relationship.toTable)}.${identifier(relationship.toColumn)}`,
|
|
138
|
+
].filter(Boolean).join("\n")).join("\n\n"));
|
|
139
|
+
for (const table of model.tables) await writeFile(path.join(tables, `${table.name}.tmdl`), renderTable(table));
|
|
140
|
+
return { outputDirectory, tableCount: model.tables.length, databaseId: stableDatabaseId(model.name) };
|
|
141
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const SEMANTIC_CATALOG_TABLE = "Super Visual Model Catalog";
|
|
2
|
+
|
|
3
|
+
function referencesCatalog(reference) {
|
|
4
|
+
return typeof reference === "string" && reference.startsWith(`${SEMANTIC_CATALOG_TABLE}[`);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function brokerUsesSemanticCatalog(broker) {
|
|
8
|
+
return (broker?.widgets ?? []).some((widget) => [
|
|
9
|
+
widget.axis,
|
|
10
|
+
...(widget.dimensions ?? []).flatMap((dimension) => [dimension.key, dimension.label]),
|
|
11
|
+
...(widget.measures ?? []),
|
|
12
|
+
].some(referencesCatalog));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sqlString(value) {
|
|
16
|
+
return `N'${String(value ?? "").replaceAll("'", "''")}'`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function catalogEntities(model) {
|
|
20
|
+
const modelName = model.name ?? model.displayName ?? "Semantic model";
|
|
21
|
+
const entities = [{ type: "Model", parent: "", name: modelName }];
|
|
22
|
+
for (const table of model.tables ?? []) {
|
|
23
|
+
if ([SEMANTIC_CATALOG_TABLE, "Widget Axis"].includes(table.name)) continue;
|
|
24
|
+
entities.push({ type: "Table", parent: modelName, name: table.name });
|
|
25
|
+
for (const column of table.columns ?? []) entities.push({ type: "Column", parent: table.name, name: column.name });
|
|
26
|
+
for (const measure of table.measures ?? []) entities.push({ type: "Measure", parent: table.name, name: measure.name });
|
|
27
|
+
for (const hierarchy of table.hierarchies ?? []) {
|
|
28
|
+
entities.push({ type: "Hierarchy", parent: table.name, name: hierarchy.name });
|
|
29
|
+
for (const level of hierarchy.levels ?? []) entities.push({ type: "Hierarchy level", parent: `${table.name}.${hierarchy.name}`, name: level.name });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const relationship of model.relationships ?? []) {
|
|
33
|
+
entities.push({ type: "Relationship", parent: `${relationship.fromTable ?? relationship.from ?? ""} → ${relationship.toTable ?? relationship.to ?? ""}`, name: relationship.name });
|
|
34
|
+
}
|
|
35
|
+
const parameterNames = new Set([...Object.keys(model.sourceParameters ?? {}), ...(model.parameters ?? []).map((parameter) => parameter.name)]);
|
|
36
|
+
for (const name of [...parameterNames].sort()) {
|
|
37
|
+
entities.push({ type: "Parameter", parent: modelName, name: name[0].toUpperCase() + name.slice(1) });
|
|
38
|
+
}
|
|
39
|
+
return entities;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function semanticModelWithCatalog(model, broker, catalogSource = model) {
|
|
43
|
+
if (!brokerUsesSemanticCatalog(broker)) return model;
|
|
44
|
+
if ((model.tables ?? []).some((table) => table.name === SEMANTIC_CATALOG_TABLE)) throw new Error(`${SEMANTIC_CATALOG_TABLE} is reserved for the generated semantic catalog`);
|
|
45
|
+
const entities = catalogEntities(catalogSource);
|
|
46
|
+
const values = entities.map((entity, index) => `(${sqlString(entity.type)}, ${sqlString(entity.parent)}, ${sqlString(entity.name)}, ${index + 1})`).join(",\n ");
|
|
47
|
+
const catalog = {
|
|
48
|
+
name: SEMANTIC_CATALOG_TABLE,
|
|
49
|
+
role: "dimension",
|
|
50
|
+
grain: "One row per declared semantic-model entity.",
|
|
51
|
+
description: "Generated searchable inventory used by the Super Visual connectivity sample.",
|
|
52
|
+
source: {
|
|
53
|
+
referencedTables: [],
|
|
54
|
+
sql: `SELECT catalog.[EntityType], catalog.[Parent], catalog.[Entity], catalog.[Sort]\nFROM (VALUES\n ${values}\n ) AS catalog([EntityType], [Parent], [Entity], [Sort])`,
|
|
55
|
+
},
|
|
56
|
+
columns: [
|
|
57
|
+
{ name: "Entity Type", sourceColumn: "EntityType", dataType: "String", hidden: false, summarizeBy: "none", description: "Kind of semantic entity." },
|
|
58
|
+
{ name: "Parent", sourceColumn: "Parent", dataType: "String", hidden: false, summarizeBy: "none", description: "Containing model, table, hierarchy, or relationship endpoints." },
|
|
59
|
+
{ name: "Entity", sourceColumn: "Entity", dataType: "String", hidden: false, summarizeBy: "none", description: "Readable semantic entity name." },
|
|
60
|
+
{ name: "Sort", sourceColumn: "Sort", dataType: "Int64", hidden: true, summarizeBy: "none", description: "Stable catalog order." },
|
|
61
|
+
],
|
|
62
|
+
measures: [
|
|
63
|
+
{ name: "Entity Count", expression: `COUNTROWS('${SEMANTIC_CATALOG_TABLE}')`, formatString: "0", displayFolder: "Connectivity", description: "Count of catalog entities in the active context." },
|
|
64
|
+
],
|
|
65
|
+
hierarchies: [{
|
|
66
|
+
name: "Semantic Entity",
|
|
67
|
+
levels: [
|
|
68
|
+
{ name: "Entity Type", column: "Entity Type" },
|
|
69
|
+
{ name: "Parent", column: "Parent" },
|
|
70
|
+
{ name: "Entity", column: "Entity" },
|
|
71
|
+
],
|
|
72
|
+
}],
|
|
73
|
+
};
|
|
74
|
+
return { ...model, tables: [...model.tables, catalog] };
|
|
75
|
+
}
|
package/README.md
DELETED