Skip to content

Commit ee1371f

Browse files
authored
feat (database): array_slice formula (baserow#5062)
* feat: add array_slice() database formula function * fix(formula): handle NaN arguments in array_slice to prevent DB crash Wrap start/count args with handle_arg_being_nan before truncating to int, so expressions like tonumber('x') no longer cause "cannot convert NaN to integer" at the PostgreSQL level. NaN is treated as 0 for both arguments.
1 parent 3670bf4 commit ee1371f

9 files changed

Lines changed: 865 additions & 1 deletion

File tree

backend/src/baserow/contrib/database/formula/ast/function_defs.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
GreaterThanOrEqualExpr,
8686
IsNullExpr,
8787
JSONBArrayJoinValues,
88+
JSONBArraySlice,
8889
JSONBArrayUniqueByValue,
8990
LessThanEqualOrExpr,
9091
LessThanExpr,
@@ -263,6 +264,7 @@ def register_formula_functions(registry):
263264
registry.register(BaserowArrayUnique())
264265
registry.register(BaserowArrayLength())
265266
registry.register(BaserowArrayJoinValues())
267+
registry.register(BaserowArraySlice())
266268
# ManyToMany functions
267269
registry.register(BaserowStringAggManyToManyValues())
268270
registry.register(BaserowManyToManyCount())
@@ -2498,6 +2500,113 @@ def to_django_expression(self, arg: Expression) -> Expression:
24982500
return JSONBArrayUniqueByValue(arg)
24992501

25002502

2503+
class BaserowArraySlice(ThreeArgumentBaserowFunction):
2504+
type = "array_slice"
2505+
arg1_type = [BaserowFormulaValidType]
2506+
arg2_type = [BaserowFormulaNumberType]
2507+
arg3_type = [BaserowFormulaNumberType]
2508+
2509+
def type_function(
2510+
self,
2511+
func_call: BaserowFunctionCall[UnTyped],
2512+
arg1: BaserowExpression[BaserowFormulaValidType],
2513+
arg2: BaserowExpression[BaserowFormulaNumberType],
2514+
arg3: BaserowExpression[BaserowFormulaNumberType],
2515+
) -> BaserowExpression[BaserowFormulaType]:
2516+
if arg1.many:
2517+
arg1 = arg1.expression_type.collapse_many(arg1)
2518+
2519+
if not isinstance(arg1.expression_type, BaserowFormulaArrayType):
2520+
return func_call.with_invalid_type("array_slice requires an array input.")
2521+
2522+
return func_call.with_args([arg1, arg2, arg3]).with_valid_type(
2523+
arg1.expression_type
2524+
)
2525+
2526+
def to_django_expression(
2527+
self, arg1: Expression, arg2: Expression, arg3: Expression
2528+
) -> Expression:
2529+
either_nan = EqualsExpr(
2530+
arg2, Value(Decimal("NaN")), output_field=fields.BooleanField()
2531+
) | EqualsExpr(arg3, Value(Decimal("NaN")), output_field=fields.BooleanField())
2532+
2533+
start_int = trunc_numeric_to_int(arg2)
2534+
count_int = trunc_numeric_to_int(arg3)
2535+
abs_count = Func(count_int, function="ABS", output_field=fields.IntegerField())
2536+
2537+
is_reverse = LessThanExpr(
2538+
count_int, Value(0), output_field=fields.BooleanField()
2539+
)
2540+
2541+
array_len = Func(
2542+
arg1, function="jsonb_array_length", output_field=fields.IntegerField()
2543+
)
2544+
2545+
# Resolve negative start to a 0-based position
2546+
resolved_start = Case(
2547+
When(
2548+
condition=GreaterThanOrEqualExpr(
2549+
start_int, Value(0), output_field=fields.BooleanField()
2550+
),
2551+
then=start_int,
2552+
),
2553+
default=Greatest(
2554+
ExpressionWrapper(
2555+
array_len + start_int, output_field=fields.IntegerField()
2556+
),
2557+
Value(0),
2558+
),
2559+
output_field=fields.IntegerField(),
2560+
)
2561+
2562+
# Forward: offset = resolved_start
2563+
# Backward: offset = max(0, resolved_start - abs_count + 1)
2564+
offset_expr = Case(
2565+
When(
2566+
condition=is_reverse,
2567+
then=Greatest(
2568+
ExpressionWrapper(
2569+
resolved_start - abs_count + Value(1),
2570+
output_field=fields.IntegerField(),
2571+
),
2572+
Value(0),
2573+
),
2574+
),
2575+
default=resolved_start,
2576+
output_field=fields.IntegerField(),
2577+
)
2578+
2579+
# Forward: 0 → NULL (all remaining), else count
2580+
# Backward: abs(count) — but clamped to (resolved_start + 1)
2581+
# so we don't go past the beginning
2582+
limit_expr = Case(
2583+
When(
2584+
condition=is_reverse,
2585+
then=Least(
2586+
abs_count,
2587+
ExpressionWrapper(
2588+
resolved_start + Value(1),
2589+
output_field=fields.IntegerField(),
2590+
),
2591+
),
2592+
),
2593+
When(
2594+
condition=EqualsExpr(
2595+
count_int, Value(0), output_field=fields.BooleanField()
2596+
),
2597+
then=Value(None),
2598+
),
2599+
default=count_int,
2600+
output_field=fields.IntegerField(),
2601+
)
2602+
2603+
return Case(
2604+
When(condition=either_nan, then=Value([], output_field=JSONField())),
2605+
default=JSONBArraySlice(arg1, offset_expr, limit_expr, is_reverse),
2606+
output_field=JSONField(),
2607+
)
2608+
2609+
25012610
class BaserowArrayLength(OneArgumentBaserowFunction):
25022611
type = "array_length"
25032612
arg_type = [BaserowFormulaArrayType]

backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,65 @@ def as_sql(self, compiler, connection, **extra_context):
163163
return sql, (*separator_params, *params)
164164

165165

166+
class JSONBArraySlice(Expression):
167+
"""
168+
Slice a JSONB array with offset, limit, and optional reverse.
169+
170+
All parameters should be pre-computed Django expressions:
171+
- offset_expr / limit_expr: the forward window (limit may be NULL for "all")
172+
- reverse_expr: boolean — when true, output order is reversed
173+
"""
174+
175+
def __init__(self, array_expr, offset_expr, limit_expr, reverse_expr):
176+
super().__init__(output_field=JSONField())
177+
self.array_expr = array_expr
178+
self.offset_expr = offset_expr
179+
self.limit_expr = limit_expr
180+
self.reverse_expr = reverse_expr
181+
182+
def resolve_expression(
183+
self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False
184+
):
185+
c = self.copy()
186+
c.is_summary = summarize
187+
c.array_expr = self.array_expr.resolve_expression(
188+
query, allow_joins, reuse, summarize, for_save
189+
)
190+
c.offset_expr = self.offset_expr.resolve_expression(
191+
query, allow_joins, reuse, summarize, for_save
192+
)
193+
c.limit_expr = self.limit_expr.resolve_expression(
194+
query, allow_joins, reuse, summarize, for_save
195+
)
196+
c.reverse_expr = self.reverse_expr.resolve_expression(
197+
query, allow_joins, reuse, summarize, for_save
198+
)
199+
return c
200+
201+
def as_sql(self, compiler, connection):
202+
array_sql, array_params = compiler.compile(self.array_expr)
203+
offset_sql, offset_params = compiler.compile(self.offset_expr)
204+
limit_sql, limit_params = compiler.compile(self.limit_expr)
205+
reverse_sql, reverse_params = compiler.compile(self.reverse_expr)
206+
207+
sql = (
208+
"(SELECT COALESCE(jsonb_agg(sub.elem ORDER BY " # noqa: S608
209+
f"CASE WHEN {reverse_sql} THEN -sub.rn ELSE sub.rn END"
210+
"), '[]'::jsonb) "
211+
"FROM (SELECT t.elem, t.rn "
212+
f"FROM jsonb_array_elements({array_sql}) WITH ORDINALITY AS t(elem, rn) "
213+
"ORDER BY t.rn "
214+
f"OFFSET {offset_sql} "
215+
f"LIMIT {limit_sql}) sub)"
216+
)
217+
return sql, (
218+
list(reverse_params)
219+
+ list(array_params)
220+
+ list(offset_params)
221+
+ list(limit_params)
222+
)
223+
224+
166225
class BaserowFilterExpression(Expression):
167226
"""
168227
Baserow expression that works with field_name and value

0 commit comments

Comments
 (0)