Skip to content

Commit d07cc9f

Browse files
committed
[IMP] formulas: allow literal arrays
Allow entering literal arrays when composing formulas using curly brackets {}. closes #7212 Task: 4735250 Signed-off-by: Lucas Lefèvre (lul) <lul@odoo.com>
1 parent dee6a4a commit d07cc9f

File tree

15 files changed

+545
-41
lines changed

15 files changed

+545
-41
lines changed

packages/o-spreadsheet-engine/src/formulas/compiler.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,23 @@ function compileTokensOrThrow(tokens: Token[]): CompiledFormula {
189189
code.append(...args);
190190
const fnName = ast.value.toUpperCase();
191191
return code.return(`ctx['${fnName}'](${args.map((arg) => arg.returnExpression)})`);
192+
case "ARRAY": {
193+
// a literal array is compiled into function calls
194+
const arrayFunctionCall: ASTFuncall = {
195+
type: "FUNCALL",
196+
value: "ARRAY.LITERAL",
197+
args: ast.value.map((row) => ({
198+
type: "FUNCALL",
199+
value: "ARRAY.ROW",
200+
args: row,
201+
tokenStartIndex: 0,
202+
tokenEndIndex: 0,
203+
})),
204+
tokenStartIndex: 0,
205+
tokenEndIndex: 0,
206+
};
207+
return compileAST(arrayFunctionCall);
208+
}
192209
case "UNARY_OPERATION": {
193210
const fnName = UNARY_OPERATOR_MAP[ast.value];
194211
const operand = compileAST(ast.operand, false, false).assignResultToVariable();

packages/o-spreadsheet-engine/src/formulas/formula_formatter.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,21 @@ function astToDoc(ast: AST): Doc {
260260
}
261261
}
262262
return wrapInParentheses(concat(splitArgsWithCommas(docs)), ast.value);
263+
264+
case "ARRAY": {
265+
const rowDocs = ast.value.map((row) =>
266+
concat(
267+
row.map((value, index) =>
268+
index === 0 ? astToDoc(value) : concat([", ", line(), astToDoc(value)])
269+
)
270+
)
271+
);
272+
const body = concat(
273+
rowDocs.map((doc, index) => (index === 0 ? group(doc) : concat(["; ", line(), group(doc)])))
274+
);
275+
return wrapInBraces(body);
276+
}
277+
263278
case "UNARY_OPERATION":
264279
const operandDoc = astToDoc(ast.operand);
265280
const needParenthesis = ast.postfix
@@ -312,6 +327,10 @@ function wrapInParentheses(doc: Doc, functionName: undefined | string = undefine
312327
return group(concat(docToConcat));
313328
}
314329

330+
function wrapInBraces(doc: Doc): Doc {
331+
return group(concat(["{", nest(1, concat([line(), doc])), line(), "}"]));
332+
}
333+
315334
/**
316335
* Converts an ast formula to the corresponding string
317336
*/
@@ -339,6 +358,12 @@ export function astToFormula(ast: AST): string {
339358
? `(${astToFormula(ast.operand)})`
340359
: astToFormula(ast.operand);
341360
return ast.value + rightOperand;
361+
case "ARRAY":
362+
return (
363+
"{" +
364+
ast.value.map((row) => row.map((cell) => astToFormula(cell)).join(",")).join(";") +
365+
"}"
366+
);
342367
case "BIN_OPERATION":
343368
const leftOperation = leftOperandNeedsParenthesis(ast)
344369
? `(${astToFormula(ast.left)})`

packages/o-spreadsheet-engine/src/formulas/parser.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export interface ASTSymbol extends ASTBase {
9191
value: string;
9292
}
9393

94+
export interface ASTArray extends ASTBase {
95+
type: "ARRAY";
96+
value: AST[][];
97+
}
98+
9499
interface ASTEmpty extends ASTBase {
95100
type: "EMPTY";
96101
value: "";
@@ -101,6 +106,7 @@ export type AST =
101106
| ASTUnaryOperation
102107
| ASTFuncall
103108
| ASTSymbol
109+
| ASTArray
104110
| ASTNumber
105111
| ASTBoolean
106112
| ASTString
@@ -219,6 +225,8 @@ function parseOperand(tokens: TokenList): AST {
219225
tokenStartIndex: current.tokenIndex,
220226
tokenEndIndex: rightParen.tokenIndex,
221227
};
228+
case "LEFT_BRACE":
229+
return parseArrayLiteral(tokens, current);
222230
case "OPERATOR":
223231
const operator = current.value;
224232
if (UNARY_OPERATORS_PREFIX.includes(operator)) {
@@ -276,6 +284,33 @@ function consumeOrThrow(tokens: TokenList, type, message?: string) {
276284
return token;
277285
}
278286

287+
function parseArrayLiteral(tokens: TokenList, leftBrace: RichToken): ASTArray {
288+
const rows: AST[][] = [];
289+
let currentRow: AST[] = [parseExpression(tokens)]; // there must be at least one element
290+
291+
while (tokens.current?.type !== "RIGHT_BRACE") {
292+
const nextToken = tokens.shift();
293+
if (!nextToken) {
294+
throw new BadExpressionError(_t("Missing closing brace"));
295+
} else if (nextToken.type === "ARG_SEPARATOR") {
296+
currentRow.push(parseExpression(tokens));
297+
} else if (nextToken.type === "ARRAY_ROW_SEPARATOR") {
298+
rows.push(currentRow);
299+
currentRow = [parseExpression(tokens)];
300+
} else {
301+
throw new BadExpressionError(_t("Unexpected token: %s", nextToken.value));
302+
}
303+
}
304+
const rightBrace = consumeOrThrow(tokens, "RIGHT_BRACE", _t("Missing closing brace"));
305+
rows.push(currentRow);
306+
return {
307+
type: "ARRAY",
308+
value: rows,
309+
tokenStartIndex: leftBrace.tokenIndex,
310+
tokenEndIndex: rightBrace.tokenIndex,
311+
};
312+
}
313+
279314
function parseExpression(tokens: TokenList, parent_priority: number = 0): AST {
280315
if (tokens.length === 0) {
281316
throw new BadExpressionError();
@@ -376,6 +411,13 @@ function* astIterator(ast: AST): Iterable<AST> {
376411
yield* astIterator(arg);
377412
}
378413
break;
414+
case "ARRAY":
415+
for (const row of ast.value) {
416+
for (const cell of row) {
417+
yield* astIterator(cell);
418+
}
419+
}
420+
break;
379421
case "UNARY_OPERATION":
380422
yield* astIterator(ast.operand);
381423
break;
@@ -397,6 +439,11 @@ export function mapAst<T extends AST["type"]>(
397439
...ast,
398440
args: ast.args.map((child) => mapAst(child, fn)),
399441
};
442+
case "ARRAY":
443+
return {
444+
...ast,
445+
value: ast.value.map((row) => row.map((cell) => mapAst(cell, fn))),
446+
};
400447
case "UNARY_OPERATION":
401448
return {
402449
...ast,

packages/o-spreadsheet-engine/src/formulas/tokenizer.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ type TokenType =
3838
| "SPACE"
3939
| "DEBUGGER"
4040
| "ARG_SEPARATOR"
41+
| "ARRAY_ROW_SEPARATOR"
4142
| "LEFT_PAREN"
4243
| "RIGHT_PAREN"
44+
| "LEFT_BRACE"
45+
| "RIGHT_BRACE"
4346
| "REFERENCE"
4447
| "INVALID_REFERENCE"
4548
| "UNKNOWN";
@@ -61,7 +64,9 @@ export function tokenize(str: string, locale = DEFAULT_LOCALE): Token[] {
6164
let token =
6265
tokenizeNewLine(chars) ||
6366
tokenizeSpace(chars) ||
67+
tokenizeArrayRowSeparator(chars, locale) ||
6468
tokenizeArgsSeparator(chars, locale) ||
69+
tokenizeBraces(chars) ||
6570
tokenizeParenthesis(chars) ||
6671
tokenizeOperator(chars) ||
6772
tokenizeString(chars) ||
@@ -100,6 +105,19 @@ function tokenizeParenthesis(chars: TokenizingChars): Token | null {
100105
return null;
101106
}
102107

108+
const braces = {
109+
"{": { type: "LEFT_BRACE", value: "{" },
110+
"}": { type: "RIGHT_BRACE", value: "}" },
111+
} as const;
112+
113+
function tokenizeBraces(chars: TokenizingChars): Token | null {
114+
if (chars.current === "{" || chars.current === "}") {
115+
const value = chars.shift();
116+
return braces[value];
117+
}
118+
return null;
119+
}
120+
103121
function tokenizeArgsSeparator(chars: TokenizingChars, locale: Locale): Token | null {
104122
if (chars.current === locale.formulaArgSeparator) {
105123
const value = chars.shift();
@@ -110,6 +128,21 @@ function tokenizeArgsSeparator(chars: TokenizingChars, locale: Locale): Token |
110128
return null;
111129
}
112130

131+
function tokenizeArrayRowSeparator(chars: TokenizingChars, locale: Locale): Token | null {
132+
// The array row separator is used in array literals to separate rows.
133+
// It is not explicitly defined in locales, but depends on the formulaArgSeparator.
134+
// Example: {1,2,3;4,5,6} — here, ';' separates rows and ',' separates columns.
135+
const rowSeparator = locale.formulaArgSeparator === ";" ? "\\" : ";";
136+
if (!rowSeparator) {
137+
return null;
138+
}
139+
if (chars.current === rowSeparator) {
140+
chars.shift();
141+
return { type: "ARRAY_ROW_SEPARATOR", value: rowSeparator };
142+
}
143+
return null;
144+
}
145+
113146
function tokenizeOperator(chars: TokenizingChars): Token | null {
114147
for (const op of OPERATORS) {
115148
if (chars.currentStartsWith(op)) {

packages/o-spreadsheet-engine/src/functions/module_array.ts

Lines changed: 107 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,80 @@ import {
1717
transposeMatrix,
1818
} from "./helpers";
1919

20+
function stackHorizontally(
21+
ranges: Arg[],
22+
options?: { requireSameRowCount?: boolean }
23+
): Matrix<FunctionResultObject> | EvaluationError {
24+
const matrices = ranges.map(toMatrix);
25+
const nbRowsArr = matrices.map((m) => m?.[0]?.length ?? 0);
26+
const nbRows = Math.max(...nbRowsArr);
27+
28+
if (options?.requireSameRowCount) {
29+
const firstLength = nbRowsArr[0];
30+
if (nbRowsArr.some((len) => len !== firstLength)) {
31+
return new EvaluationError(
32+
_t(
33+
"All ranges in [[FUNCTION_NAME]] must have the same number of columns (got %s).",
34+
nbRowsArr.join(", ")
35+
)
36+
);
37+
}
38+
}
39+
40+
const result: Matrix<FunctionResultObject> = [];
41+
for (const matrix of matrices) {
42+
for (let col = 0; col < matrix.length; col++) {
43+
// Fill with nulls if needed
44+
const array: FunctionResultObject[] = Array(nbRows).fill({ value: null });
45+
for (let row = 0; row < matrix[col].length; row++) {
46+
array[row] = matrix[col][row];
47+
}
48+
result.push(array);
49+
}
50+
}
51+
return result;
52+
}
53+
54+
function stackVertically(
55+
ranges: Arg[],
56+
options?: { requireSameColCount?: boolean }
57+
): Matrix<FunctionResultObject> | EvaluationError {
58+
const matrices = ranges.map(toMatrix);
59+
const nbColsArr = matrices.map((m) => m?.length ?? 0);
60+
const nbCols = Math.max(...nbColsArr);
61+
62+
if (options?.requireSameColCount) {
63+
const firstLength = nbColsArr[0];
64+
if (nbColsArr.some((len) => len !== firstLength)) {
65+
return new EvaluationError(
66+
_t(
67+
"All ranges in [[FUNCTION_NAME]] must have the same number of columns (got %s).",
68+
nbColsArr.join(", ")
69+
)
70+
);
71+
}
72+
}
73+
74+
const nbRows = matrices.reduce((acc, m) => acc + (m?.[0]?.length ?? 0), 0);
75+
const result: Matrix<FunctionResultObject> = generateMatrix(nbCols, nbRows, () => ({
76+
value: null,
77+
}));
78+
79+
let currentRow = 0;
80+
for (const matrix of matrices) {
81+
for (let col = 0; col < matrix.length; col++) {
82+
for (let row = 0; row < matrix[col].length; row++) {
83+
result[col][currentRow + row] = matrix[col][row];
84+
}
85+
}
86+
currentRow += matrix[0]?.length ?? 0;
87+
}
88+
89+
return result;
90+
}
91+
2092
// -----------------------------------------------------------------------------
21-
// ARRAY_CONSTRAIN
93+
// ARRAY.CONSTRAIN
2294
// -----------------------------------------------------------------------------
2395
export const ARRAY_CONSTRAIN = {
2496
description: _t("Returns a result array constrained to a specific width and height."),
@@ -55,6 +127,36 @@ export const ARRAY_CONSTRAIN = {
55127
isExported: false,
56128
} satisfies AddFunctionDescription;
57129

130+
// -----------------------------------------------------------------------------
131+
// ARRAY.LITERAL
132+
// -----------------------------------------------------------------------------
133+
export const ARRAY_LITERAL = {
134+
description: _t(
135+
"Appends ranges vertically and in sequence to return a larger array. All ranges must have the same number of columns."
136+
),
137+
args: [arg("range (any, range<any>, repeating)", _t("The range to be appended."))],
138+
compute: function (...ranges: Arg[]) {
139+
return stackVertically(ranges, { requireSameColCount: true });
140+
},
141+
isExported: false,
142+
hidden: true,
143+
} satisfies AddFunctionDescription;
144+
145+
// -----------------------------------------------------------------------------
146+
// ARRAY.ROW
147+
// -----------------------------------------------------------------------------
148+
export const ARRAY_ROW = {
149+
description: _t(
150+
"Appends ranges horizontally and in sequence to return a larger array. All ranges must have the same number of rows."
151+
),
152+
args: [arg("range (any, range<any>, repeating)", _t("The range to be appended."))],
153+
compute: function (...ranges: Arg[]) {
154+
return stackHorizontally(ranges, { requireSameRowCount: true });
155+
},
156+
isExported: false,
157+
hidden: true,
158+
} satisfies AddFunctionDescription;
159+
58160
// -----------------------------------------------------------------------------
59161
// CHOOSECOLS
60162
// -----------------------------------------------------------------------------
@@ -267,23 +369,8 @@ export const FREQUENCY = {
267369
export const HSTACK = {
268370
description: _t("Appends ranges horizontally and in sequence to return a larger array."),
269371
args: [arg("range (any, range<any>, repeating)", _t("The range to be appended."))],
270-
compute: function (...ranges: Arg[]): Matrix<FunctionResultObject> {
271-
const nbRows = Math.max(...ranges.map((r) => r?.[0]?.length ?? 0));
272-
273-
const result: Matrix<FunctionResultObject> = [];
274-
275-
for (const range of ranges) {
276-
const _range = toMatrix(range);
277-
for (let col = 0; col < _range.length; col++) {
278-
//TODO: fill with #N/A for unavailable values instead of zeroes
279-
const array: FunctionResultObject[] = Array(nbRows).fill({ value: null });
280-
for (let row = 0; row < _range[col].length; row++) {
281-
array[row] = _range[col][row];
282-
}
283-
result.push(array);
284-
}
285-
}
286-
return result;
372+
compute: function (...ranges: Arg[]) {
373+
return stackHorizontally(ranges);
287374
},
288375
isExported: true,
289376
} satisfies AddFunctionDescription;
@@ -652,26 +739,8 @@ export const TRANSPOSE = {
652739
export const VSTACK = {
653740
description: _t("Appends ranges vertically and in sequence to return a larger array."),
654741
args: [arg("range (any, range<any>, repeating)", _t("The range to be appended."))],
655-
compute: function (...ranges: Arg[]): Matrix<FunctionResultObject> {
656-
const nbColumns = Math.max(...ranges.map((range) => toMatrix(range).length));
657-
const nbRows = ranges.reduce((acc, range) => acc + toMatrix(range)[0].length, 0);
658-
659-
const result: Matrix<FunctionResultObject> = Array(nbColumns)
660-
.fill([])
661-
.map(() => Array(nbRows).fill({ value: 0 })); // TODO fill with #N/A
662-
663-
let currentRow = 0;
664-
for (const range of ranges) {
665-
const _array = toMatrix(range);
666-
for (let col = 0; col < _array.length; col++) {
667-
for (let row = 0; row < _array[col].length; row++) {
668-
result[col][currentRow + row] = _array[col][row];
669-
}
670-
}
671-
currentRow += _array[0].length;
672-
}
673-
674-
return result;
742+
compute: function (...ranges: Arg[]) {
743+
return stackVertically(ranges);
675744
},
676745
isExported: true,
677746
} satisfies AddFunctionDescription;

0 commit comments

Comments
 (0)