@bluemarble/bi 0.5.0 → 0.6.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/dist/{DashboardDevtools-6v5HDe-A.js → DashboardDevtools-SCewfQGR.js} +508 -28
- package/dist/DashboardDevtools-SCewfQGR.js.map +1 -0
- package/dist/{ModelDictionary-D3JzCd6x.js → ModelDictionary-C2tzlOY8.js} +4 -3
- package/dist/{ModelDictionary-D3JzCd6x.js.map → ModelDictionary-C2tzlOY8.js.map} +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +164 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/index.d.ts +26 -5
- package/dist/client/index.js +72 -26
- package/dist/client/index.js.map +1 -1
- package/dist/codegen-DBe4-XW_.js +354 -0
- package/dist/codegen-DBe4-XW_.js.map +1 -0
- package/dist/{types-FdrY0FOZ.d.ts → layout-B7eYgNyR.d.ts} +226 -152
- package/dist/{layout-QuOYFmp1.js → layout-BS51SQcJ.js} +219 -346
- package/dist/layout-BS51SQcJ.js.map +1 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +2 -1
- package/dist/server/index.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +4 -1
- package/dist/DashboardDevtools-6v5HDe-A.js.map +0 -1
- package/dist/layout-QuOYFmp1.js.map +0 -1
|
@@ -1,350 +1,146 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
table: match[1],
|
|
8
|
-
column: match[2]
|
|
9
|
-
} : null;
|
|
10
|
-
}
|
|
11
|
-
/** Relacionamentos válidos do modelo, como arestas N -> 1. */
|
|
12
|
-
function modelEdges(model) {
|
|
13
|
-
return (model.relationships ?? []).flatMap((relationship, index) => {
|
|
14
|
-
const from = parseColumnRef(relationship?.from);
|
|
15
|
-
const to = parseColumnRef(relationship?.to);
|
|
16
|
-
return from && to ? [{
|
|
17
|
-
from,
|
|
18
|
-
to,
|
|
19
|
-
index,
|
|
20
|
-
inner: relationship.inner === true
|
|
21
|
-
}] : [];
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
-
/**
|
|
26
|
-
* Tabelas citadas no SQL como `tabela.coluna`. O que está entre aspas simples é ignorado, para
|
|
27
|
-
* `'a.b'` num literal não contar como referência.
|
|
28
|
-
*/
|
|
29
|
-
function referencedTables(sql, tables) {
|
|
30
|
-
const code = sql.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
|
31
|
-
return tables.filter((name) => new RegExp(`(?<![\\w.\`])${escapeRegex(name)}\\.`).test(code));
|
|
32
|
-
}
|
|
33
|
-
/** Busca em largura a partir de `start`, seguindo os relacionamentos no sentido N -> 1. */
|
|
34
|
-
function reachFrom(start, edges) {
|
|
35
|
-
const paths = /* @__PURE__ */ new Map([[start, []]]);
|
|
36
|
-
let frontier = [start];
|
|
37
|
-
while (frontier.length) {
|
|
38
|
-
const next = [];
|
|
39
|
-
for (const table of frontier) for (const edge of edges) {
|
|
40
|
-
if (edge.from.table !== table || paths.has(edge.to.table)) continue;
|
|
41
|
-
paths.set(edge.to.table, [...paths.get(table) ?? [], {
|
|
42
|
-
table: edge.to.table,
|
|
43
|
-
edge
|
|
44
|
-
}]);
|
|
45
|
-
next.push(edge.to.table);
|
|
46
|
-
}
|
|
47
|
-
frontier = next;
|
|
48
|
-
}
|
|
49
|
-
const incoming = /* @__PURE__ */ new Map();
|
|
50
|
-
for (const edge of edges) if (paths.has(edge.from.table)) incoming.set(edge.to.table, (incoming.get(edge.to.table) ?? 0) + 1);
|
|
51
|
-
return {
|
|
52
|
-
paths,
|
|
53
|
-
ambiguous: [...incoming].filter(([table, count]) => table !== start && count > 1).map(([table]) => table)
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Tabelas que precisam entrar na consulta, na ordem do JOIN, para que a partir de `home` se chegue
|
|
58
|
-
* a todas as `targets`. Como cada tabela é alcançada por um caminho só (o modelo valida ambiguidade),
|
|
59
|
-
* a união dos caminhos é uma árvore: cada tabela entra uma vez, ligada pela aresta de quem veio antes.
|
|
60
|
-
*/
|
|
61
|
-
function joinPlan(home, targets, edges) {
|
|
62
|
-
const { paths } = reachFrom(home, edges);
|
|
63
|
-
const plan = [];
|
|
64
|
-
const added = /* @__PURE__ */ new Set([home]);
|
|
65
|
-
const include = (target) => {
|
|
66
|
-
for (const step of paths.get(target) ?? []) {
|
|
67
|
-
if (added.has(step.table)) continue;
|
|
68
|
-
added.add(step.table);
|
|
69
|
-
plan.push(step);
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
for (const target of targets) include(target);
|
|
73
|
-
for (let grew = true; grew;) {
|
|
74
|
-
grew = false;
|
|
75
|
-
for (const edge of edges) if (edge.inner && added.has(edge.from.table) && !added.has(edge.to.table)) {
|
|
76
|
-
include(edge.to.table);
|
|
77
|
-
grew = true;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const depth = (table) => paths.get(table)?.length ?? 0;
|
|
81
|
-
return plan.sort((a, b) => depth(a.table) - depth(b.table));
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Tabela de origem de uma medida `sum`: entre as tabelas que o SQL cita, a que alcança todas as
|
|
85
|
-
* outras (ex.: `fPesagem.peso * dProduto.fator` agrega em `fPesagem`). Nulo se nenhuma alcança.
|
|
86
|
-
*/
|
|
87
|
-
function measureHome(tables, edges) {
|
|
88
|
-
if (!tables.length) return null;
|
|
89
|
-
return tables.find((candidate) => {
|
|
90
|
-
const { paths } = reachFrom(candidate, edges);
|
|
91
|
-
return tables.every((table) => paths.has(table));
|
|
92
|
-
}) ?? null;
|
|
93
|
-
}
|
|
94
|
-
//#endregion
|
|
95
|
-
//#region src/model.ts
|
|
96
|
-
const VALUE_FORMATS = [
|
|
97
|
-
"number",
|
|
98
|
-
"integer",
|
|
99
|
-
"currency",
|
|
100
|
-
"percent"
|
|
1
|
+
import { l as isAggregate, s as VALUE_FORMATS } from "./codegen-DBe4-XW_.js";
|
|
2
|
+
//#region src/formatRules.ts
|
|
3
|
+
const COLUMN_ALIGNS = [
|
|
4
|
+
"left",
|
|
5
|
+
"center",
|
|
6
|
+
"right"
|
|
101
7
|
];
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"
|
|
8
|
+
const COLUMN_VERTICAL_ALIGNS = [
|
|
9
|
+
"top",
|
|
10
|
+
"middle",
|
|
11
|
+
"bottom"
|
|
12
|
+
];
|
|
13
|
+
const RULE_OPERATORS = [
|
|
14
|
+
">",
|
|
15
|
+
">=",
|
|
16
|
+
"<",
|
|
17
|
+
"<=",
|
|
18
|
+
"=",
|
|
19
|
+
"!=",
|
|
20
|
+
"between",
|
|
21
|
+
"contains",
|
|
22
|
+
"empty",
|
|
23
|
+
"notEmpty"
|
|
113
24
|
];
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
25
|
+
const RULE_OPERATOR_LABEL = {
|
|
26
|
+
">": "maior que",
|
|
27
|
+
">=": "maior ou igual a",
|
|
28
|
+
"<": "menor que",
|
|
29
|
+
"<=": "menor ou igual a",
|
|
30
|
+
"=": "igual a",
|
|
31
|
+
"!=": "diferente de",
|
|
32
|
+
between: "entre",
|
|
33
|
+
contains: "contém",
|
|
34
|
+
empty: "vazio",
|
|
35
|
+
notEmpty: "não vazio"
|
|
121
36
|
};
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}[measure.type]}(${measure.sql})`;
|
|
133
|
-
}
|
|
134
|
-
/** Nomes citados como `{nome}` numa fórmula. */
|
|
135
|
-
const formulaRefs = (expression) => [...new Set([...expression.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((match) => match[1]))];
|
|
136
|
-
/** Medidas que uma medida usa diretamente (razão e fórmula). */
|
|
137
|
-
function measureDeps(measure) {
|
|
138
|
-
if (measure.type === "ratio") return [measure.numerator, measure.denominator];
|
|
139
|
-
if (measure.type === "formula") return formulaRefs(measure.expression);
|
|
140
|
-
return [];
|
|
141
|
-
}
|
|
142
|
-
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
37
|
+
/** Operadores que comparam com número (`value`, e `to` no `between`). */
|
|
38
|
+
const NUMERIC_OPERATORS = [
|
|
39
|
+
">",
|
|
40
|
+
">=",
|
|
41
|
+
"<",
|
|
42
|
+
"<=",
|
|
43
|
+
"between"
|
|
44
|
+
];
|
|
45
|
+
/** Operadores sem valor. */
|
|
46
|
+
const UNARY_OPERATORS = ["empty", "notEmpty"];
|
|
143
47
|
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
numerator: measure.numerator,
|
|
172
|
-
denominator: measure.denominator
|
|
173
|
-
},
|
|
174
|
-
...measure.type === "formula" && { refs: formulaRefs(measure.expression) }
|
|
175
|
-
}]))
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Lista os problemas do modelo (vazio = válido). Confere também a forma do objeto, porque o rascunho
|
|
180
|
-
* do devtools chega como JSON da request.
|
|
181
|
-
*/
|
|
182
|
-
function validateModel(model) {
|
|
183
|
-
if (!isRecord$1(model)) return ["modelo inválido"];
|
|
184
|
-
if (!isRecord$1(model.dimensions) || !isRecord$1(model.measures)) return ["`dimensions` e `measures` são obrigatórios"];
|
|
185
|
-
if ("base" in model || "joins" in model) return ["o formato `base` + `joins` não existe mais: use `tables` + `relationships`"];
|
|
186
|
-
if (!isRecord$1(model.tables)) return ["`tables` é obrigatório"];
|
|
187
|
-
if (model.relationships !== void 0 && !Array.isArray(model.relationships)) return ["`relationships` precisa ser uma lista"];
|
|
188
|
-
if (model.diagram !== void 0 && !diagramValid(model.diagram)) return ["`diagram` precisa ser { nome: { x, y, width?, height? } } com números (tamanho > 0)"];
|
|
189
|
-
const errors = [];
|
|
190
|
-
const { tables } = model;
|
|
191
|
-
const names = [...Object.keys(model.dimensions), ...Object.keys(model.measures)];
|
|
192
|
-
for (const name of [...names, ...Object.keys(tables)]) if (!IDENTIFIER.test(name)) errors.push(`nome inválido: "${name}"`);
|
|
193
|
-
else if (name.startsWith("__")) errors.push(`nome reservado: "${name}" (começa com "__")`);
|
|
194
|
-
const duplicated = names.find((name, index) => names.indexOf(name) !== index);
|
|
195
|
-
if (duplicated) errors.push(`nome repetido: "${duplicated}"`);
|
|
196
|
-
for (const [name, table] of Object.entries(tables)) {
|
|
197
|
-
if (!isRecord$1(table) || !isText(table.sql)) {
|
|
198
|
-
errors.push(`tabela "${name}" sem SQL`);
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
if (table.key !== void 0 && (typeof table.key !== "string" || !IDENTIFIER.test(table.key))) errors.push(`tabela "${name}": key inválida`);
|
|
202
|
-
errors.push(...metaErrors(`tabela "${name}"`, table));
|
|
203
|
-
}
|
|
204
|
-
const tableNames = Object.keys(tables);
|
|
205
|
-
errors.push(...relationshipErrors(model, tableNames));
|
|
206
|
-
for (const [name, dimension] of Object.entries(model.dimensions)) if (!isRecord$1(dimension) || !isText(dimension.sql)) errors.push(`dimensão "${name}" sem SQL`);
|
|
207
|
-
else if (dimension.type !== "number" && dimension.type !== "string") errors.push(`dimensão "${name}" com tipo inválido`);
|
|
208
|
-
else {
|
|
209
|
-
errors.push(...metaErrors(`dimensão "${name}"`, dimension));
|
|
210
|
-
if (referencedTables(dimension.sql, tableNames).length !== 1) errors.push(`dimensão "${name}" precisa usar colunas de uma tabela só (\`tabela.coluna\`)`);
|
|
211
|
-
}
|
|
212
|
-
const edges = modelEdges(model);
|
|
213
|
-
for (const [name, measure] of Object.entries(model.measures)) {
|
|
214
|
-
if (!isRecord$1(measure)) errors.push(`medida "${name}" inválida`);
|
|
215
|
-
else if (isAggregate(measure)) {
|
|
216
|
-
if (!isText(measure.sql)) errors.push(`medida "${name}" sem SQL`);
|
|
217
|
-
else {
|
|
218
|
-
const used = referencedTables(measure.sql, tableNames);
|
|
219
|
-
if (!used.length) errors.push(`medida "${name}" precisa usar colunas de uma tabela (\`tabela.coluna\`)`);
|
|
220
|
-
else if (!measureHome(used, edges)) errors.push(`medida "${name}" usa ${used.join(", ")}, mas nenhuma delas chega às outras pelos relacionamentos`);
|
|
221
|
-
}
|
|
222
|
-
} else if (measure.type === "ratio" || measure.type === "formula") {
|
|
223
|
-
if (measure.type === "formula" && !isText(measure.expression)) errors.push(`medida "${name}" sem expressão`);
|
|
224
|
-
else if (measure.type === "formula" && !formulaRefs(measure.expression).length) errors.push(`medida "${name}": a fórmula precisa citar medidas como {nome}`);
|
|
225
|
-
for (const ref of measureDeps(measure)) if (typeof ref !== "string" || !model.measures[ref]) errors.push(`medida "${name}" referencia "${ref}", que não existe`);
|
|
226
|
-
} else errors.push(`medida "${name}" com tipo inválido`);
|
|
227
|
-
if (isRecord$1(measure)) errors.push(...metaErrors(`medida "${name}"`, measure));
|
|
228
|
-
}
|
|
229
|
-
if (errors.length) return errors;
|
|
230
|
-
const visit = (name, stack) => {
|
|
231
|
-
if (stack.includes(name)) return [...stack, name].join(" -> ");
|
|
232
|
-
for (const dep of measureDeps(model.measures[name])) {
|
|
233
|
-
const cycle = visit(dep, [...stack, name]);
|
|
234
|
-
if (cycle) return cycle;
|
|
235
|
-
}
|
|
236
|
-
return null;
|
|
237
|
-
};
|
|
238
|
-
for (const name of Object.keys(model.measures)) {
|
|
239
|
-
const cycle = visit(name, []);
|
|
240
|
-
if (cycle) {
|
|
241
|
-
errors.push(`medida circular: ${cycle}`);
|
|
242
|
-
break;
|
|
243
|
-
}
|
|
48
|
+
const isColor = (value) => typeof value === "string" && /^#[0-9a-fA-F]{3,8}$/.test(value);
|
|
49
|
+
const RULE_KEYS = [
|
|
50
|
+
"field",
|
|
51
|
+
"operator",
|
|
52
|
+
"value",
|
|
53
|
+
"to",
|
|
54
|
+
"target",
|
|
55
|
+
"column",
|
|
56
|
+
"color",
|
|
57
|
+
"background",
|
|
58
|
+
"bold",
|
|
59
|
+
"align",
|
|
60
|
+
"verticalAlign"
|
|
61
|
+
];
|
|
62
|
+
/** Problema de forma de uma regra (`null` = válida). */
|
|
63
|
+
function formatRuleError(rule) {
|
|
64
|
+
if (!isRecord$1(rule)) return "precisa ser um objeto";
|
|
65
|
+
const unknown = Object.keys(rule).find((key) => !RULE_KEYS.includes(key));
|
|
66
|
+
if (unknown) return `campo desconhecido \`${unknown}\``;
|
|
67
|
+
if (typeof rule.field !== "string" || !rule.field) return "`field` é obrigatório";
|
|
68
|
+
if (!RULE_OPERATORS.includes(rule.operator)) return `\`operator\` precisa ser ${RULE_OPERATORS.join(", ")}`;
|
|
69
|
+
const operator = rule.operator;
|
|
70
|
+
if (NUMERIC_OPERATORS.includes(operator)) {
|
|
71
|
+
if (typeof rule.value !== "number" || !Number.isFinite(rule.value)) return "`value` precisa ser um número";
|
|
72
|
+
if (operator === "between" && (typeof rule.to !== "number" || !Number.isFinite(rule.to))) return "`to` precisa ser um número (fim do intervalo)";
|
|
73
|
+
} else if (!UNARY_OPERATORS.includes(operator)) {
|
|
74
|
+
if (typeof rule.value !== "number" && typeof rule.value !== "string") return "`value` precisa ser texto ou número";
|
|
244
75
|
}
|
|
245
|
-
return
|
|
76
|
+
if (operator !== "between" && rule.to !== void 0) return "`to` só vale com `between`";
|
|
77
|
+
if (rule.target !== void 0 && rule.target !== "cell" && rule.target !== "row") return "`target` precisa ser cell ou row";
|
|
78
|
+
if (rule.column !== void 0 && (typeof rule.column !== "string" || !rule.column)) return "`column` precisa ser texto";
|
|
79
|
+
if (rule.target === "row" && rule.column !== void 0) return "`column` não vale com row";
|
|
80
|
+
for (const key of ["color", "background"]) if (rule[key] !== void 0 && !isColor(rule[key])) return `\`${key}\` precisa ser uma cor (#rrggbb)`;
|
|
81
|
+
if (rule.bold !== void 0 && typeof rule.bold !== "boolean") return "`bold` precisa ser true/false";
|
|
82
|
+
if (rule.align !== void 0 && !COLUMN_ALIGNS.includes(rule.align)) return `\`align\` precisa ser ${COLUMN_ALIGNS.join(", ")}`;
|
|
83
|
+
if (rule.verticalAlign !== void 0 && !COLUMN_VERTICAL_ALIGNS.includes(rule.verticalAlign)) return `\`verticalAlign\` precisa ser ${COLUMN_VERTICAL_ALIGNS.join(", ")}`;
|
|
84
|
+
if (rule.color === void 0 && rule.background === void 0 && !rule.bold && rule.align === void 0 && rule.verticalAlign === void 0) return "precisa de `color`, `background`, `bold`, `align` ou `verticalAlign`";
|
|
85
|
+
return null;
|
|
246
86
|
}
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
87
|
+
const isEmpty = (value) => value === null || value === void 0 || value === "";
|
|
88
|
+
/** A regra vale para esse valor? Valor vazio só casa com `empty` (e `!=`). */
|
|
89
|
+
function matchesRule(rule, value) {
|
|
90
|
+
if (rule.operator === "empty") return isEmpty(value);
|
|
91
|
+
if (rule.operator === "notEmpty") return !isEmpty(value);
|
|
92
|
+
if (isEmpty(value)) return rule.operator === "!=";
|
|
93
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
94
|
+
const target = rule.value;
|
|
95
|
+
switch (rule.operator) {
|
|
96
|
+
case ">": return number > Number(target);
|
|
97
|
+
case ">=": return number >= Number(target);
|
|
98
|
+
case "<": return number < Number(target);
|
|
99
|
+
case "<=": return number <= Number(target);
|
|
100
|
+
case "between": {
|
|
101
|
+
const [low, high] = [Number(target), Number(rule.to)].sort((a, b) => a - b);
|
|
102
|
+
return number >= low && number <= high;
|
|
262
103
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (seen.has(pair)) errors.push(`${context}: já existe um relacionamento de "${from.table}" para "${to.table}"`);
|
|
268
|
-
seen.add(pair);
|
|
269
|
-
}
|
|
270
|
-
if (errors.length) return errors;
|
|
271
|
-
const edges = modelEdges(model);
|
|
272
|
-
for (const table of tableNames) {
|
|
273
|
-
const { ambiguous, paths } = reachFrom(table, edges);
|
|
274
|
-
if (ambiguous.length) {
|
|
275
|
-
errors.push(`caminho ambíguo de "${table}" até "${ambiguous[0]}": há mais de um relacionamento levando lá`);
|
|
276
|
-
break;
|
|
277
|
-
}
|
|
278
|
-
if (edges.some((edge) => edge.to.table === table && paths.has(edge.from.table))) {
|
|
279
|
-
errors.push(`relacionamentos em ciclo passando por "${table}"`);
|
|
280
|
-
break;
|
|
104
|
+
case "=":
|
|
105
|
+
case "!=": {
|
|
106
|
+
const equal = typeof target === "number" || Number.isFinite(number) && typeof value === "number" ? number === Number(target) : String(value).toLowerCase() === String(target).toLowerCase();
|
|
107
|
+
return rule.operator === "=" ? equal : !equal;
|
|
281
108
|
}
|
|
109
|
+
case "contains": return String(value).toLowerCase().includes(String(target ?? "").toLowerCase());
|
|
282
110
|
}
|
|
283
|
-
return errors;
|
|
284
111
|
}
|
|
285
|
-
//#endregion
|
|
286
|
-
//#region src/codegen.ts
|
|
287
112
|
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
113
|
+
* Estilo de cada coluna de uma linha: as regras `row` valem para todas, as `cell` só para a coluna
|
|
114
|
+
* delas, por cima das de linha. Entre regras do mesmo alcance, a de baixo ganha.
|
|
290
115
|
*/
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
return
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const args = isAggregate(measure) ? [literal(measure.sql)] : measure.type === "formula" ? [literal(measure.expression)] : [literal(measure.numerator), literal(measure.denominator)];
|
|
311
|
-
if (meta.length) args.push(`{ ${meta.join(", ")} }`);
|
|
312
|
-
return `${measure.type}(${args.join(", ")})`;
|
|
313
|
-
}
|
|
314
|
-
function tableCode(table) {
|
|
315
|
-
return `{ ${[
|
|
316
|
-
...table.key !== void 0 ? [`key: ${literal(table.key)}`] : [],
|
|
317
|
-
`sql: ${literal(table.sql)}`,
|
|
318
|
-
...metaEntries(table)
|
|
319
|
-
].join(", ")} }`;
|
|
320
|
-
}
|
|
321
|
-
const entries = (record, render) => Object.entries(record).map(([name, value]) => ` ${name}: ${render(value)}`).join(",\n");
|
|
322
|
-
/** O objeto passado ao `defineModel`, começando em `{` (sem indentação na primeira linha). */
|
|
323
|
-
function modelObjectCode(model) {
|
|
324
|
-
const lines = ["{", ` name: ${literal(model.name)},`];
|
|
325
|
-
lines.push(Object.keys(model.tables).length ? ` tables: {\n${entries(model.tables, tableCode)}\n },` : " tables: {},");
|
|
326
|
-
if (model.relationships?.length) lines.push(` relationships: [\n${model.relationships.map((relationship) => ` { from: ${literal(relationship.from)}, to: ${literal(relationship.to)}${relationship.inner ? ", inner: true" : ""} }`).join(",\n")}\n ],`);
|
|
327
|
-
lines.push(` dimensions: {\n${entries(model.dimensions, (dim) => [
|
|
328
|
-
`{ sql: ${literal(dim.sql)}`,
|
|
329
|
-
`type: '${dim.type}'`,
|
|
330
|
-
...metaEntries(dim)
|
|
331
|
-
].join(", ").concat(" }"))}\n },`, ` measures: {\n${entries(model.measures, measureCode)}\n }${model.diagram ? "," : ""}`);
|
|
332
|
-
if (model.diagram) lines.push(` diagram: {\n${entries(model.diagram, diagramCode)}\n }`);
|
|
333
|
-
lines.push("}");
|
|
334
|
-
return lines.join("\n");
|
|
335
|
-
}
|
|
336
|
-
/** Helpers do `@bluemarble/bi/server` que o código do modelo usa. */
|
|
337
|
-
function modelImports(model) {
|
|
338
|
-
return ["defineModel", ...new Set(Object.values(model.measures).map((measure) => measure.type))].sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }));
|
|
116
|
+
function rowRuleStyles(rules, row, columns) {
|
|
117
|
+
const rowStyle = {};
|
|
118
|
+
const cells = {};
|
|
119
|
+
for (const rule of rules) {
|
|
120
|
+
if (!matchesRule(rule, row[rule.field])) continue;
|
|
121
|
+
const style = ruleStyle(rule);
|
|
122
|
+
if (rule.target === "row") Object.assign(rowStyle, style);
|
|
123
|
+
else {
|
|
124
|
+
const column = rule.column ?? rule.field;
|
|
125
|
+
cells[column] = {
|
|
126
|
+
...cells[column],
|
|
127
|
+
...style
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return Object.fromEntries(columns.map((column) => [column, {
|
|
132
|
+
...rowStyle,
|
|
133
|
+
...cells[column]
|
|
134
|
+
}]));
|
|
339
135
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
136
|
+
function ruleStyle(rule) {
|
|
137
|
+
return {
|
|
138
|
+
...rule.color && { color: rule.color },
|
|
139
|
+
...rule.background && { background: rule.background },
|
|
140
|
+
...rule.bold && { bold: true },
|
|
141
|
+
...rule.align && { align: rule.align },
|
|
142
|
+
...rule.verticalAlign && { verticalAlign: rule.verticalAlign }
|
|
143
|
+
};
|
|
348
144
|
}
|
|
349
145
|
//#endregion
|
|
350
146
|
//#region src/layout.ts
|
|
@@ -637,19 +433,21 @@ const CHART_FIELDS = {
|
|
|
637
433
|
columnOrder: {
|
|
638
434
|
kind: "columnOrder",
|
|
639
435
|
label: "Ordem das colunas",
|
|
640
|
-
hint: "Dimensões e
|
|
436
|
+
hint: "Dimensões, medidas e % do total em qualquer ordem; vazio = dimensões e depois medidas (cada % logo depois da medida)",
|
|
641
437
|
section: "Dados"
|
|
642
438
|
},
|
|
643
439
|
percentOf: {
|
|
644
440
|
kind: "measures",
|
|
441
|
+
percent: true,
|
|
645
442
|
label: "% do total",
|
|
646
443
|
hint: "Coluna extra com a participação de cada linha: valor da linha ÷ soma da coluna. Faz sentido com medidas somáveis (custo, volume), não com razões.",
|
|
647
444
|
section: "Dados"
|
|
648
445
|
},
|
|
649
446
|
sortBy: {
|
|
650
447
|
kind: "column",
|
|
448
|
+
outside: true,
|
|
651
449
|
label: "Ordenar por",
|
|
652
|
-
hint: "Ordem inicial; quem usa reordena clicando no cabeçalho",
|
|
450
|
+
hint: "Ordem inicial; quem usa reordena clicando no cabeçalho. Pode ser uma dimensão/medida fora da tabela (ex.: número do mês para o nome do mês) — uma dimensão de fora divide as linhas se tiver mais de um valor por linha.",
|
|
653
451
|
section: "Dados"
|
|
654
452
|
},
|
|
655
453
|
sortDirection: {
|
|
@@ -673,9 +471,40 @@ const CHART_FIELDS = {
|
|
|
673
471
|
...clickToFilter,
|
|
674
472
|
hint: "Clicar numa linha filtra pela primeira dimensão"
|
|
675
473
|
},
|
|
474
|
+
cellBorders: {
|
|
475
|
+
kind: "boolean",
|
|
476
|
+
default: false,
|
|
477
|
+
label: "Bordas entre as células",
|
|
478
|
+
hint: "Grade em volta de cada célula; desligado = só a linha embaixo de cada linha",
|
|
479
|
+
section: "Aparência"
|
|
480
|
+
},
|
|
481
|
+
borderColor: {
|
|
482
|
+
kind: "color",
|
|
483
|
+
default: "#e0e0e0",
|
|
484
|
+
label: "Cor das bordas",
|
|
485
|
+
section: "Aparência"
|
|
486
|
+
},
|
|
676
487
|
labels: LABELS_FIELD,
|
|
677
488
|
formats: FORMATS_FIELD,
|
|
678
|
-
|
|
489
|
+
align: {
|
|
490
|
+
kind: "aligns",
|
|
491
|
+
label: "Alinhamento",
|
|
492
|
+
hint: "Alinhamento horizontal de cada coluna; vazio = dimensão à esquerda, número à direita",
|
|
493
|
+
section: "Rótulos e formatos"
|
|
494
|
+
},
|
|
495
|
+
verticalAlign: {
|
|
496
|
+
kind: "verticalAligns",
|
|
497
|
+
label: "Alinhamento vertical",
|
|
498
|
+
hint: "Alinhamento vertical de cada coluna (aparece com texto em mais de uma linha); vazio = no meio",
|
|
499
|
+
section: "Rótulos e formatos"
|
|
500
|
+
},
|
|
501
|
+
percentDecimals,
|
|
502
|
+
formatRules: {
|
|
503
|
+
kind: "formatRules",
|
|
504
|
+
label: "Regras",
|
|
505
|
+
hint: "Muda a cor do texto, o fundo ou o negrito da célula ou da linha conforme o valor de uma dimensão/medida (pode ser de fora da tabela). Regras de linha primeiro, de célula por cima; a de baixo ganha.",
|
|
506
|
+
section: "Formatação condicional"
|
|
507
|
+
}
|
|
679
508
|
}
|
|
680
509
|
};
|
|
681
510
|
const BUILTIN_CHART_TYPES = Object.fromEntries(CHART_TYPES.map((type) => [type, {
|
|
@@ -917,6 +746,15 @@ function fieldError(spec, value) {
|
|
|
917
746
|
return new Set(value).size === value.length ? null : "tem item repetido";
|
|
918
747
|
case "formatters": return isRecord(value) && Object.values(value).every(isName) ? null : "precisa ser { dimensão: formatter }";
|
|
919
748
|
case "labels": return isRecord(value) && Object.values(value).every((label) => typeof label === "string") ? null : "precisa ser { coluna: rótulo }";
|
|
749
|
+
case "aligns": return isRecord(value) && Object.values(value).every((align) => COLUMN_ALIGNS.includes(align)) ? null : `precisa ser { coluna: ${COLUMN_ALIGNS.join(" | ")} }`;
|
|
750
|
+
case "verticalAligns": return isRecord(value) && Object.values(value).every((align) => COLUMN_VERTICAL_ALIGNS.includes(align)) ? null : `precisa ser { coluna: ${COLUMN_VERTICAL_ALIGNS.join(" | ")} }`;
|
|
751
|
+
case "formatRules":
|
|
752
|
+
if (!Array.isArray(value)) return "precisa ser uma lista de regras";
|
|
753
|
+
for (const [index, rule] of value.entries()) {
|
|
754
|
+
const error = formatRuleError(rule);
|
|
755
|
+
if (error) return `regra ${index + 1}: ${error}`;
|
|
756
|
+
}
|
|
757
|
+
return null;
|
|
920
758
|
case "formats":
|
|
921
759
|
if (!isRecord(value)) return "precisa ser { medida: formato }";
|
|
922
760
|
for (const [measure, format] of Object.entries(value)) {
|
|
@@ -1041,12 +879,39 @@ function fieldReferences(fields, item) {
|
|
|
1041
879
|
return refs;
|
|
1042
880
|
}
|
|
1043
881
|
/**
|
|
1044
|
-
* Colunas que o gráfico mostra (para `sortBy`, rótulos): as dimensões e medidas escolhidas nele
|
|
1045
|
-
* ordem do campo `columnOrder` (se o tipo tiver)
|
|
882
|
+
* Colunas que o gráfico mostra (para `sortBy`, rótulos): as dimensões e medidas escolhidas nele e os
|
|
883
|
+
* percentuais (`%:medida`), na ordem do campo `columnOrder` (se o tipo tiver) — ver `tableColumns`.
|
|
1046
884
|
*/
|
|
1047
885
|
function chartColumns(chart, types = BUILTIN_CHART_TYPES) {
|
|
1048
|
-
const
|
|
1049
|
-
|
|
886
|
+
const values = chart;
|
|
887
|
+
const columns = [];
|
|
888
|
+
const percents = [];
|
|
889
|
+
for (const [key, spec] of Object.entries(types[chart.type]?.fields ?? {})) {
|
|
890
|
+
const value = values[key];
|
|
891
|
+
if (value === void 0 || value === "") continue;
|
|
892
|
+
if (spec.kind === "dimension" || spec.kind === "measure") columns.push(value);
|
|
893
|
+
if (spec.kind === "dimensions") columns.push(...value);
|
|
894
|
+
if (spec.kind === "measures") (spec.percent ? percents : columns).push(...value);
|
|
895
|
+
}
|
|
896
|
+
return tableColumns([...new Set(columns)], [...new Set(percents)], columnOrderOf(chart, types));
|
|
897
|
+
}
|
|
898
|
+
const percentColumn = (measure) => `%:${measure}`;
|
|
899
|
+
/**
|
|
900
|
+
* Ordem das colunas: as de `order` primeiro, o resto depois, na ordem em que veio (`orderedColumns`).
|
|
901
|
+
* O % de uma medida que não está em `order` vem logo depois da medida (ou no fim, se ela não é
|
|
902
|
+
* coluna) — layouts sem `%:` no `columnOrder` continuam como antes.
|
|
903
|
+
*/
|
|
904
|
+
function tableColumns(columns, percentOf, order) {
|
|
905
|
+
const percents = percentOf.map(percentColumn);
|
|
906
|
+
const result = orderedColumns([...columns, ...percents.filter((column) => order?.includes(column))], order);
|
|
907
|
+
for (const measure of percentOf) {
|
|
908
|
+
const column = percentColumn(measure);
|
|
909
|
+
if (result.includes(column)) continue;
|
|
910
|
+
const at = result.indexOf(measure);
|
|
911
|
+
if (at === -1) result.push(column);
|
|
912
|
+
else result.splice(at + 1, 0, column);
|
|
913
|
+
}
|
|
914
|
+
return result;
|
|
1050
915
|
}
|
|
1051
916
|
/** Valor do campo `columnOrder` do gráfico (o tipo pode não ter). */
|
|
1052
917
|
function columnOrderOf(chart, types = BUILTIN_CHART_TYPES) {
|
|
@@ -1073,8 +938,15 @@ function layoutModelWarnings(layout, model, types = BUILTIN_CHART_TYPES) {
|
|
|
1073
938
|
const columns = chartColumns(chart, types);
|
|
1074
939
|
for (const [key, spec] of Object.entries(types[chart.type]?.fields ?? {})) {
|
|
1075
940
|
const value = chart[key];
|
|
1076
|
-
if (spec.kind === "column" && isName(value) && !columns.includes(value)) warnings.push(`${name}: "${value}" (${key}) não é coluna do gráfico`);
|
|
1077
|
-
if (
|
|
941
|
+
if (spec.kind === "column" && isName(value) && !columns.includes(value)) warnings.push(spec.outside ? isModelField(model, value) ? "" : `${name}: "${value}" (${key}) não existe no modelo` : `${name}: "${value}" (${key}) não é coluna do gráfico`);
|
|
942
|
+
if (spec.kind === "formatRules" && Array.isArray(value)) value.forEach((rule, index) => {
|
|
943
|
+
if (!isRecord(rule)) return;
|
|
944
|
+
const context = `${name}: regra ${index + 1} (${key})`;
|
|
945
|
+
if (isName(rule.field) && !columns.includes(rule.field) && !isModelField(model, rule.field)) warnings.push(`${context}: "${rule.field}" não existe no modelo`);
|
|
946
|
+
const painted = rule.target === "row" ? void 0 : rule.column ?? rule.field;
|
|
947
|
+
if (isName(painted) && !columns.includes(painted)) warnings.push(`${context}: "${painted}" não é coluna do gráfico (nada é pintado)`);
|
|
948
|
+
});
|
|
949
|
+
if ((spec.kind === "labels" || spec.kind === "formats" || spec.kind === "aligns" || spec.kind === "verticalAligns") && isRecord(value)) {
|
|
1078
950
|
for (const column of Object.keys(value)) if (!columns.includes(column)) warnings.push(`${name}: "${column}" (${key}) não é coluna do gráfico`);
|
|
1079
951
|
}
|
|
1080
952
|
if (spec.kind === "columnOrder" && Array.isArray(value)) {
|
|
@@ -1083,8 +955,9 @@ function layoutModelWarnings(layout, model, types = BUILTIN_CHART_TYPES) {
|
|
|
1083
955
|
}
|
|
1084
956
|
}
|
|
1085
957
|
for (const filter of layout.filters ?? []) if (!model.dimensions[filter.dimension]) warnings.push(`filtro "${filter.label || filter.dimension}": dimensão "${filter.dimension}" não existe`);
|
|
1086
|
-
return warnings;
|
|
958
|
+
return warnings.filter(Boolean);
|
|
1087
959
|
}
|
|
960
|
+
const isModelField = (model, name) => Object.hasOwn(model.dimensions, name) || Object.hasOwn(model.measures, name);
|
|
1088
961
|
/**
|
|
1089
962
|
* Gráfico novo de um tipo, reaproveitando o que der do anterior (troca de tipo no devtools) ou
|
|
1090
963
|
* escolhendo dimensão/medidas do modelo.
|
|
@@ -1182,6 +1055,6 @@ function layoutFileContent(layout) {
|
|
|
1182
1055
|
return `${JSON.stringify(layout, null, 2)}\n`;
|
|
1183
1056
|
}
|
|
1184
1057
|
//#endregion
|
|
1185
|
-
export {
|
|
1058
|
+
export { RULE_OPERATOR_LABEL as C, RULE_OPERATORS as S, rowRuleStyles as T, tableColumns as _, FILTER_FIELDS as a, COLUMN_VERTICAL_ALIGNS as b, chartOfType as c, filterReferences as d, isBuiltinChart as f, resolveChartTypes as g, percentColumn as h, CARD_FIELDS as i, chartReferences as l, layoutModelWarnings as m, CANVAS_PRESETS as n, chartCardStyle as o, layoutFileContent as p, CARDS_FIELDS as r, chartColumns as s, BUILTIN_CHART_TYPES as t, customChartProps as u, validateLayout as v, UNARY_OPERATORS as w, NUMERIC_OPERATORS as x, COLUMN_ALIGNS as y };
|
|
1186
1059
|
|
|
1187
|
-
//# sourceMappingURL=layout-
|
|
1060
|
+
//# sourceMappingURL=layout-BS51SQcJ.js.map
|