@bluemarble/bi 0.0.0-stage → 0.1.2
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/README.md +76 -2
- package/dist/DashboardDevtools-DLhEr3fN.js +6384 -0
- package/dist/DashboardDevtools-DLhEr3fN.js.map +1 -0
- package/dist/ModelDictionary-DU6i_QSn.js +492 -0
- package/dist/ModelDictionary-DU6i_QSn.js.map +1 -0
- package/dist/client/index.d.ts +410 -0
- package/dist/client/index.js +1325 -0
- package/dist/client/index.js.map +1 -0
- package/dist/compare-DloZhg1T.js +58 -0
- package/dist/compare-DloZhg1T.js.map +1 -0
- package/dist/layout-iPByu6V0.js +899 -0
- package/dist/layout-iPByu6V0.js.map +1 -0
- package/dist/server/index.d.ts +171 -0
- package/dist/server/index.js +744 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types-CB4sK4jP.d.ts +371 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +0 -0
- package/package.json +75 -4
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import { C as validateModel, D as parseColumnRef, E as modelEdges, O as reachFrom, S as modelMeta, T as measureHome, _ as AGGREGATIONS, b as aggregateSql, d as layoutModelWarnings, f as validateLayout, g as toModelCode, h as modelObjectCode, k as referencedTables, m as modelImports, p as canonicalJson, u as layoutFileContent, w as joinPlan, x as isAggregate } from "../layout-iPByu6V0.js";
|
|
2
|
+
import { n as diffRows, t as describeDiffs } from "../compare-DloZhg1T.js";
|
|
3
|
+
//#region src/server/compile.ts
|
|
4
|
+
var DashboardQueryError = class extends Error {};
|
|
5
|
+
const MAX_FILTER_VALUES = 1e3;
|
|
6
|
+
const quote = (identifier) => `\`${identifier}\``;
|
|
7
|
+
/**
|
|
8
|
+
* Expressão de uma medida. `leaf` diz como sai cada agregação (`sum`, `count`, `avg`...): a coluna
|
|
9
|
+
* já agregada pela tabela de origem.
|
|
10
|
+
*/
|
|
11
|
+
function measureSql(model, name, leaf, stack = []) {
|
|
12
|
+
if (stack.includes(name)) throw new DashboardQueryError(`Medida circular: ${[...stack, name].join(" -> ")}`);
|
|
13
|
+
const measure = model.measures[name];
|
|
14
|
+
if (!measure) throw new DashboardQueryError(`Medida desconhecida: "${name}"`);
|
|
15
|
+
if (isAggregate(measure)) return leaf(name, measure);
|
|
16
|
+
const next = [...stack, name];
|
|
17
|
+
const ref = (other) => measureSql(model, other, leaf, next);
|
|
18
|
+
if (measure.type === "ratio") return `(${ref(measure.numerator)}) / NULLIF(${ref(measure.denominator)}, 0)`;
|
|
19
|
+
return `(${measure.expression.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, other) => `(${ref(other)})`)})`;
|
|
20
|
+
}
|
|
21
|
+
/** Agregações de que as medidas pedidas dependem (razões e fórmulas abertas). */
|
|
22
|
+
function leafMeasures(model, names) {
|
|
23
|
+
const leaves = /* @__PURE__ */ new Set();
|
|
24
|
+
for (const name of names) measureSql(model, name, (leaf, measure) => {
|
|
25
|
+
leaves.add(leaf);
|
|
26
|
+
return measure.sql;
|
|
27
|
+
});
|
|
28
|
+
return [...leaves];
|
|
29
|
+
}
|
|
30
|
+
function compileFilters(model, query) {
|
|
31
|
+
const filters = [];
|
|
32
|
+
for (const [name, values] of Object.entries(query.filters ?? {})) {
|
|
33
|
+
const dimension = model.dimensions[name];
|
|
34
|
+
if (!dimension) throw new DashboardQueryError(`Filtro em dimensão desconhecida: "${name}"`);
|
|
35
|
+
if (!Array.isArray(values) || !values.length) continue;
|
|
36
|
+
if (values.length > MAX_FILTER_VALUES) throw new DashboardQueryError(`Valores demais no filtro "${name}"`);
|
|
37
|
+
for (const value of values) if (!(dimension.type === "number" ? Number.isFinite(value) : typeof value === "string")) throw new DashboardQueryError(`Valor inválido no filtro "${name}": ${value}`);
|
|
38
|
+
filters.push({
|
|
39
|
+
dimension: name,
|
|
40
|
+
clause: `${dimension.sql} IN (${values.map(() => "?").join(", ")})`,
|
|
41
|
+
params: values
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return filters;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Transforma uma consulta (dimensões + medidas + filtros) em SQL parametrizado (ver `compileStar`).
|
|
48
|
+
*
|
|
49
|
+
* Nomes de dimensões/medidas só são aceitos se existirem no modelo; valores de filtro vão sempre
|
|
50
|
+
* como parâmetro — nada vindo da request é concatenado no SQL.
|
|
51
|
+
*/
|
|
52
|
+
function compileQuery(model, query, options = {}) {
|
|
53
|
+
const dimensions = query.dimensions ?? [];
|
|
54
|
+
const measures = query.measures ?? [];
|
|
55
|
+
if (!dimensions.length && !measures.length) throw new DashboardQueryError("Informe ao menos uma dimensão ou medida");
|
|
56
|
+
for (const name of dimensions) if (!model.dimensions[name]) throw new DashboardQueryError(`Dimensão desconhecida: "${name}"`);
|
|
57
|
+
return compileStar(model, dimensions, measures, compileFilters(model, query), options);
|
|
58
|
+
}
|
|
59
|
+
const range = (count) => Array.from({ length: count }, (_, index) => index + 1).join(", ");
|
|
60
|
+
/**
|
|
61
|
+
* Cada tabela de origem (a das medidas pedidas) vira uma agregação no próprio
|
|
62
|
+
* grão, com LEFT JOIN só nas tabelas que levam às dimensões e filtros usados:
|
|
63
|
+
*
|
|
64
|
+
* SELECT <dimensões>, SUM(<medida>) FROM fPesagem
|
|
65
|
+
* LEFT JOIN dOperacao ON dOperacao.id = fPesagem.navio_operacao_id
|
|
66
|
+
* WHERE <filtros> GROUP BY <dimensões>
|
|
67
|
+
*
|
|
68
|
+
* Com medidas de mais de uma fato, cada uma agrega separada e o resultado é somado por dimensão
|
|
69
|
+
* (`UNION ALL` + `GROUP BY`) — juntar duas fatos antes de somar multiplicaria os valores. Razões e
|
|
70
|
+
* fórmulas são calculadas no fim, sobre os totais.
|
|
71
|
+
*
|
|
72
|
+
* Filtro numa dimensão que a fato não alcança é ignorado por ela (como no Power BI, a medida não é
|
|
73
|
+
* filtrada); agrupar por uma dimensão que ela não alcança é erro.
|
|
74
|
+
*/
|
|
75
|
+
function compileStar(model, dimensions, measures, filters, { sampleLimit }) {
|
|
76
|
+
const { tables } = model;
|
|
77
|
+
const tableNames = Object.keys(tables);
|
|
78
|
+
const edges = modelEdges(model);
|
|
79
|
+
const tableOf = (sql) => referencedTables(sql, tableNames);
|
|
80
|
+
const dimensionTable = (name) => tableOf(model.dimensions[name].sql)[0];
|
|
81
|
+
const groups = /* @__PURE__ */ new Map();
|
|
82
|
+
const leafOf = (name) => model.measures[name];
|
|
83
|
+
for (const leaf of leafMeasures(model, measures)) {
|
|
84
|
+
const measure = model.measures[leaf];
|
|
85
|
+
if (!isAggregate(measure)) continue;
|
|
86
|
+
const home = measureHome(tableOf(measure.sql), edges);
|
|
87
|
+
if (!home) throw new DashboardQueryError(`Medida "${leaf}" sem tabela de origem`);
|
|
88
|
+
groups.set(home, [...groups.get(home) ?? [], leaf]);
|
|
89
|
+
}
|
|
90
|
+
if (!groups.size) groups.set(dimensionHome(model, dimensions, filters, edges), []);
|
|
91
|
+
const source = (name) => {
|
|
92
|
+
const sql = tables[name].sql;
|
|
93
|
+
return sampleLimit === void 0 ? `(${sql})` : `(SELECT * FROM (${sql}) t LIMIT ${Math.max(0, Math.trunc(sampleLimit))})`;
|
|
94
|
+
};
|
|
95
|
+
const aggregate = (home, leaves) => {
|
|
96
|
+
const { paths } = reachFrom(home, edges);
|
|
97
|
+
for (const name of dimensions) if (!paths.has(dimensionTable(name))) throw new DashboardQueryError(leaves.length ? `A dimensão "${name}" (${dimensionTable(name)}) não se relaciona com ${home}, de onde vêm ${leaves.join(", ")}` : `As dimensões ${dimensions.join(", ")} não se relacionam entre si`);
|
|
98
|
+
const applied = filters.filter((filter) => paths.has(dimensionTable(filter.dimension)));
|
|
99
|
+
const needed = /* @__PURE__ */ new Set([
|
|
100
|
+
...dimensions.map(dimensionTable),
|
|
101
|
+
...applied.map((filter) => dimensionTable(filter.dimension)),
|
|
102
|
+
...leaves.flatMap((leaf) => tableOf(leafOf(leaf).sql))
|
|
103
|
+
]);
|
|
104
|
+
const joins = joinPlan(home, needed, edges).map(({ table, edge }) => `${edge.inner ? "JOIN" : "LEFT JOIN"} ${source(table)} ${quote(table)}\n ON ${quote(table)}.${quote(edge.to.column)} = ${quote(edge.from.table)}.${quote(edge.from.column)}`);
|
|
105
|
+
return {
|
|
106
|
+
home,
|
|
107
|
+
sql: [
|
|
108
|
+
`SELECT ${[...dimensions.map((name) => `${model.dimensions[name].sql} AS ${quote(name)}`), ...leaves.map((leaf) => `${aggregateSql(leafOf(leaf))} AS ${quote(leaf)}`)].join(",\n ")}`,
|
|
109
|
+
`FROM ${source(home)} ${quote(home)}`,
|
|
110
|
+
...joins,
|
|
111
|
+
applied.length ? `WHERE ${applied.map((filter) => filter.clause).join(" AND ")}` : "",
|
|
112
|
+
dimensions.length ? `GROUP BY ${range(dimensions.length)}` : ""
|
|
113
|
+
].filter(Boolean).join("\n"),
|
|
114
|
+
params: applied.flatMap((filter) => filter.params)
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
const aggregates = [...groups].map(([home, leaves]) => aggregate(home, leaves));
|
|
118
|
+
const parts = aggregates.map(({ home, sql, params }) => ({
|
|
119
|
+
name: `fato:${home}`,
|
|
120
|
+
sql: `SELECT COUNT(*) AS n FROM (${sql}) g`,
|
|
121
|
+
params
|
|
122
|
+
}));
|
|
123
|
+
const orderBy = dimensions.length ? `\nORDER BY ${range(dimensions.length)}` : "";
|
|
124
|
+
if (aggregates.length === 1) {
|
|
125
|
+
const [only] = aggregates;
|
|
126
|
+
if (!measures.length || measures.every((name) => isAggregate(model.measures[name]))) return {
|
|
127
|
+
sql: `${only.sql}${orderBy}`,
|
|
128
|
+
params: only.params,
|
|
129
|
+
parts
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const columns = [...dimensions, ...[...groups.values()].flat()];
|
|
133
|
+
const union = aggregates.map(({ home }, index) => {
|
|
134
|
+
const own = new Set(groups.get(home));
|
|
135
|
+
return `SELECT ${columns.map((column) => dimensions.includes(column) || own.has(column) ? quote(column) : `NULL AS ${quote(column)}`).join(", ")} FROM ${quote(`__fato${index}`)}`;
|
|
136
|
+
}).join("\n UNION ALL ");
|
|
137
|
+
const leaf = (name) => `SUM(${quote("__u")}.${quote(name)})`;
|
|
138
|
+
const select = [...dimensions.map((name) => `${quote("__u")}.${quote(name)} AS ${quote(name)}`), ...measures.map((name) => `${measureSql(model, name, leaf)} AS ${quote(name)}`)];
|
|
139
|
+
return {
|
|
140
|
+
sql: [
|
|
141
|
+
`WITH ${aggregates.map(({ sql }, index) => `${quote(`__fato${index}`)} AS (\n${sql}\n)`).join(",\n")}`,
|
|
142
|
+
`SELECT ${select.join(",\n ")}`,
|
|
143
|
+
`FROM (\n ${union}\n) ${quote("__u")}`,
|
|
144
|
+
dimensions.length ? `GROUP BY ${range(dimensions.length)}${orderBy}` : ""
|
|
145
|
+
].filter(Boolean).join("\n"),
|
|
146
|
+
params: aggregates.flatMap((item) => item.params),
|
|
147
|
+
parts
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Consulta só de dimensões (ex.: opções de um filtro): parte da tabela que alcança todas as
|
|
152
|
+
* dimensões pedidas e o maior número de filtros ativos — com "Mês = Janeiro", a lista de navios sai
|
|
153
|
+
* da fato que liga navio e calendário, e só mostra quem tem lançamento em janeiro. Sem filtro, a
|
|
154
|
+
* própria tabela da dimensão (lista tudo, mesmo sem lançamento).
|
|
155
|
+
*/
|
|
156
|
+
function dimensionHome(model, dimensions, filters, edges) {
|
|
157
|
+
const tableNames = Object.keys(model.tables);
|
|
158
|
+
const tableOf = (name) => referencedTables(model.dimensions[name].sql, tableNames)[0];
|
|
159
|
+
const own = tableOf(dimensions[0]);
|
|
160
|
+
const targets = dimensions.map(tableOf);
|
|
161
|
+
const filterTables = filters.map((filter) => tableOf(filter.dimension));
|
|
162
|
+
let best = null;
|
|
163
|
+
for (const table of [own, ...tableNames.filter((name) => name !== own)]) {
|
|
164
|
+
const { paths } = reachFrom(table, edges);
|
|
165
|
+
if (!targets.every((target) => paths.has(target))) continue;
|
|
166
|
+
const score = filterTables.filter((target) => paths.has(target)).length;
|
|
167
|
+
if (!best || score > best.score) best = {
|
|
168
|
+
table,
|
|
169
|
+
score
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return best?.table ?? own;
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/server/defineModel.ts
|
|
176
|
+
/**
|
|
177
|
+
* Modelo semântico de um dashboard: o equivalente ao modelo do Power BI (tabelas, relacionamentos e
|
|
178
|
+
* medidas DAX), só que versionado em código.
|
|
179
|
+
*
|
|
180
|
+
* - `tables`: tabelas fato (lançamentos, lado N) e dimensão (cadastros, lado 1). O SQL é a consulta
|
|
181
|
+
* de origem — tabela, view ou SELECT, sem agregar.
|
|
182
|
+
* - `relationships`: N:1 (`from: 'fato.coluna'`, `to: 'dimensao.coluna'`); o filtro vai do lado 1
|
|
183
|
+
* para o lado N. Cada medida agrega no grão da própria tabela, então juntar fatos não duplica
|
|
184
|
+
* valores (o problema de "fan-out").
|
|
185
|
+
* - `dimensions`: por onde se agrupa e filtra, citando `tabela.coluna` de uma tabela.
|
|
186
|
+
* - `measures`: agregações (`sum`, `count`, `countDistinct`, `avg`, `min`, `max`) sobre
|
|
187
|
+
* `tabela.coluna`, razões entre medidas (`ratio`) ou fórmulas (`formula`).
|
|
188
|
+
*
|
|
189
|
+
* Dialeto: MySQL/MariaDB.
|
|
190
|
+
*/
|
|
191
|
+
const sum = (sql, meta) => ({
|
|
192
|
+
...meta,
|
|
193
|
+
type: "sum",
|
|
194
|
+
sql
|
|
195
|
+
});
|
|
196
|
+
const aggregate = (type) => (sql, meta) => ({
|
|
197
|
+
...meta,
|
|
198
|
+
type,
|
|
199
|
+
sql
|
|
200
|
+
});
|
|
201
|
+
/** `COUNT(sql)`: valores não nulos. Para contar linhas, use uma coluna que nunca é nula (a chave). */
|
|
202
|
+
const count = aggregate("count");
|
|
203
|
+
/** `COUNT(DISTINCT sql)`: ex. quantas operações têm lançamento (`fVeiculo.navio_operacao_id`). */
|
|
204
|
+
const countDistinct = aggregate("countDistinct");
|
|
205
|
+
/** `AVG(sql)`: média das linhas (no estrela, das linhas da própria tabela, não de totais). */
|
|
206
|
+
const avg = aggregate("avg");
|
|
207
|
+
const min = aggregate("min");
|
|
208
|
+
const max = aggregate("max");
|
|
209
|
+
/** `numerator / denominator`, ambos nomes de medidas do mesmo modelo (nulo quando o divisor é 0). */
|
|
210
|
+
const ratio = (numerator, denominator, meta) => ({
|
|
211
|
+
...meta,
|
|
212
|
+
type: "ratio",
|
|
213
|
+
numerator,
|
|
214
|
+
denominator
|
|
215
|
+
});
|
|
216
|
+
/**
|
|
217
|
+
* Combina medidas já agregadas: `{nome}` vira o valor da medida. Ex.:
|
|
218
|
+
* `formula('COALESCE({custoMmo}, 0) + COALESCE({custoAppa}, 0)')` — o jeito de somar medidas de
|
|
219
|
+
* fatos diferentes no modelo estrela.
|
|
220
|
+
*/
|
|
221
|
+
const formula = (expression, meta) => ({
|
|
222
|
+
...meta,
|
|
223
|
+
type: "formula",
|
|
224
|
+
expression
|
|
225
|
+
});
|
|
226
|
+
/** Valida o modelo na carga do módulo: erro de definição aparece no deploy, não na primeira query. */
|
|
227
|
+
function defineModel(model) {
|
|
228
|
+
const errors = validateModel(model);
|
|
229
|
+
if (errors.length) throw new Error(`[${model.name}] ${errors.join("; ")}`);
|
|
230
|
+
return model;
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/server/modelFile.ts
|
|
234
|
+
/**
|
|
235
|
+
* Troca, dentro do arquivo do modelo, só o objeto passado ao `defineModel(...)` e o import do
|
|
236
|
+
* `@bluemarble/bi/server`. O resto do arquivo (JSDoc do modelo, outros exports) fica como está.
|
|
237
|
+
*/
|
|
238
|
+
var ModelFileError = class extends Error {};
|
|
239
|
+
/** Índice do `)` que fecha o `(` em `open`, pulando strings, templates e comentários. */
|
|
240
|
+
function findClosingParen(source, open) {
|
|
241
|
+
let depth = 0;
|
|
242
|
+
let comments = false;
|
|
243
|
+
const stack = [];
|
|
244
|
+
for (let i = open; i < source.length; i++) {
|
|
245
|
+
const char = source[i];
|
|
246
|
+
const next = source[i + 1];
|
|
247
|
+
if (stack[stack.length - 1] === "`") {
|
|
248
|
+
if (char === "\\") i++;
|
|
249
|
+
else if (char === "`") stack.pop();
|
|
250
|
+
else if (char === "$" && next === "{") {
|
|
251
|
+
stack.push("${");
|
|
252
|
+
i++;
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (char === "'" || char === "\"") {
|
|
257
|
+
for (i++; i < source.length && source[i] !== char; i++) if (source[i] === "\\") i++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (char === "/" && next === "/") {
|
|
261
|
+
comments = true;
|
|
262
|
+
while (i < source.length && source[i] !== "\n") i++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (char === "/" && next === "*") {
|
|
266
|
+
comments = true;
|
|
267
|
+
i = source.indexOf("*/", i + 2) + 1;
|
|
268
|
+
if (i === 0) break;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (char === "`") stack.push("`");
|
|
272
|
+
else if (char === "{") stack.push("{");
|
|
273
|
+
else if (char === "}") stack.pop();
|
|
274
|
+
else if (char === "(") depth++;
|
|
275
|
+
else if (char === ")" && --depth === 0) return {
|
|
276
|
+
close: i,
|
|
277
|
+
comments
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
throw new ModelFileError("Não encontrei o fim da chamada `defineModel(...)`");
|
|
281
|
+
}
|
|
282
|
+
function replaceModelInFile(source, model) {
|
|
283
|
+
const warnings = [];
|
|
284
|
+
const match = new RegExp(`export\\s+const\\s+${model.name}\\s*=\\s*defineModel(?:<[^>]*>)?\\s*\\(`).exec(source);
|
|
285
|
+
if (!match) throw new ModelFileError(`O arquivo não tem \`export const ${model.name} = defineModel(...)\``);
|
|
286
|
+
const open = match.index + match[0].length - 1;
|
|
287
|
+
const { close, comments } = findClosingParen(source, open);
|
|
288
|
+
if (comments) warnings.push("Comentários dentro do defineModel(...) serão perdidos (os de fora ficam).");
|
|
289
|
+
let content = `${source.slice(0, open + 1)}${modelObjectCode(model)}${source.slice(close)}`;
|
|
290
|
+
const importRegex = /import\s*\{([^}]*)\}\s*from\s*'@bluemarble\/bi\/server'/;
|
|
291
|
+
const current = importRegex.exec(content);
|
|
292
|
+
const helpers = /* @__PURE__ */ new Set([
|
|
293
|
+
"defineModel",
|
|
294
|
+
"formula",
|
|
295
|
+
"ratio",
|
|
296
|
+
...AGGREGATIONS
|
|
297
|
+
]);
|
|
298
|
+
const line = `import { ${[...(current?.[1] ?? "").split(",").map((name) => name.trim()).filter((name) => name && !helpers.has(name)), ...modelImports(model)].sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" })).join(", ")} } from '@bluemarble/bi/server'`;
|
|
299
|
+
content = current ? content.replace(importRegex, line) : `${line}\n${content}`;
|
|
300
|
+
const outside = source.slice(0, match.index) + source.slice(close + 1);
|
|
301
|
+
const measureHelpers = [...helpers].filter((name) => name !== "defineModel");
|
|
302
|
+
if (new RegExp(`\\b(${measureHelpers.join("|")})\\(`).test(outside)) warnings.push("O arquivo usa os helpers de medida fora do modelo; confira o import.");
|
|
303
|
+
return {
|
|
304
|
+
content,
|
|
305
|
+
warnings
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/server/normalize.ts
|
|
310
|
+
/** Decimal do Prisma, bigint e afins viram number; datas viram ISO. */
|
|
311
|
+
function normalizeValue(value) {
|
|
312
|
+
if (value === null || value === void 0) return null;
|
|
313
|
+
if (typeof value === "string" || typeof value === "number") return value;
|
|
314
|
+
if (typeof value === "bigint") return Number(value);
|
|
315
|
+
if (value instanceof Date) return value.toISOString();
|
|
316
|
+
const number = Number(value);
|
|
317
|
+
return Number.isNaN(number) ? String(value) : number;
|
|
318
|
+
}
|
|
319
|
+
function normalizeRow(row) {
|
|
320
|
+
const normalized = {};
|
|
321
|
+
for (const [key, value] of Object.entries(row)) normalized[key] = normalizeValue(value);
|
|
322
|
+
return normalized;
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/server/devtools.ts
|
|
326
|
+
const fs = () => import("node:fs/promises");
|
|
327
|
+
/** Mensagem do banco sem o embrulho do Prisma (`Raw query failed. Code: ... Message: ...`). */
|
|
328
|
+
function dbMessage(error) {
|
|
329
|
+
const err = error;
|
|
330
|
+
const message = err?.meta?.message ?? err?.message ?? String(error);
|
|
331
|
+
return message.match(/Message: `?([\s\S]*?)`?$/)?.[1] ?? message;
|
|
332
|
+
}
|
|
333
|
+
const timed = async (load) => {
|
|
334
|
+
const startedAt = performance.now();
|
|
335
|
+
return {
|
|
336
|
+
result: await load(),
|
|
337
|
+
durationMs: Math.round(performance.now() - startedAt)
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
const SLUG = /^[A-Za-z0-9_-]{1,80}$/;
|
|
341
|
+
async function handleDevtoolsRequest(context) {
|
|
342
|
+
const { request } = context;
|
|
343
|
+
if (request.describe) return describe(context);
|
|
344
|
+
if (request.schema) return {
|
|
345
|
+
rows: [],
|
|
346
|
+
devtools: { schema: await loadSchema(context.execute) }
|
|
347
|
+
};
|
|
348
|
+
if (request.columns) {
|
|
349
|
+
const { columns, errors } = await describeColumns(context);
|
|
350
|
+
return {
|
|
351
|
+
rows: [],
|
|
352
|
+
devtools: {
|
|
353
|
+
columns,
|
|
354
|
+
columnErrors: errors
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (request.validate) return {
|
|
359
|
+
rows: [],
|
|
360
|
+
devtools: { validation: await validateSql(context) }
|
|
361
|
+
};
|
|
362
|
+
if (request.save) return {
|
|
363
|
+
rows: [],
|
|
364
|
+
devtools: { save: await saveModel(context) }
|
|
365
|
+
};
|
|
366
|
+
if (request.saveLayout) return {
|
|
367
|
+
rows: [],
|
|
368
|
+
devtools: { save: await saveLayout(context) }
|
|
369
|
+
};
|
|
370
|
+
if (request.snapshots) return {
|
|
371
|
+
rows: [],
|
|
372
|
+
devtools: { snapshots: await listSnapshots(context) }
|
|
373
|
+
};
|
|
374
|
+
if (request.snapshot) return {
|
|
375
|
+
rows: [],
|
|
376
|
+
devtools: { snapshot: await saveSnapshot(context) }
|
|
377
|
+
};
|
|
378
|
+
if (request.explain) {
|
|
379
|
+
const { sql, params } = compileQuery(context.model, context.query);
|
|
380
|
+
return {
|
|
381
|
+
rows: [],
|
|
382
|
+
devtools: { explain: (await context.execute(`EXPLAIN ${sql}`, params)).map(context.normalize) }
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (request.profile) return {
|
|
386
|
+
rows: [],
|
|
387
|
+
devtools: { profile: await profile(context) }
|
|
388
|
+
};
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
function describe({ registered, options }) {
|
|
392
|
+
return {
|
|
393
|
+
rows: [],
|
|
394
|
+
meta: modelMeta(registered),
|
|
395
|
+
devtools: {
|
|
396
|
+
model: registered,
|
|
397
|
+
capabilities: {
|
|
398
|
+
saveModel: !!options.modelsDir,
|
|
399
|
+
snapshots: !!options.snapshotsDir,
|
|
400
|
+
saveLayout: !!options.layoutsDir
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
async function profile({ model, query, execute }) {
|
|
406
|
+
const { sql, params, parts } = compileQuery(model, query);
|
|
407
|
+
const steps = [];
|
|
408
|
+
for (const part of parts) {
|
|
409
|
+
const { result, durationMs } = await timed(() => execute(part.sql, part.params));
|
|
410
|
+
steps.push({
|
|
411
|
+
name: part.name,
|
|
412
|
+
durationMs,
|
|
413
|
+
rows: Number(result[0]?.n ?? 0)
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const total = await timed(() => execute(sql, params));
|
|
417
|
+
steps.push({
|
|
418
|
+
name: "consulta",
|
|
419
|
+
durationMs: total.durationMs,
|
|
420
|
+
rows: total.result.length
|
|
421
|
+
});
|
|
422
|
+
return steps;
|
|
423
|
+
}
|
|
424
|
+
async function loadSchema(execute) {
|
|
425
|
+
const rows = await execute(`SELECT c.TABLE_NAME AS tableName, t.TABLE_TYPE AS tableType,
|
|
426
|
+
c.COLUMN_NAME AS columnName, c.DATA_TYPE AS dataType
|
|
427
|
+
FROM information_schema.COLUMNS c
|
|
428
|
+
JOIN information_schema.TABLES t
|
|
429
|
+
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME
|
|
430
|
+
WHERE c.TABLE_SCHEMA = DATABASE()
|
|
431
|
+
ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION`, []);
|
|
432
|
+
const tables = /* @__PURE__ */ new Map();
|
|
433
|
+
for (const row of rows) {
|
|
434
|
+
const name = String(row.tableName);
|
|
435
|
+
let table = tables.get(name);
|
|
436
|
+
if (!table) {
|
|
437
|
+
table = {
|
|
438
|
+
name,
|
|
439
|
+
type: String(row.tableType).includes("VIEW") ? "view" : "table",
|
|
440
|
+
columns: []
|
|
441
|
+
};
|
|
442
|
+
tables.set(name, table);
|
|
443
|
+
}
|
|
444
|
+
table.columns.push({
|
|
445
|
+
name: String(row.columnName),
|
|
446
|
+
type: String(row.dataType)
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
return [...tables.values()];
|
|
450
|
+
}
|
|
451
|
+
/** Poucas chaves bastam para o banco acusar erro de SQL e para descobrir as colunas. */
|
|
452
|
+
const SAMPLE_KEYS = 20;
|
|
453
|
+
const emptyColumns = () => ({
|
|
454
|
+
tables: {},
|
|
455
|
+
numeric: []
|
|
456
|
+
});
|
|
457
|
+
/** Colunas numéricas da primeira linha (Decimal do Prisma e bigint contam). */
|
|
458
|
+
function numericColumns(node, row) {
|
|
459
|
+
return Object.entries(row ?? {}).filter(([, value]) => typeof normalizeValue(value) === "number").map(([column]) => `${node}.${column}`);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Colunas de cada tabela do modelo (uma linha de cada). `errors` traz o que o banco recusou, com as
|
|
463
|
+
* chaves da validação (`table:<nome>`).
|
|
464
|
+
*/
|
|
465
|
+
async function loadColumns(model, execute) {
|
|
466
|
+
const columns = emptyColumns();
|
|
467
|
+
const errors = {};
|
|
468
|
+
const attempt = async (key, sql) => {
|
|
469
|
+
try {
|
|
470
|
+
return await execute(sql, []);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
errors[key] = dbMessage(error);
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
for (const [name, table] of Object.entries(model.tables)) {
|
|
477
|
+
const rows = await attempt(`table:${name}`, `SELECT * FROM (${table.sql}) t LIMIT 1`);
|
|
478
|
+
if (!rows) continue;
|
|
479
|
+
columns.tables[name] = Object.keys(rows[0] ?? {});
|
|
480
|
+
columns.numeric.push(...numericColumns(name, rows[0]));
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
columns,
|
|
484
|
+
errors
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
async function describeColumns({ model, execute }) {
|
|
488
|
+
const errors = validateModel(model);
|
|
489
|
+
if (errors.length) throw new DashboardQueryError(`Modelo inválido: ${errors.join("; ")}`);
|
|
490
|
+
return loadColumns(model, execute);
|
|
491
|
+
}
|
|
492
|
+
async function validateSql({ model, execute }) {
|
|
493
|
+
const validation = {
|
|
494
|
+
errors: {},
|
|
495
|
+
warnings: {},
|
|
496
|
+
columns: emptyColumns()
|
|
497
|
+
};
|
|
498
|
+
const modelErrors = validateModel(model);
|
|
499
|
+
if (modelErrors.length) {
|
|
500
|
+
validation.errors.model = modelErrors.join("; ");
|
|
501
|
+
return validation;
|
|
502
|
+
}
|
|
503
|
+
const attempt = async (key, sql) => {
|
|
504
|
+
try {
|
|
505
|
+
return await execute(sql, []);
|
|
506
|
+
} catch (error) {
|
|
507
|
+
validation.errors[key] = dbMessage(error);
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
const loaded = await loadColumns(model, execute);
|
|
512
|
+
validation.columns = loaded.columns;
|
|
513
|
+
Object.assign(validation.errors, loaded.errors);
|
|
514
|
+
const tableNames = Object.keys(model.tables);
|
|
515
|
+
for (const [index, relationship] of (model.relationships ?? []).entries()) {
|
|
516
|
+
const key = `relationship:${index}`;
|
|
517
|
+
const from = parseColumnRef(relationship.from);
|
|
518
|
+
const to = parseColumnRef(relationship.to);
|
|
519
|
+
if (!from || !to) continue;
|
|
520
|
+
const missing = [from, to].find((ref) => validation.columns.tables[ref.table]?.length && !validation.columns.tables[ref.table].includes(ref.column));
|
|
521
|
+
if (missing) {
|
|
522
|
+
validation.errors[key] = `a tabela "${missing.table}" não tem a coluna "${missing.column}"`;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const toSql = model.tables[to.table]?.sql;
|
|
526
|
+
if (!toSql || validation.errors[`table:${to.table}`]) continue;
|
|
527
|
+
const repeated = await attempt(key, `SELECT \`${to.column}\` AS v FROM (${toSql}) t WHERE \`${to.column}\` IS NOT NULL GROUP BY 1 HAVING COUNT(*) > 1 LIMIT 1`);
|
|
528
|
+
if (repeated?.length) validation.errors[key] = `o lado 1 (${relationship.to}) tem valor repetido: ${String(normalizeValue(repeated[0].v))}`;
|
|
529
|
+
}
|
|
530
|
+
for (const [name, dimension] of Object.entries(model.dimensions)) {
|
|
531
|
+
const table = referencedTables(dimension.sql, tableNames)[0];
|
|
532
|
+
if (validation.errors[`table:${table}`]) continue;
|
|
533
|
+
const sql = `SELECT ${dimension.sql} AS v FROM (SELECT * FROM (${model.tables[table]?.sql}) t LIMIT ${SAMPLE_KEYS}) \`${table}\` WHERE ${dimension.sql} IS NOT NULL LIMIT 1`;
|
|
534
|
+
const value = (await attempt(`dimension:${name}`, sql))?.[0]?.v;
|
|
535
|
+
if (value === void 0 || value === null) continue;
|
|
536
|
+
const isNumber = typeof value === "number" || typeof value === "bigint";
|
|
537
|
+
if (dimension.type === "number" && !isNumber && Number.isNaN(Number(value))) validation.warnings[`dimension:${name}`] = `tipo number, mas o banco devolveu "${value}"`;
|
|
538
|
+
if (dimension.type === "string" && isNumber) validation.warnings[`dimension:${name}`] = "tipo string, mas o banco devolveu número (filtros vão chegar como texto)";
|
|
539
|
+
}
|
|
540
|
+
for (const [name, measure] of Object.entries(model.measures)) {
|
|
541
|
+
if (!isAggregate(measure)) continue;
|
|
542
|
+
if (referencedTables(measure.sql, tableNames).some((table) => validation.errors[`table:${table}`])) continue;
|
|
543
|
+
const { sql, params } = compileQuery(model, {
|
|
544
|
+
model: model.name,
|
|
545
|
+
measures: [name]
|
|
546
|
+
}, { sampleLimit: SAMPLE_KEYS });
|
|
547
|
+
try {
|
|
548
|
+
await execute(sql, params);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
validation.errors[`measure:${name}`] = dbMessage(error);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return validation;
|
|
554
|
+
}
|
|
555
|
+
async function saveModel({ registered, model, request, options }) {
|
|
556
|
+
if (!options.modelsDir) throw new DashboardQueryError("O endpoint não configurou `modelsDir`");
|
|
557
|
+
if (!request.model) throw new DashboardQueryError("Nada para salvar: não há rascunho");
|
|
558
|
+
if (model.name !== registered.name) throw new DashboardQueryError("O rascunho não pode trocar o nome do modelo");
|
|
559
|
+
const path = `${options.modelsDir.replace(/\/+$/, "")}/${registered.name}.ts`;
|
|
560
|
+
const { readFile, writeFile } = await fs();
|
|
561
|
+
let previous;
|
|
562
|
+
try {
|
|
563
|
+
previous = await readFile(path, "utf8");
|
|
564
|
+
} catch {
|
|
565
|
+
throw new DashboardQueryError(`Arquivo do modelo não encontrado: ${path}`);
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const replaced = replaceModelInFile(previous, model);
|
|
569
|
+
const { warnings } = replaced;
|
|
570
|
+
const content = options.format ? await options.format(replaced.content, path) : replaced.content;
|
|
571
|
+
const written = request.save === "write" && content !== previous;
|
|
572
|
+
if (written) await writeFile(path, content, "utf8");
|
|
573
|
+
return {
|
|
574
|
+
path,
|
|
575
|
+
previous,
|
|
576
|
+
content,
|
|
577
|
+
written,
|
|
578
|
+
warnings
|
|
579
|
+
};
|
|
580
|
+
} catch (error) {
|
|
581
|
+
if (error instanceof ModelFileError) throw new DashboardQueryError(error.message);
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async function saveLayout({ model, request, options }) {
|
|
586
|
+
if (!options.layoutsDir) throw new DashboardQueryError("O endpoint não configurou `layoutsDir`");
|
|
587
|
+
const { layout, mode } = request.saveLayout ?? {};
|
|
588
|
+
const errors = layout ? validateLayout(layout) : ["Nada para salvar: não há layout"];
|
|
589
|
+
if (errors.length) throw new DashboardQueryError(`Layout inválido: ${errors.join("; ")}`);
|
|
590
|
+
const valid = layout;
|
|
591
|
+
const path = `${options.layoutsDir.replace(/\/+$/, "")}/${valid.id}.json`;
|
|
592
|
+
const { readFile, writeFile } = await fs();
|
|
593
|
+
let previous;
|
|
594
|
+
try {
|
|
595
|
+
previous = await readFile(path, "utf8");
|
|
596
|
+
} catch {
|
|
597
|
+
throw new DashboardQueryError(`Arquivo do layout não encontrado: ${path}`);
|
|
598
|
+
}
|
|
599
|
+
const raw = layoutFileContent(valid);
|
|
600
|
+
const content = options.format ? await options.format(raw, path) : raw;
|
|
601
|
+
const written = mode === "write" && content !== previous;
|
|
602
|
+
if (written) await writeFile(path, content, "utf8");
|
|
603
|
+
return {
|
|
604
|
+
path,
|
|
605
|
+
previous,
|
|
606
|
+
content,
|
|
607
|
+
written,
|
|
608
|
+
warnings: layoutModelWarnings(valid, model)
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
const snapshotDir = (options, model) => {
|
|
612
|
+
if (!options.snapshotsDir) throw new DashboardQueryError("O endpoint não configurou `snapshotsDir`");
|
|
613
|
+
return `${options.snapshotsDir.replace(/\/+$/, "")}/${model}`;
|
|
614
|
+
};
|
|
615
|
+
async function saveSnapshot({ registered, query, request, options, runRows }) {
|
|
616
|
+
const { name, overwrite } = request.snapshot ?? { name: "" };
|
|
617
|
+
if (!SLUG.test(name)) throw new DashboardQueryError("Nome do snapshot: letras, números, \"-\" e \"_\" (até 80)");
|
|
618
|
+
const dir = snapshotDir(options, registered.name);
|
|
619
|
+
const path = `${dir}/${name}.json`;
|
|
620
|
+
const { mkdir, readFile, writeFile } = await fs();
|
|
621
|
+
if (!overwrite) {
|
|
622
|
+
if (await readFile(path, "utf8").then(() => true, () => false)) throw new DashboardQueryError(`Já existe um snapshot "${name}"`);
|
|
623
|
+
}
|
|
624
|
+
const snapshot = {
|
|
625
|
+
name,
|
|
626
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
627
|
+
query: {
|
|
628
|
+
model: registered.name,
|
|
629
|
+
dimensions: query.dimensions ?? [],
|
|
630
|
+
measures: query.measures ?? [],
|
|
631
|
+
filters: query.filters ?? {}
|
|
632
|
+
},
|
|
633
|
+
rows: await runRows(registered)
|
|
634
|
+
};
|
|
635
|
+
await mkdir(dir, { recursive: true });
|
|
636
|
+
await writeFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
|
|
637
|
+
return { path };
|
|
638
|
+
}
|
|
639
|
+
async function listSnapshots({ registered, options }) {
|
|
640
|
+
const dir = snapshotDir(options, registered.name);
|
|
641
|
+
const { readdir, readFile } = await fs();
|
|
642
|
+
const files = await readdir(dir).catch(() => []);
|
|
643
|
+
return await Promise.all(files.filter((file) => file.endsWith(".json")).sort().map(async (file) => JSON.parse(await readFile(`${dir}/${file}`, "utf8"))));
|
|
644
|
+
}
|
|
645
|
+
//#endregion
|
|
646
|
+
//#region src/server/runQuery.ts
|
|
647
|
+
const MAX_CACHE_ENTRIES = 500;
|
|
648
|
+
const cache = /* @__PURE__ */ new Map();
|
|
649
|
+
function fromCache(key, ttl, load) {
|
|
650
|
+
const now = Date.now();
|
|
651
|
+
const hit = cache.get(key);
|
|
652
|
+
if (hit && hit.expiresAt > now) return {
|
|
653
|
+
execution: hit.execution,
|
|
654
|
+
cached: true
|
|
655
|
+
};
|
|
656
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
657
|
+
for (const [k, entry] of cache) if (entry.expiresAt <= now) cache.delete(k);
|
|
658
|
+
if (cache.size >= MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value);
|
|
659
|
+
}
|
|
660
|
+
const execution = load();
|
|
661
|
+
cache.set(key, {
|
|
662
|
+
expiresAt: now + ttl,
|
|
663
|
+
execution
|
|
664
|
+
});
|
|
665
|
+
execution.catch(() => cache.delete(key));
|
|
666
|
+
return {
|
|
667
|
+
execution,
|
|
668
|
+
cached: false
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
async function runQuery(models, query, execute, options = {}) {
|
|
672
|
+
const { debug, ...privileged } = query?.devtools ?? {};
|
|
673
|
+
const devtools = options.devtools ? query?.devtools : void 0;
|
|
674
|
+
if (!options.devtools && Object.values(privileged).some(Boolean)) throw new DashboardQueryError("Devtools desabilitado neste ambiente");
|
|
675
|
+
const registered = models[query?.model];
|
|
676
|
+
if (!registered) throw new DashboardQueryError(`Modelo desconhecido: "${query?.model}"`);
|
|
677
|
+
const draft = devtools?.model;
|
|
678
|
+
if (draft) {
|
|
679
|
+
const errors = validateModel(draft);
|
|
680
|
+
if (errors.length) throw new DashboardQueryError(`Rascunho inválido: ${errors.join("; ")}`);
|
|
681
|
+
}
|
|
682
|
+
const model = draft ?? registered;
|
|
683
|
+
if (query.meta) return {
|
|
684
|
+
rows: [],
|
|
685
|
+
meta: modelMeta(model)
|
|
686
|
+
};
|
|
687
|
+
const ttl = options.cacheTtlMs ?? 0;
|
|
688
|
+
const run = async (target) => {
|
|
689
|
+
const { sql, params } = compileQuery(target, query);
|
|
690
|
+
const load = async () => {
|
|
691
|
+
const startedAt = performance.now();
|
|
692
|
+
const rows = await execute(sql, params);
|
|
693
|
+
return {
|
|
694
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
695
|
+
rows: rows.map(normalizeRow)
|
|
696
|
+
};
|
|
697
|
+
};
|
|
698
|
+
const { execution, cached } = ttl > 0 ? fromCache(JSON.stringify([sql, params]), ttl, load) : {
|
|
699
|
+
execution: load(),
|
|
700
|
+
cached: false
|
|
701
|
+
};
|
|
702
|
+
return {
|
|
703
|
+
sql,
|
|
704
|
+
params,
|
|
705
|
+
cached,
|
|
706
|
+
...await execution
|
|
707
|
+
};
|
|
708
|
+
};
|
|
709
|
+
if (devtools) {
|
|
710
|
+
if (devtools.clearCache) cache.clear();
|
|
711
|
+
try {
|
|
712
|
+
const handled = await handleDevtoolsRequest({
|
|
713
|
+
registered,
|
|
714
|
+
model,
|
|
715
|
+
query,
|
|
716
|
+
request: devtools,
|
|
717
|
+
execute: (sql, params) => execute(sql, params),
|
|
718
|
+
normalize: normalizeRow,
|
|
719
|
+
options: typeof options.devtools === "object" ? options.devtools : {},
|
|
720
|
+
runRows: async (target) => (await run(target)).rows
|
|
721
|
+
});
|
|
722
|
+
if (handled) return handled;
|
|
723
|
+
} catch (error) {
|
|
724
|
+
if (error instanceof DashboardQueryError) throw error;
|
|
725
|
+
throw new DashboardQueryError(dbMessage(error));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const { rows, sql, params, durationMs, cached } = await run(model);
|
|
729
|
+
if (!devtools?.debug) return { rows };
|
|
730
|
+
return {
|
|
731
|
+
rows,
|
|
732
|
+
devtools: { debug: {
|
|
733
|
+
sql,
|
|
734
|
+
params,
|
|
735
|
+
durationMs,
|
|
736
|
+
cached,
|
|
737
|
+
draft: !!draft
|
|
738
|
+
} }
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
//#endregion
|
|
742
|
+
export { DashboardQueryError, avg, canonicalJson, compileQuery, count, countDistinct, defineModel, describeDiffs, diffRows, formula, layoutModelWarnings, max, min, modelMeta, ratio, runQuery, sum, toModelCode, validateLayout, validateModel };
|
|
743
|
+
|
|
744
|
+
//# sourceMappingURL=index.js.map
|