Skip to content

Specialize entity index matching by field type; remove FieldValue#1343

Open
DZakh wants to merge 5 commits into
mainfrom
claude/stoic-hamilton-gyid2x
Open

Specialize entity index matching by field type; remove FieldValue#1343
DZakh wants to merge 5 commits into
mainfrom
claude/stoic-hamilton-gyid2x

Conversation

@DZakh

@DZakh DZakh commented Jun 24, 2026

Copy link
Copy Markdown
Member

Why

In-memory entity indices (getWhere / loadByField) matched every entity write against every registered filter through EntityFilter.matches, which routed all comparisons through FieldValue — an option<@unboxed variant> wrapper whose eq/gt/lt fell back to the polymorphic Caml_obj path for everything except BigDecimal. That comparison runs in updateIndices, an O(entities × registered filters) loop on the hot write path, so the polymorphic dispatch and structural-compare overhead showed up per comparison.

We already know each field's exact type from the table config, so the comparison can be resolved once per filter instead of re-dispatched per entity.

What

  • Removed the FieldValue module entirely.
  • Single match implementation: makeMatcher(~filter, ~table) replaces both the generic recursive matches and the earlier per-filter builder. It resolves each field's comparison once from fieldType into a small valueCompare record reused for every entity write. Entity dicts are now dict<unknown>.
  • Per-type specialization:
    • scalars (String/Enum/Entity-id/int family/BigInt/Bool) → native === / > / <
    • BigDecimal → bignumber.js methods
    • DategetTime() numeric compare
    • Json → structural deep compare (no native ordering exists)
    • array-valued fields → element-wise equality + lexicographic ordering built from the element comparator
    • a nullish entity value matches nothing, mirroring SQL NULL semantics and the Postgres-side WHERE
  • Cache-key serialization moved from FieldValue.toString to a standalone serializeValue helper, off the hot path (toString is the LoadManager hasher and has no table).
  • Migrated the remaining matches callers (InMemoryTable index maintenance, TestIndexer, MockIndexer) to makeMatcher.

Behavior change (intentional)

Filtering a nullable field with _gt / _lt where a row's value is null now yields no match (SQL-correct), instead of the previous undefined-ish Caml_obj ordering against null. This aligns in-memory matching with what Postgres returns for the same query.

Tests

scenarios/test_codegen/test/EntityFilter_test.res:

  • makeMatcher truth table — 17 filter cases × 3 rows covering every field kind, all operators, nullable-undefined, array equality + lexicographic ordering, Date eq/gt, and And.
  • nullish value on BigDecimal / Date / array fields → no crash, no match.
  • Json fields compared structurally (distinct object, equal contents → match).
  • empty And throws.
  • toString cache-key serialization across string/int/bigint/bool/bigdecimal/array/And.

Verified: pnpm rescript clean; EntityFilter_test 13/13; WriteRead_test (getWhere end-to-end through the index + MockIndexer) green.

🤖 Generated with Claude Code

https://claude.ai/code/session_0158A7T3rrBeU3xhaVEPwGUU


Generated by Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved filter matching so results are compared more consistently across numbers, booleans, dates, arrays, nullable values, and JSON fields.
    • Fixed in-memory indexing so loaded and cached results stay in sync when filters are applied or removed.
    • Made empty-filter handling more reliable, including clearer error behavior for invalid filter groups.
  • New Features

    • Added more stable filter key generation, helping preserve consistent cached results across reloads.

claude added 3 commits June 23, 2026 14:18
Resolve each filter's comparison once from the table config when an index
is registered, replacing the per-write operator switch and polymorphic
Caml_obj comparison with native operators for primitive fields. Falls back
to the structural FieldValue comparison for BigDecimal, Json, Date, arrays,
and nullable ordering, where native ops would diverge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158A7T3rrBeU3xhaVEPwGUU
Resolve each indexed field's comparison once from the table's fieldType
instead of routing every entity comparison through the polymorphic
Caml_obj path. Collapse the generic matcher and the per-filter builder
into a single makeMatcher, drop the FieldValue wrapper, and serialize
cache keys with a standalone helper off the hot path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158A7T3rrBeU3xhaVEPwGUU
Add cases for nullish values on object-typed fields (no crash, no match),
structural Json comparison, the empty-And throw, and toString cache-key
serialization across every value type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158A7T3rrBeU3xhaVEPwGUU
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 44541bfe-d228-484e-8571-faf7943884b2

📥 Commits

Reviewing files that changed from the base of the PR and between 66b5063 and fccb84e.

📒 Files selected for processing (6)
  • packages/envio/src/InMemoryTable.res
  • packages/envio/src/LoadLayer.res
  • packages/envio/src/TestIndexer.res
  • packages/envio/src/db/EntityFilter.res
  • scenarios/test_codegen/test/EntityFilter_test.res
  • scenarios/test_codegen/test/helpers/MockIndexer.res
📝 Walkthrough

Walkthrough

EntityFilter.matches (using FieldValue casting) is removed and replaced with a makeMatcher(filter, ~table) function that resolves field types from the table schema once and returns a reusable predicate. InMemoryTable index entries are extended to cache that matcher. LoadLayer, TestIndexer, and MockIndexer are updated to supply the table argument, and new tests cover the matcher and toString serialization.

Changes

EntityFilter matcher refactor

Layer / File(s) Summary
EntityFilter: serializeValue, toString, and makeMatcher
packages/envio/src/db/EntityFilter.res
Replaces per-call FieldValue-based serialization and matching. Adds serializeValue for deterministic cache-key fragments, updates toString for Eq/Gt/Lt/In to use it, removes FieldValue.eq/gt/lt, and introduces matcher = dict<unknown> => bool plus makeMatcher(filter, ~table) with nullish handling, type-specialized scalar comparators (BigDecimal/Date/Json/primitives), lexicographic array comparison, and operator dispatch for all filter variants.
InMemoryTable: cached matcher in index entries
packages/envio/src/InMemoryTable.res
Expands filterWithRelatedIds from (filter, relatedEntityIds) to (filter, matcher, relatedEntityIds). Updates addEmptyIndex to require ~table and call makeMatcher, stores the matcher in filterIndices, and reworks updateIndices to evaluate filter membership via the cached matcher over entityAsDict. Adjusts tuple destructuring in deleteEntityFromIndices and getUnsafeOnIndex.
Call-site updates and test coverage
packages/envio/src/LoadLayer.res, packages/envio/src/TestIndexer.res, scenarios/test_codegen/test/helpers/MockIndexer.res, scenarios/test_codegen/test/EntityFilter_test.res
LoadLayer passes ~table=entityConfig.table to addEmptyIndex. TestIndexer precomputes a matcher via makeMatcher and applies it over entityAsDict. MockIndexer replaces EntityFilter.matches with makeMatcher(~table). New tests cover makeMatcher behavior across scalar, array, nullable, Date, JSON, nested And filters, plus toString stable serialization.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • enviodev/hyperindex#1303: Introduces the tagged EntityFilter.t operators and routes LoadLayer/storage through filter-based loadOrThrow; this PR extends EntityFilter.res by replacing the matches function introduced there with the new makeMatcher approach.
  • enviodev/hyperindex#1306: Modifies InMemoryTable.res to adopt filterIndices with EntityFilter.matches; this PR directly evolves that same index structure to cache matchers instead of calling matches per evaluation.
  • enviodev/hyperindex#1317: Modifies EntityFilter.res core filter-handling logic (hardening EntityFilter.merge), making it directly adjacent to this PR's replacement of the matches/FieldValue pipeline in the same file.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: field-type-specialized matching and removal of FieldValue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/envio/src/db/EntityFilter.res`:
- Around line 5-10: The `serializeValue` helper in `EntityFilter.res` is
producing colliding cache-key strings by mapping `null` and `undefined` to the
same value, using `toString()` for objects, and emitting raw strings inside
arrays. Update `serializeValue` so it encodes type information and escapes or
quotes string content deterministically for all supported values, including
nested arrays and plain objects, and make sure the new representation stays
stable for `getWhere` cache key generation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 36046220-36bc-425d-9eb2-96a1358506ab

📥 Commits

Reviewing files that changed from the base of the PR and between 62ccd15 and 66b5063.

📒 Files selected for processing (6)
  • packages/envio/src/InMemoryTable.res
  • packages/envio/src/LoadLayer.res
  • packages/envio/src/TestIndexer.res
  • packages/envio/src/db/EntityFilter.res
  • scenarios/test_codegen/test/EntityFilter_test.res
  • scenarios/test_codegen/test/helpers/MockIndexer.res

Comment on lines +5 to +10
let serializeValue: unknown => string = %raw(`function ser(v) {
if (v === undefined || v === null) return "undefined";
if (Array.isArray(v)) return "[" + v.map(ser).join(",") + "]";
if (typeof v === "object") return v.toString();
return String(v);
}`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make serializeValue collision-free.

This serializer is now the source of truth for filter cache keys, but it conflates distinct values: null and undefined both become "undefined", plain JSON objects become "[object Object]", and strings/array elements are emitted without escaping. That means different getWhere filters can hash to the same key and reuse the wrong in-memory index or grouped load result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/envio/src/db/EntityFilter.res` around lines 5 - 10, The
`serializeValue` helper in `EntityFilter.res` is producing colliding cache-key
strings by mapping `null` and `undefined` to the same value, using `toString()`
for objects, and emitting raw strings inside arrays. Update `serializeValue` so
it encodes type information and escapes or quotes string content
deterministically for all supported values, including nested arrays and plain
objects, and make sure the new representation stays stable for `getWhere` cache
key generation.

Read indexed fields off the entity with a @get_index binding instead of
casting it to a dict at every call site, so InMemoryTable, TestIndexer,
and MockIndexer pass the entity through unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158A7T3rrBeU3xhaVEPwGUU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants