Skip to content

objectui: FilterConditionField cannot author spec’s $icontains — the case-insensitive contains is unreachable from the filter UI #4023

Description

@os-zhuang

Migrated from objectstack-ai/objectstack#6337 under the file-at-destination ruling (objectstack-ai/objectstack#7167, maintainer 2026-08-10). Originally filed 2026-08-07T14:06:06Z. The full prior thread — including triage rulings and hold/restart conditions — remains on the source issue and MUST be read before acting on this card.


Part of #3567 (transferred to the main backlog by the triage seat — feature-level work lives in objectstack whatever repo the code lands in; see the multi-repo coordination rule "One main backlog").

Blocked-by: objectstack-ai/objectstack#5702

What is missing

packages/spec's FieldOperatorsSchema gained $icontains (case-insensitive contains, spec describe: "folds ASCII case") between 17.0.0-rc.2 and rc.5. objectui's FilterConditionField has no builder operator that can author it, so the capability is unreachable from the filter UI. The existing bidirectional parity test in packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts ("every spec field operator is reachable from the builder", objectui#2942) went red on the rc.5 bump and was handled by objectui#3560 as an explicit, ratcheted KNOWN_UNREACHABLE entry rather than a silent exclusion.

Deliverable (lands in objectui)

  1. Add a case-insensitive contains operator to the FilterConditionField builder vocabulary; condToMongo emits { field: { $icontains: value } }.
  2. kvToCondition reads it back — without the reverse mapping a saved filter view degrades on reopen.
  3. Ten locale packs gain the label key (the t() call-site key gate, objectui#3547).
  4. Remove the $icontains entry from KNOWN_UNREACHABLE in FilterConditionField.operators.test.ts; the parity assertion then takes over, and objectui#3560's ratchet simultaneously validates that the removal was legitimate.

Why this is blocked, and it is not the reason the filing issue gave

⚠️ The originating issue states the server already accepts $icontains. Half true, and the half that is false is the blocking half. Measured against objectstack origin/main:

So the spec vocabulary accepts the token while no driver can execute it. Shipping the builder operator first would put a filter in users' hands that every backend fails — strictly worse than the capability being absent. This card returns to the queue when objectstack-ai/objectstack#5702 merges.

Open product question (default recorded, not blocking)

Should today's contains have been case-insensitive all along? If so, the better shape is to have contains emit $icontains and add an explicit containsCaseSensitive for $contains — but that changes the behaviour of stored filter views, so it needs the maintainer. Default for this card: add a new operator, leave contains semantics untouched. A dev implements the default; if the maintainer wants the flip, say so here first and this card is re-scoped (it would then move to the decision inbox).

Provenance

Measured during objectui#3560 (@objectstack/* family bump to 17.0.0-rc.5). Not a regression introduced by that bump — a capability gap it exposed. Original filing: #3567.


Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 15, 2026
  2. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    Collaborator

    Dispatched by the repo:objectui PM seat (session session_01RnQd8iMMUwXQEV1crFmQiQ), branch claude/issue-4023-icontains-builder-operator — unblocked: the card's stated return-to-queue condition has been met.

    Blocker re-check (objectstack origin/main, today). objectstack#5702 closed completed 2026-08-08 (PR objectstack#6549: SQL family — sql/turso/sqlite-wasm), and objectstack#6520 ("the other half of #5702") landed $icontains on every JS evaluation face including driver-memory (memory-matcher.ts, memory-icontains.test.ts) and driver-mongodb (mongodb-filter.ts, mongodb-icontains.test.ts). No backend now fails the operator — the "filter every backend fails" blocking condition is gone.

    Adjudication. Implement the card's recorded default exactly: a new case-insensitive contains operator; today's contains semantics untouched. The open product question (whether contains should flip to case-insensitive) stays with the maintainer — not this card, not this PR. Semantics note for labels/tests: the casefold is ASCII-only by deliberate contract decision (objectstack#4706 Q1 = A; café does not match CAFÉ) — align any operator help text with how existing operators describe themselves, and don't invent a new help surface.

    Mechanism assumptions. FieldOperatorsSchema has carried $icontains since 17.0.0-rc.5; the parity test FilterConditionField.operators.test.ts holds it as a ratcheted KNOWN_UNREACHABLE entry (objectui#3560), so deliverable 4 (remove the entry) makes the parity assertion take over and simultaneously validates the removal.

    Suggested route. The card's four deliverables in order: builder operator; condToMongo emits { field: { $icontains: value } }; kvToCondition reads it back (round-trip test — a saved filter view must not degrade on reopen); ten-pack label key per the t() call-site key gate; KNOWN_UNREACHABLE entry removed. Changeset: fields patch (+ i18n patch). No skip-changeset mechanism exists in objectui. Note: #4028 is dispatched concurrently and may also touch the ten packs in a different namespace — on queue conflict, rebase.


    Generated by Claude Code

  3. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    Collaborator
    {
      "issue": 4023,
      "branch": "claude/issue-4023-icontains-builder-operator",
      "pr_number": 4737,
      "head_sha": "67f441280",
      "status": "done",
      "premise_still_valid": true,
      "mechanism_assumptions_checked": [
        "PM's blocker re-check CONFIRMED at the pinned dependency, not just on the issue tree: @objectstack/spec@17.0.0-rc.6's StringOperatorSchema docblock states $icontains is executable on every backend and evaluation face (objectstack#5702 SQL family + objectstack#6520 JS faces), and names the ASCII-only fold (objectstack#4706 Q1 = A, 'café' does NOT match 'CAFÉ').",
        "FieldOperatorsSchema.shape read at runtime: 16 keys, $icontains present. KNOWN_UNREACHABLE's ratchet premise held.",
        "The parity test's ratchet list ($eq/$between/$icontains) was a SECOND hand-written copy of KNOWN_UNREACHABLE: removing the entry from the Set alone would have left the ratchet vouching for it and still passing. Hoisted to one module-scope Set; the ratchet now iterates it.",
        "VIEW_FILTER_OPERATORS (19 members) has NO icontains, and no alias folds onto one — so view-operator-builder-parity.test.ts is unaffected by the new builder id (no canonical operator folds to 'containscaseinsensitive'). Verified by running the suite: green.",
        "MEASURED, and it changed the implementation: the FilterBuilder dropdown is SHARED by three consumers with three at-rest dialects. FilterConditionField writes MongoDB-style criteria the server engine evaluates (can carry $icontains); viewFilterFold writes ViewFilterRule over VIEW_FILTER_OPERATORS and ListView sends the array AST over VALID_AST_OPERATORS — neither vocabulary has any case-insensitive contains. Adding the operator to the one global list would have made it selectable in the list-filter popover, where mapOperator emits it verbatim and, per this repo's own filter-operator-ast-parity.test.ts, the query comes back unfiltered or 400. That is the exact hazard #4023 was blocked over, relocated from the drivers to objectui's own bridges. Shipped opt-in per consumer instead (FilterBuilder.extraOperators); root cause filed upstream.",
        "convertOperatorToAST('$icontains') returns null, so convertFiltersToAST throws FilterOperatorError (INVALID_FILTER/400) — the downstream validator FilterConditionField's own docblock names. Confirmed the card's path never reaches it: the criteria is stored as JSON on sys_sharing_rule.criteria_json and evaluated server-side, never lowered through objectui's array AST.",
        "Ten locale packs verified individually for quote style (en/zh single, other eight double) and placement inside filterBuilder.operators; check-i18n-en-drift reports '1 key added, 0 en values changed', so the drift gate correctly abstains and all-locales-key-parity owns it.",
        "objectui has no skip-changeset mechanism — a changeset was written instead (components + fields + i18n, all patch), and check-changeset-presence confirms 14 guarded source files against 1 declaration."
      ],
      "gates": {
        "build_closure": "pnpm --filter '@object-ui/components^...' --filter '@object-ui/fields^...' build — green (run FIRST, before any judgement)",
        "fields+i18n tests": "vitest run packages/i18n packages/fields — 136 files, 2224 tests passed (includes FilterConditionField.operators.test.ts and all-locales-key-parity)",
        "components+plugin-view tests": "vitest run packages/components packages/plugin-view — 142 files, 1232 tests passed (includes view-operator-builder-parity and the new filter-builder-opt-in-operators)",
        "type-check": "components/fields/i18n — Done",
        "type-check downstream sweep": "app-shell/plugin-list/plugin-view — Done. Direction is DOWNSTREAM consumers of @object-ui/components (the three that render FilterBuilder); first run failed on unbuilt plugin-grid/plugin-form dists, re-run green after building their closures",
        "lint": "components/fields/i18n — 0 errors (warnings pre-existing; changed files add none beyond the file's existing `as any` style)",
        "check-changeset-presence": "green — 14 source file(s) of 3 released packages, 1 changeset",
        "check-i18n-call-site-keys": "green — 2407/2407 literal keys resolve",
        "check-i18n-en-drift": "green — 0 en values changed (1 key added)",
        "check-i18n-dead-keys": "report-only, no new entries",
        "check-control-bytes": "green — 4250 tracked text files",
        "check-changeset-fixed / no-major / lint-coverage / type-check-coverage": "all green",
        "reverse_verification": "Predicted RED, observed RED. From the COMMITTED state, deleted the containsCaseInsensitive arm from condToMongo: 3 failures, including the parity sweep's `expected [ '$icontains' ] to deeply equal []` — the assertion deliverable 4 hands the ratchet. Restored with `git checkout <branch> -- <path>`; never stashed.",
        "ci": "in_progress — draft PR #4737 opened at 67f441280; per the dispatch contract the CI-convergence wait is the PM's"
      },
      "deviations": [
        "SCOPE ADDITION, deliberate and named in the PR body: the operator is opt-in per consumer rather than added to the one global FilterBuilder list. FilterBuilder gains an optional `extraOperators` prop and an OPT_IN_OPERATORS set; only FilterConditionField passes it, so every other FilterBuilder offers exactly what it offered before. Reason under the three axes — (1) real business need: the measured need is authoring case-insensitive sharing-rule criteria; there is no need in list views, whose dialect cannot even express it; (2) long-term soundness: the global list would knowingly widen a defect class that already cost objectstack#3948 an unfiltered query, and mapping onto plain `contains` in the bridges is a different question silently answered (the same collapse view-operator-builder-parity.test.ts refuses for is_null/isEmpty); (3) hard to get wrong: a dropdown row whose save path rejects it is exactly the lenient-consumer trap. The card's own wording ('the FilterConditionField builder vocabulary') reads consistently with this. If the maintainer prefers the global list, the change is one line plus deleting the OPT_IN_OPERATORS entry.",
        "Three small in-file improvements beyond the four deliverables, each named in the PR body: KNOWN_UNREACHABLE hoisted to module scope with the ratchet reading it (otherwise the ratchet's duplicate list keeps vouching for a removed entry); the parity sweep iterates FILTER_BUILDER_OPERATORS instead of a hand-written copy; getOperatorsForField's bucket selection extracted as the pure `operatorsForFieldType` so the opt-in gate is testable without a Radix popover in jsdom.",
        "The report template's cross-package reverse-verification ('paste a key the new type rejects') does not fit this change and was NOT fabricated: nothing was narrowed — `extraOperators` is an added optional prop and FilterBuilderOperator gained a member — so there is no key the new types reject. The ablation above is the reverse verification that does apply.",
        "No new help/tooltip surface for the ASCII-only fold, per the dispatch note: no existing operator has one, so the boundary is stated in code comments, the changeset and the PR body, and the label says 'ignore case' rather than promising an accent-blind search."
      ],
      "followups_filed": [
        "objectstack-ai/objectstack#8934: `$icontains` has no counterpart in VIEW_FILTER_OPERATORS or VALID_AST_OPERATORS, so the capability is authorable only in the MongoDB-style dialect — the root cause that made the opt-in design necessary. Filed in objectstack because the fix lands in packages/spec; unassigned, unlabeled, backlinked to #4023.",
        "objectstack-ai/objectui#4736: the list filter builder already offers `exists`/`notExists`, which ListView.mapOperator has no row for, so the id goes out verbatim and the query breaks. Pre-existing since #2942; this PR does not widen it, and the `extraOperators` mechanism it adds is one of the two candidate fixes. Unassigned, unlabeled."
      ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions