Add JSON Logic string + array operators#3485
Draft
ajpallares wants to merge 4 commits into
Draft
Conversation
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>
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves SDK-4332
Motivation
Implements
in,cat,substr,merge, andmissing_someso v1 targeting predicates can express string-content, array-membership, and "any N of these fields populated" checks.Summary
StringArrayOperatorscoversin,cat,substr, andmerge.opMissingSomelives next toopMissinginAccessorOperators— it reuses the same dot-path lookup, so co-locating beats spawning a parallel module.Operatorsdispatch 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:
inarray membership useslooseEqinstead 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.substrslices by Unicode code points, not UTF-16 code units. Matches Kotlin'sString.codePointCountsemantics 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:
catstringifies via a JS-styleString(value)helper (null→"null", arrays → comma-joined, objects →"[object Object]").substrwith negativelengthmirrors the JS reference's two-step impl (drop from the right of the substring-from-start).missing_somefalls back to0for non-numericmin_required, matching our other operators' lenient numeric coercion.Tests
StringArrayOperatorsTestcovers each of the four operators (basic cases, coercion, edge cases, arity errors).AccessorOperatorsTestextended withmissing_somecases (threshold met, below threshold, zero required, dot-paths, arity errors).EvaluatorTestadds two integration tests through dispatch: acountry in [...]membership check, and amissing_somegate inside anif.Notes
Stacked on top of #3482. Re-target to
mainonce that lands.Made with Cursor