@bluemarble/bi 0.4.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-7L93aV1-.js → DashboardDevtools-SCewfQGR.js} +1081 -240
- package/dist/DashboardDevtools-SCewfQGR.js.map +1 -0
- package/dist/{ModelDictionary-Y7W9oK1J.js → ModelDictionary-C2tzlOY8.js} +82 -5
- package/dist/ModelDictionary-C2tzlOY8.js.map +1 -0
- 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 +32 -3
- package/dist/client/index.js +79 -100
- 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-HXAn-3I1.d.ts → layout-B7eYgNyR.d.ts} +236 -150
- package/dist/{layout-676sEvvb.js → layout-BS51SQcJ.js} +242 -345
- 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-7L93aV1-.js.map +0 -1
- package/dist/ModelDictionary-Y7W9oK1J.js.map +0 -1
- package/dist/layout-676sEvvb.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
|
-
|
|
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;
|
|
256
103
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
continue;
|
|
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;
|
|
262
108
|
}
|
|
263
|
-
|
|
264
|
-
if (from.table === to.table) errors.push(`${context}: liga a tabela "${from.table}" a ela mesma`);
|
|
265
|
-
if (relationship.inner !== void 0 && typeof relationship.inner !== "boolean") errors.push(`${context}: \`inner\` precisa ser true/false`);
|
|
266
|
-
const pair = `${from.table}->${to.table}`;
|
|
267
|
-
if (seen.has(pair)) errors.push(`${context}: já existe um relacionamento de "${from.table}" para "${to.table}"`);
|
|
268
|
-
seen.add(pair);
|
|
109
|
+
case "contains": return String(value).toLowerCase().includes(String(target ?? "").toLowerCase());
|
|
269
110
|
}
|
|
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;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
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
|
|
@@ -440,7 +236,7 @@ const percentDecimals = {
|
|
|
440
236
|
max: 6,
|
|
441
237
|
default: 1,
|
|
442
238
|
label: "Casas do %",
|
|
443
|
-
section: "
|
|
239
|
+
section: "Dados"
|
|
444
240
|
};
|
|
445
241
|
const CHART_FIELDS = {
|
|
446
242
|
kpi: {
|
|
@@ -634,16 +430,24 @@ const CHART_FIELDS = {
|
|
|
634
430
|
label: "Medidas (colunas)",
|
|
635
431
|
section: "Dados"
|
|
636
432
|
},
|
|
433
|
+
columnOrder: {
|
|
434
|
+
kind: "columnOrder",
|
|
435
|
+
label: "Ordem das colunas",
|
|
436
|
+
hint: "Dimensões, medidas e % do total em qualquer ordem; vazio = dimensões e depois medidas (cada % logo depois da medida)",
|
|
437
|
+
section: "Dados"
|
|
438
|
+
},
|
|
637
439
|
percentOf: {
|
|
638
440
|
kind: "measures",
|
|
441
|
+
percent: true,
|
|
639
442
|
label: "% do total",
|
|
640
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.",
|
|
641
444
|
section: "Dados"
|
|
642
445
|
},
|
|
643
446
|
sortBy: {
|
|
644
447
|
kind: "column",
|
|
448
|
+
outside: true,
|
|
645
449
|
label: "Ordenar por",
|
|
646
|
-
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.",
|
|
647
451
|
section: "Dados"
|
|
648
452
|
},
|
|
649
453
|
sortDirection: {
|
|
@@ -667,9 +471,40 @@ const CHART_FIELDS = {
|
|
|
667
471
|
...clickToFilter,
|
|
668
472
|
hint: "Clicar numa linha filtra pela primeira dimensão"
|
|
669
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
|
+
},
|
|
670
487
|
labels: LABELS_FIELD,
|
|
671
488
|
formats: FORMATS_FIELD,
|
|
672
|
-
|
|
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
|
+
}
|
|
673
508
|
}
|
|
674
509
|
};
|
|
675
510
|
const BUILTIN_CHART_TYPES = Object.fromEntries(CHART_TYPES.map((type) => [type, {
|
|
@@ -905,11 +740,21 @@ function fieldError(spec, value) {
|
|
|
905
740
|
case "column": return isName(value) ? null : spec.required ? "é obrigatório" : "precisa ser texto";
|
|
906
741
|
case "dimensions":
|
|
907
742
|
case "measures":
|
|
743
|
+
case "columnOrder":
|
|
908
744
|
if (!Array.isArray(value) || !value.every(isName)) return "precisa ser uma lista de nomes";
|
|
909
745
|
if (spec.required && !value.length) return "precisa de pelo menos um item";
|
|
910
746
|
return new Set(value).size === value.length ? null : "tem item repetido";
|
|
911
747
|
case "formatters": return isRecord(value) && Object.values(value).every(isName) ? null : "precisa ser { dimensão: formatter }";
|
|
912
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;
|
|
913
758
|
case "formats":
|
|
914
759
|
if (!isRecord(value)) return "precisa ser { medida: formato }";
|
|
915
760
|
for (const [measure, format] of Object.entries(value)) {
|
|
@@ -1033,10 +878,51 @@ function fieldReferences(fields, item) {
|
|
|
1033
878
|
}
|
|
1034
879
|
return refs;
|
|
1035
880
|
}
|
|
1036
|
-
/**
|
|
881
|
+
/**
|
|
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`.
|
|
884
|
+
*/
|
|
1037
885
|
function chartColumns(chart, types = BUILTIN_CHART_TYPES) {
|
|
1038
|
-
const
|
|
1039
|
-
|
|
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;
|
|
915
|
+
}
|
|
916
|
+
/** Valor do campo `columnOrder` do gráfico (o tipo pode não ter). */
|
|
917
|
+
function columnOrderOf(chart, types = BUILTIN_CHART_TYPES) {
|
|
918
|
+
const values = chart;
|
|
919
|
+
for (const [key, spec] of Object.entries(types[chart.type]?.fields ?? {})) if (spec.kind === "columnOrder" && Array.isArray(values[key])) return values[key];
|
|
920
|
+
}
|
|
921
|
+
/** `columns` na ordem de `order`; os que não estão nela vão depois, na ordem em que vieram. */
|
|
922
|
+
function orderedColumns(columns, order) {
|
|
923
|
+
if (!order?.length) return columns;
|
|
924
|
+
const first = order.filter((name) => columns.includes(name));
|
|
925
|
+
return [...first, ...columns.filter((name) => !first.includes(name))];
|
|
1040
926
|
}
|
|
1041
927
|
/**
|
|
1042
928
|
* Dimensões/medidas que o layout usa e o modelo não tem. Não impede salvar (o modelo pode estar
|
|
@@ -1052,15 +938,26 @@ function layoutModelWarnings(layout, model, types = BUILTIN_CHART_TYPES) {
|
|
|
1052
938
|
const columns = chartColumns(chart, types);
|
|
1053
939
|
for (const [key, spec] of Object.entries(types[chart.type]?.fields ?? {})) {
|
|
1054
940
|
const value = chart[key];
|
|
1055
|
-
if (spec.kind === "column" && isName(value) && !columns.includes(value)) warnings.push(`${name}: "${value}" (${key}) não é coluna do gráfico`);
|
|
1056
|
-
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)) {
|
|
1057
950
|
for (const column of Object.keys(value)) if (!columns.includes(column)) warnings.push(`${name}: "${column}" (${key}) não é coluna do gráfico`);
|
|
1058
951
|
}
|
|
952
|
+
if (spec.kind === "columnOrder" && Array.isArray(value)) {
|
|
953
|
+
for (const column of value) if (isName(column) && !columns.includes(column)) warnings.push(`${name}: "${column}" (${key}) não é coluna do gráfico`);
|
|
954
|
+
}
|
|
1059
955
|
}
|
|
1060
956
|
}
|
|
1061
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`);
|
|
1062
|
-
return warnings;
|
|
958
|
+
return warnings.filter(Boolean);
|
|
1063
959
|
}
|
|
960
|
+
const isModelField = (model, name) => Object.hasOwn(model.dimensions, name) || Object.hasOwn(model.measures, name);
|
|
1064
961
|
/**
|
|
1065
962
|
* Gráfico novo de um tipo, reaproveitando o que der do anterior (troca de tipo no devtools) ou
|
|
1066
963
|
* escolhendo dimensão/medidas do modelo.
|
|
@@ -1158,6 +1055,6 @@ function layoutFileContent(layout) {
|
|
|
1158
1055
|
return `${JSON.stringify(layout, null, 2)}\n`;
|
|
1159
1056
|
}
|
|
1160
1057
|
//#endregion
|
|
1161
|
-
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 };
|
|
1162
1059
|
|
|
1163
|
-
//# sourceMappingURL=layout-
|
|
1060
|
+
//# sourceMappingURL=layout-BS51SQcJ.js.map
|