Skip to content

Add JSON Logic string + array operators#3485

Draft
ajpallares wants to merge 4 commits into
pallares/json-logic-evaluatorfrom
pallares/json-logic-string-array-operators
Draft

Add JSON Logic string + array operators#3485
ajpallares wants to merge 4 commits into
pallares/json-logic-evaluatorfrom
pallares/json-logic-string-array-operators

Conversation

@ajpallares
Copy link
Copy Markdown
Member

@ajpallares ajpallares commented May 14, 2026

Resolves SDK-4332

Motivation

Implements in, cat, substr, merge, and missing_some so v1 targeting predicates can express string-content, array-membership, and "any N of these fields populated" checks.

Summary

  • New StringArrayOperators covers in, cat, substr, and merge.
  • opMissingSome lives next to opMissing in AccessorOperators — it reuses the same dot-path lookup, so co-locating beats spawning a parallel module.
  • Operators dispatch table extended with all five operators.

Behavior notes (deviations from the JSON Logic JS reference)

Both deviations are documented in type-level KDocs and unit-tested:

  • in array membership uses looseEq instead of strict ===. Rule authors regularly write integer literals against backend-supplied string lists ({"in": [{"var": "tier_id"}, ["1", "2", "3"]]} etc.); strict equality would silently fail those, loose equality matches the rest of our equality story.
  • substr slices by Unicode code points, not UTF-16 code units. Matches Kotlin's String.codePointCount semantics and gives the intuitive answer for multibyte strings. Differs from JS only for surrogate-pair characters, which are vanishingly rare in real rule data.

A few smaller decisions that follow JS:

  • cat stringifies via a JS-style String(value) helper (null"null", arrays → comma-joined, objects → "[object Object]").
  • substr with negative length mirrors the JS reference's two-step impl (drop from the right of the substring-from-start).
  • missing_some falls back to 0 for non-numeric min_required, matching our other operators' lenient numeric coercion.

Tests

  • StringArrayOperatorsTest covers each of the four operators (basic cases, coercion, edge cases, arity errors).
  • AccessorOperatorsTest extended with missing_some cases (threshold met, below threshold, zero required, dot-paths, arity errors).
  • EvaluatorTest adds two integration tests through dispatch: a country in [...] membership check, and a missing_some gate inside an if.

Notes

Stacked on top of #3482. Re-target to main once that lands.

Made with Cursor

Implements `in`, `cat`, `substr`, `merge`, and `missing_some` so v1
targeting predicates can express string-content, array-membership, and
"any N of these fields populated" checks.

What's new:

- New `StringArrayOperators` covers `in`, `cat`, `substr`, and
  `merge`.
- `opMissingSome` lives next to `opMissing` in `AccessorOperators` —
  it reuses the same dot-path lookup, so co-locating beats spawning a
  parallel module.
- `Operators` dispatch table extended with all five operators.

Behavior notes (deviations from the JSON Logic JS reference, both
documented in type-level KDocs and unit-tested):

- **`in` array membership uses `looseEq` instead of strict `===`.**
  Rule authors regularly write integer literals against
  backend-supplied string lists (`{"in": [{"var": "tier_id"},
  ["1", "2", "3"]]}` etc.); strict equality would silently fail
  those, loose equality matches the rest of our equality story.
- **`substr` slices by Unicode code points**, not UTF-16 code units.
  Matches Kotlin's `String.codePointCount` semantics and gives the
  intuitive answer for multibyte strings. Differs from JS only for
  surrogate-pair characters, which are vanishingly rare in real
  rule data.

A few smaller decisions that follow JS:

- `cat` stringifies via a JS-style `String(value)` helper (`null` →
  `"null"`, arrays → comma-joined, objects → `"[object Object]"`).
- `substr` with negative `length` mirrors the JS reference's
  two-step impl (drop from the right of the substring-from-start).
- `missing_some` falls back to `0` for non-numeric `min_required`,
  matching our other operators' lenient numeric coercion.

Tests:

- `StringArrayOperatorsTest` covers each of the four operators.
- `AccessorOperatorsTest` extended with `missing_some` cases
  (threshold met, below threshold, zero required, dot-paths, arity
  errors).
- `EvaluatorTest` adds two integration tests through dispatch: a
  `country in [...]` membership check, and a `missing_some` gate
  inside an `if`.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ajpallares ajpallares added pr:feat A new feature pr:other and removed pr:feat A new feature labels May 14, 2026
ajpallares and others added 3 commits May 21, 2026 17:48
…nto pallares/json-logic-string-array-operators

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	rules-engine/src/main/kotlin/com/revenuecat/purchases/rules/operators/AccessorOperators.kt
#	rules-engine/src/main/kotlin/com/revenuecat/purchases/rules/operators/Operators.kt
#	rules-engine/src/test/kotlin/com/revenuecat/purchases/rules/operators/AccessorOperatorsTest.kt
Mirrors the test half of iOS commit `f423397bf`. Kotlin/JVM's
`Double.toLong()` is already total — `NaN → 0L`, `±Infinity` clamp
to `Long.MAX_VALUE` / `Long.MIN_VALUE`, finite out-of-range Doubles
clamp the same way — so the operator code keeps the existing
`(value.toNumberOrNull() ?: 0.0).toLong()` pattern unchanged. What's
new is a battery of regression tests so a future refactor (e.g.
swapping in a narrower `Int` cast, or routing through a third-party
helper) can't silently reintroduce the trapping behavior the iOS
counterpart fixed with its `clampedInt` helper.

Adds 7 tests:

`StringArrayOperatorsTest`:
- `substr with NaN start treats it as zero`
- `substr with infinite start clamps to total` (covers `±Infinity`)
- `substr with oversized start clamps to total`
- `substr with NaN length treats it as zero`
- `substr with infinite length returns remaining or empty` (covers
  `±Infinity`)

`AccessorOperatorsTest`:
- `missing_some with NaN threshold treats it as zero`
- `missing_some with infinite threshold never satisfies`

Each test pins one branch of the JS `ToInteger` semantics (NaN → 0,
positive infinity / oversized → clamp at upper bound, negative
infinity → clamp at lower bound) at the operator level so the
contract is decoupled from the implementation detail of which
intermediate type is used.

Verified: `:rules-engine:testDefaultsDebugUnitTest`, `detektAll`, and
`scripts/check-rules-engine-internal-only.sh` all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
…nto pallares/json-logic-string-array-operators
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant