Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions backend/src/baserow/core/formula/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ def _transform_db_value_to_dict(
if context := self._deserialize_baserow_object(value):
# If we have, then we can parse it and return the `BaserowFormulaObject`
return BaserowFormulaObject(
mode=context["m"], version=context["v"], formula=context["f"]
mode=context["m"],
version=context["v"],
# Raw DB values can hold `"f": null`; clients expect a string.
formula=context["f"] or "",
)
elif isinstance(value, str):
# Otherwise, it's a raw formula string, which we can wrap in a
Expand Down Expand Up @@ -129,7 +132,7 @@ def _transform_db_value_to_dict(
)

return BaserowFormulaObject(
mode=value["m"], version=value["v"], formula=value["f"]
mode=value["m"], version=value["v"], formula=value["f"] or ""
)

def contribute_to_class(self, cls, name, **kwargs):
Expand Down Expand Up @@ -293,16 +296,21 @@ def _transform_db_property(
:return: A `BaserowFormulaObject`.
"""

# Legacy rows written before the formula-object migration can hold
# `null` at a formula path, and older write paths could persist a null
# `f`; clients expect `formula` to always be a string, so coerce it
# while reading. Only the formula is coerced on purpose: a missing mode
# or version is not guessed here.
if not isinstance(value, dict):
return BaserowFormulaObject(
mode=BASEROW_FORMULA_MODE_SIMPLE,
version=BASEROW_FORMULA_VERSION_INITIAL,
formula=value,
formula=value or "",
)
return BaserowFormulaObject(
mode=value.get("m", value.get("mode")),
version=value.get("v", value.get("version")),
formula=value.get("f", value.get("formula")),
formula=value.get("f", value.get("formula")) or "",
)

def _transform_db_properties(
Expand Down Expand Up @@ -414,16 +422,18 @@ def _transform_python_property(
:return: A `BaserowFormulaMinified`.
"""

# Coerce `None` values so that non-serializer write paths (direct ORM
# saves, application import) can never persist a null formula.
if not isinstance(value, dict):
return BaserowFormulaMinified(
m=BASEROW_FORMULA_MODE_SIMPLE,
v=BASEROW_FORMULA_VERSION_INITIAL,
f=value,
f=value or "",
)
return BaserowFormulaMinified(
m=value.get("mode", BASEROW_FORMULA_MODE_SIMPLE),
v=value.get("version", BASEROW_FORMULA_VERSION_INITIAL),
f=value.get("formula", ""),
f=value.get("formula") or "",
)

def get_prep_value(
Expand Down
90 changes: 89 additions & 1 deletion backend/tests/baserow/core/formula/test_formula_field.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
from baserow.core.formula.field import FormulaField, JSONFormulaField
from baserow.core.formula.field import (
BASEROW_FORMULA_VERSION_INITIAL,
FormulaField,
JSONFormulaField,
)
from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE


def test_json_formula_field_get_prep_value_does_not_mutate_input():
Expand Down Expand Up @@ -52,6 +57,89 @@ def test_json_formula_field_get_prep_value_does_not_mutate_input():
}


def test_json_formula_field_transform_db_property_coerces_null_formula():
"""
Legacy rows written before the formula-object migration can hold `null` at
a formula path, and older write paths could persist a null `f`. These must
be read back as `formula: ""`, never `formula: None`, because clients expect
a string. A missing mode or version is deliberately not defaulted.
"""

field = JSONFormulaField(properties=["value"])
expected = {
"formula": "",
"mode": BASEROW_FORMULA_MODE_SIMPLE,
"version": BASEROW_FORMULA_VERSION_INITIAL,
}
minified_with_null_formula = {
"f": None,
"m": BASEROW_FORMULA_MODE_SIMPLE,
"v": BASEROW_FORMULA_VERSION_INITIAL,
}

assert field._transform_db_property(None) == expected
assert field._transform_db_property(minified_with_null_formula) == expected


def test_json_formula_field_transform_db_properties_coerces_legacy_null_value():
field = JSONFormulaField(properties=["value"])

result = field._transform_db_properties([{"name": "id", "value": None}])

assert result == [
{
"name": "id",
"value": {
"formula": "",
"mode": BASEROW_FORMULA_MODE_SIMPLE,
"version": BASEROW_FORMULA_VERSION_INITIAL,
},
}
]


def test_json_formula_field_get_prep_value_coerces_null_formula():
"""
Non-serializer write paths (direct ORM saves, application import) must not
be able to persist a null formula.
"""

field = JSONFormulaField(properties=["value"])
expected = [
{
"name": "id",
"value": {
"f": "",
"m": BASEROW_FORMULA_MODE_SIMPLE,
"v": BASEROW_FORMULA_VERSION_INITIAL,
},
}
]
object_with_null_formula = {
"formula": None,
"mode": BASEROW_FORMULA_MODE_SIMPLE,
"version": BASEROW_FORMULA_VERSION_INITIAL,
}

assert field.get_prep_value([{"name": "id", "value": None}]) == expected
assert (
field.get_prep_value([{"name": "id", "value": object_with_null_formula}])
== expected
)


def test_formula_field_transform_db_value_coerces_null_formula():
field = FormulaField()

result = field._transform_db_value_to_dict('{"m": "simple", "v": "0.1", "f": null}')

assert result == {
"formula": "",
"mode": BASEROW_FORMULA_MODE_SIMPLE,
"version": BASEROW_FORMULA_VERSION_INITIAL,
}


def test_deserialize_baserow_object_valid():
field = FormulaField()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Fixed a crash when clicking in a formula field that has a null value.",
"issue_origin": "github",
"issue_number": null,
"domain": "core",
"bullet_points": [],
"created_at": "2026-08-24"
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ const nodesHierarchy = computed(() => {
* @returns {String} The formula string.
*/
const formulaStr = computed(() => {
return props.modelValue?.formula
// Legacy stored values can contain `formula: null`; `FormulaInputField`
// expects a string.
return props.modelValue?.formula || ''
})

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ const nodesHierarchy = computed(() => {
* @returns {String} The expression string.
*/
const formulaStr = computed(() => {
return currentValue.value.formula
// Legacy stored values can contain `formula: null`; `FormulaInputField`
// expects a string.
return currentValue.value.formula || ''
})

const dataSourceLoading = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
:node-selected="nodeSelected"
:loading="loading"
:mode="mode"
:has-value="value.length > 0"
:has-value="Boolean(value && value.length)"
:allow-node-selection="allowNodeSelection"
:nodes-hierarchy="nodesHierarchy"
:enabled-modes="enabledModes"
Expand Down Expand Up @@ -642,7 +642,9 @@ export default {
// this.wrapperContent can be stale content, so get the data
// directly from the editor.
const editorContent = this.editor.getJSON()
const formula = this.toFormula(editorContent)
// `toFormula` returns null when the content cannot be serialized; never
// emit null upstream, parents store it and feed it back as `value`.
const formula = this.toFormula(editorContent) ?? ''

if (this.validateFormula(formula)) {
this.$emit('input', formula)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export default {
)
},
formulaStr() {
return this.currentValue.formula
// Legacy stored values can contain `formula: null`; `FormulaInputField`
// expects a string.
return this.currentValue.formula || ''
},
applicationContext() {
return this.databaseFormulaContext || {}
Expand Down
41 changes: 41 additions & 0 deletions web-frontend/test/unit/core/formula/FormulaInputField.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,44 @@ describe('FormulaInputField example insertion', () => {
expect(wrapper.emitted('input')).toBeUndefined()
})
})

// ── Null value tolerance ────────────────────────────────────────────
// Legacy stored data can contain `formula: null`, which parents used to pass
// straight through as the `value` prop. Focusing the field then rendered the
// explorer context, whose `:has-value="value.length > 0"` binding threw
// "Cannot read properties of null (reading 'length')".

describe('FormulaInputField tolerates a null value', () => {
let testApp = null

beforeEach(() => {
testApp = new TestApp()
})

afterEach(() => {
testApp.afterEach()
})

it('opens the explorer context on focus instead of crashing', async () => {
const wrapper = await testApp.mount(FormulaInputField, {
props: { value: null, mode: 'simple' },
})
wrapper.vm.isFocused = true
await wrapper.vm.$nextTick()

expect(
wrapper.findComponent({ name: 'FormulaInputExplorerContext' }).exists()
).toBe(true)
})

it('emits an empty string instead of null when serialization fails', async () => {
const wrapper = await testApp.mount(FormulaInputField, {
props: { value: '', mode: 'simple' },
})
vi.spyOn(wrapper.vm, 'toFormula').mockReturnValue(null)

wrapper.vm.emitChange()

expect(wrapper.emitted('input').at(-1)).toEqual([''])
})
})
Loading