Skip to content
Closed
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
32 changes: 32 additions & 0 deletions .changeset/10813-typed-empty-null-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@object-ui/fields': patch
---

fix(fields): "Is empty" / "Is not empty" no longer send an empty-string comparand to a numeric or boolean column

`FilterConditionField` (the `filter-condition` widget behind `relatedListFilter`,
a roll-up's `summaryOperations.filter` and `sys_sharing_rule.criteria_json`)
wrote "Is empty" as `{ $or: [{ FIELD: { $in: [''] } }, { FIELD: { $null: true } }] }`
and "Is not empty" as `{ FIELD: { $nin: [''], $null: false } }` on every field
type, number included. On a numeric or boolean column the SQL driver binds that
`''` as-is, so it reached the column as `IN ('')` / `NOT IN ('')` — a comparand a
strict backend has to cast.

A field whose type is in `@objectstack/spec`'s numeric or boolean value class
(`number`, `currency`, `percent`, `rating`, `slider`, `progress`, `summary`,
`boolean`, `toggle`) now gets the null half alone:

- "Is empty": `{ FIELD: { $null: true } }`
- "Is not empty": `{ FIELD: { $null: false } }`

Every other column keeps the shapes above byte for byte: text, select, lookup and
the rest of the string-stored types, and a field whose type the widget does not
know, because there `''` is a value a record can hold. `date`, `datetime` and
`time` keep them too: an edit form sends a cleared date, date-time or time box as
`''`, and on a backend that stores those columns as text the value is kept, so
dropping the member would change which records a rule matches there.

A criteria saved in an older shape on a numeric or boolean column still opens as
the same "Is empty" / "Is not empty" row and is rewritten only when an admin
edits it; the new shape reopens under the "Is null" / "Is not null" label it
shares with those operators.
67 changes: 64 additions & 3 deletions packages/fields/src/widgets/FilterConditionField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { FilterBuilder, cn } from '@object-ui/components';
import { SchemaRendererContext } from '@object-ui/react';
import { NUMERIC_VALUE_TYPES, BOOLEAN_VALUE_TYPES } from '@objectstack/spec/data';
import type { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
Expand Down Expand Up @@ -218,11 +219,59 @@ function toArray(value: any): any[] {
* rather than the field, so it merges into an AND group beside field keys and
* {@link kvToCondition} reads it back as one row. Two such rows collide on
* `$or` and fall to the `$and` form, as any two rows on one key already do.
*
* Written only for a column whose stored value can be the empty string — see
* {@link NON_STRING_VALUE_TYPES} for the columns that get `$null` alone
* (objectui#10813).
*/
function isEmptyEntry(field: string): Record<string, unknown> {
return { $or: [{ [field]: { $in: [''] } }, { [field]: { $null: true } }] };
}

/**
* Field types whose stored value is never a string, so the `''` member of
* "is empty" / "is not empty" has no row it could match and must not be sent
* (objectui#10813).
*
* The protocol's own value classes (`@objectstack/spec/data`, ADR-0104),
* asked rather than copied: numeric and boolean — a finite number and a JS
* boolean on the wire. On these columns the SQL driver binds the comparand
* as-is (its column-type gate covers JSON columns only), so `''` reached an
* `integer`, `decimal` or `boolean` column as `IN ('')` / `NOT IN ('')`, which
* a strict backend has to cast. For them "is empty" is `$null: true` and
* "is not empty" is `$null: false`: the same rows, with no `''` to cast. No
* form writes `''` into them either — a cleared number box is sent as `null`.
*
* ⛔ Deliberately NOT a list of "string types" with everything else falling to
* `$null`. A column absent from this set keeps the `''` member byte for byte:
*
* - text, select, lookup and every other string-stored column, because there
* `''` is a value a record can really hold — an edit form sends a cleared
* text box as `''`, and the platform stores it unchanged — so dropping the
* member would change which records a stored sharing rule matches;
* - `date`, `datetime` and `time`, for the same reason on some backends: the
* spec calls their stored form a dialect question (ISO TEXT on SQLite, a
* native type on Postgres), and an edit form sends a cleared date, date-time
* or time box as `''`, which the platform stores unchanged where the column
* is text. Which way to settle that is objectui#10813's open question;
* - a field whose type this widget does not know (the schema has not loaded,
* or the field is hidden or not in it), because guessing a type here would
* re-scope a rule the admin can see on screen.
*
* ⛔ Not `NON_TEXT_STORED_VALUE_TYPES`, although it is exactly these two classes
* in the spec release this package installs: that set is the text-operator
* gate's, and the spec's main line has already widened it to the temporal
* classes by ruling rather than by storage — adopting it would move the
* temporal columns onto `$null` on a spec upgrade, with no change here.
*
* Whether the platform's one meaning of "is empty" keeps `''` at all is
* objectui#10813's open question, and not this set's to answer.
*/
const NON_STRING_VALUE_TYPES: ReadonlySet<string> = new Set([
...NUMERIC_VALUE_TYPES,
...BOOLEAN_VALUE_TYPES,
]);

function isPlainObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
Expand Down Expand Up @@ -313,10 +362,22 @@ export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string |
// "Is not empty" is its exact complement — has a value (`$null: false`,
// the refusal's own "has a value" half) AND that value is not `''` — on one
// field key, so it reads back through the ordinary two-operator arm.
case 'is_empty': return isEmptyEntry(field);
case 'is_not_empty': return { [field]: { $nin: [''], $null: false } };
//
// objectui#10813 — the `''` member only where a stored value can be `''`:
// a numeric or boolean column gets the `$null` half alone (see
// {@link NON_STRING_VALUE_TYPES}). Those two shapes are the ones `is_null`
// / `is_not_null` write, so a reopened rule reads them back under those
// labels — the same predicate on such a column. A rule stored in the older
// shapes still reads back as "is empty" and is rewritten only when an admin
// edits it.
case 'is_empty':
return t !== undefined && NON_STRING_VALUE_TYPES.has(t) ? { [field]: { $null: true } } : isEmptyEntry(field);
case 'is_not_empty':
return t !== undefined && NON_STRING_VALUE_TYPES.has(t)
? { [field]: { $null: false } }
: { [field]: { $nin: [''], $null: false } };
// Null / existence spec operators. Distinct from is_empty/is_not_empty,
// which also treat '' as empty.
// which also treat '' as empty on a column that can store it.
case 'is_null': return { [field]: { $null: true } };
case 'is_not_null': return { [field]: { $null: false } };
case 'exists': return { [field]: { $exists: true } };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@
*
* Four blocks:
*
* 1. WRITER — each operator on each field type that offers it (text, number,
* date, select, lookup: `operatorsForFieldType` in `@object-ui/components`),
* driven through the REAL dropdowns. The `equals` rows are the control:
* the same harness and the same faces, green before and after.
* 1. WRITER — each operator on each field type that offers it and can store
* `''` (text, date, select, lookup: `operatorsForFieldType` in
* `@object-ui/components`), driven through the REAL dropdowns. The `equals`
* rows are the control: the same harness and the same faces, green before
* and after, on the number column too. A number column gets the `$null`
* half alone since objectui#10813, pinned in
* `FilterConditionField.typedEmpty-10813.test.tsx`.
* 2. READER — the old shape AND the new shape each open as the same single
* builder row, and opening one emits nothing (no rewrite on read alone).
* 3. RE-SAVE — an old rule is written in the new shape the next time any row
Expand Down Expand Up @@ -153,6 +156,12 @@ const TYPES: ReadonlyArray<{ type: string; field: string; label: string }> = [
{ type: 'lookup', field: 'account', label: 'Account' },
];

/**
* The columns whose stored value can be `''`, so they keep the `''` member.
* A number column gets `$null` alone since objectui#10813.
*/
const STRING_STORED_TYPES = TYPES.filter(({ type }) => type !== 'number');

/** A fresh row on `label`'s column, still on the seed operator (`equals`). */
async function freshRowOn(label: string) {
const utils = renderWidget('');
Expand All @@ -162,15 +171,15 @@ async function freshRowOn(label: string) {
}

describe('WRITER — each operator on each offered field type writes the accepted shape (objectui#10790)', () => {
it.each(TYPES)('"Is empty" on a $type column', async ({ field, label }) => {
it.each(STRING_STORED_TYPES)('"Is empty" on a $type column', async ({ field, label }) => {
const { onChange } = await freshRowOn(label);
await pickFrom(1, 'Is empty');
const stored = lastEmitted(onChange);
expect(stored).toBe(JSON.stringify(isEmpty(field)));
expectAcceptedByTheFaces(stored);
});

it.each(TYPES)('"Is not empty" on a $type column', async ({ field, label }) => {
it.each(STRING_STORED_TYPES)('"Is not empty" on a $type column', async ({ field, label }) => {
const { onChange } = await freshRowOn(label);
await pickFrom(1, 'Is not empty');
const stored = lastEmitted(onChange);
Expand Down
Loading
Loading