mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* Add Scope plumbing and custom operator extension point Thread evaluation scope through the rules engine and delegate unknown operators to an empty CustomOperators dispatcher, without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Add rc.rootVar operator for nested predicate root access Introduce the rc.rootVar custom operator and shared resolveVar lookup, with fixture coverage including falsy-item empty-path discrimination. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix project.pbxproj: restore RewardedAdTrackingMetadata entries The RulesEngine registration edit accidentally dropped four RewardedAdTrackingMetadata.swift entries from the Xcode project. Restore them and normalize CustomOperators identifiers to 24 hex chars. Co-authored-by: Cursor <cursoragent@cursor.com> * Promote firstArgEvaluated to shared Operators helpers Move the unary-arg helper from LogicOperators to Operators so custom operators can use the same json-logic-js spread convention. Co-authored-by: Cursor <cursoragent@cursor.com> * Address review comments on custom operator extension point Drop the cross-engine registration note from CustomOperators, and restore the single-line var extra-args warning that was reflowed without changing its text. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
168 lines
8.5 KiB
Swift
168 lines
8.5 KiB
Swift
//
|
|
// IterationOperators.swift
|
|
//
|
|
// Created by Antonio Pallares.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
extension RulesEngine {
|
|
|
|
/// Iteration operators: `some`, `all`, `none`, `map`, `filter`, `reduce`.
|
|
///
|
|
/// All six follow the JSON Logic JS reference (`json-logic-js`):
|
|
///
|
|
/// - **Shape** (`some` / `all` / `none` / `map` / `filter`):
|
|
/// `{"<op>": [arrayExpr, predicateExpr]}`. The first argument is
|
|
/// evaluated in the outer scope and must resolve to an array; anything
|
|
/// else is treated as an empty source. The second argument is a
|
|
/// literal template that is evaluated per-item with `vars` rebound to
|
|
/// the current item, with no parent-scope inheritance for `var`; the
|
|
/// preserved root scope remains available (`rc.rootVar` reads it).
|
|
/// - **Shape** (`reduce`):
|
|
/// `{"reduce": [arrayExpr, predicateExpr, initialAccumulator]}`. Both
|
|
/// the first and third arguments are evaluated in the outer scope.
|
|
/// The predicate is evaluated per-item with `vars` rebound to
|
|
/// `{"current": <item>, "accumulator": <acc>}`; `var` sees only that
|
|
/// object, but `rc.rootVar` still reaches the root.
|
|
///
|
|
/// **Empty- and non-array sources** per the JSON Logic JS spec:
|
|
/// - `some` / `all` return `false`.
|
|
/// - `none` returns `true`.
|
|
/// - `map` / `filter` return `[]`.
|
|
/// - `reduce` returns the initial accumulator unchanged.
|
|
enum IterationOperators {
|
|
|
|
/// `{"some": [arrayExpr, predicate]}` — `true` iff `predicate` is
|
|
/// truthy for at least one item. The array expression is evaluated in
|
|
/// the current scope; the predicate is re-evaluated per item with
|
|
/// `vars` rebound to that item (`var` reads the item; `rc.rootVar`
|
|
/// reads the root). Empty array or non-array source returns `false`.
|
|
/// Short-circuits on the first truthy result.
|
|
static func opSome(args: Value, vars: Scope) throws -> Value {
|
|
let (items, predicate) = try parseIterationArgs(args, vars: vars)
|
|
guard let items else { return .bool(false) }
|
|
for item in items {
|
|
let result = try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item))
|
|
if result.isTruthy { return .bool(true) }
|
|
}
|
|
return .bool(false)
|
|
}
|
|
|
|
/// `{"all": [arrayExpr, predicate]}` — `true` iff `predicate` is
|
|
/// truthy for every item. The array expression is evaluated in the
|
|
/// current scope; the predicate is re-evaluated per item with `vars`
|
|
/// rebound to that item (`var` reads the item; `rc.rootVar` reads the
|
|
/// root). Empty array returns `false` per the JSON Logic JS spec.
|
|
/// Non-array source returns `false`. Short-circuits on the first
|
|
/// non-truthy result.
|
|
static func opAll(args: Value, vars: Scope) throws -> Value {
|
|
let (items, predicate) = try parseIterationArgs(args, vars: vars)
|
|
guard let items, !items.isEmpty else { return .bool(false) }
|
|
for item in items {
|
|
let result = try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item))
|
|
if !result.isTruthy { return .bool(false) }
|
|
}
|
|
return .bool(true)
|
|
}
|
|
|
|
/// `{"none": [arrayExpr, predicate]}` — `true` iff `predicate` is
|
|
/// falsy for every item. Inverse of `some`. Short-circuits on the
|
|
/// first truthy item. Empty and non-array sources both return `true`,
|
|
/// matching the JS reference's `!Array.isArray(x) || !x.length` guard.
|
|
static func opNone(args: Value, vars: Scope) throws -> Value {
|
|
let (items, predicate) = try parseIterationArgs(args, vars: vars)
|
|
guard let items else { return .bool(true) }
|
|
for item in items {
|
|
let result = try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item))
|
|
if result.isTruthy { return .bool(false) }
|
|
}
|
|
return .bool(true)
|
|
}
|
|
|
|
/// `{"map": [arrayExpr, predicate]}` — apply `predicate` to each
|
|
/// item, return the new array of *raw* (non-truthy-coerced) results.
|
|
/// Empty or non-array source yields `[]`.
|
|
static func opMap(args: Value, vars: Scope) throws -> Value {
|
|
let (items, predicate) = try parseIterationArgs(args, vars: vars)
|
|
guard let items else { return .array([]) }
|
|
var results: [Value] = []
|
|
results.reserveCapacity(items.count)
|
|
for item in items {
|
|
results.append(try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item)))
|
|
}
|
|
return .array(results)
|
|
}
|
|
|
|
/// `{"filter": [arrayExpr, predicate]}` — return only items for
|
|
/// which `predicate` is truthy. Empty or non-array source yields
|
|
/// `[]`. The retained items are the *original* values, not the
|
|
/// predicate results.
|
|
static func opFilter(args: Value, vars: Scope) throws -> Value {
|
|
let (items, predicate) = try parseIterationArgs(args, vars: vars)
|
|
guard let items else { return .array([]) }
|
|
var results: [Value] = []
|
|
for item in items {
|
|
let result = try Evaluator.evaluateValue(predicate, vars: vars.scoped(to: item))
|
|
if result.isTruthy { results.append(item) }
|
|
}
|
|
return .array(results)
|
|
}
|
|
|
|
/// `{"reduce": [arrayExpr, predicate, initialAccumulator]}` — fold
|
|
/// over the array. The third argument is evaluated in the outer
|
|
/// scope to seed the accumulator, then the predicate is evaluated
|
|
/// once per item with `vars` rebound to
|
|
/// `{"current": item, "accumulator": acc}`. A non-array source
|
|
/// returns the seed unchanged. A missing predicate defaults to
|
|
/// `.undefined` (so it evaluates to `.undefined` per item, matching
|
|
/// `json-logic-js`'s `apply(undefined, …)`). A missing initial
|
|
/// accumulator defaults to `.null` — `json-logic-js` seeds the fold
|
|
/// with `typeof values[2] !== "undefined" ? values[2] : null`.
|
|
/// Arguments past the third are ignored.
|
|
static func opReduce(args: Value, vars: Scope) throws -> Value {
|
|
let raw = Operators.argsAsList(args)
|
|
let sourceArg: Value = raw.indices.contains(0) ? raw[0] : .null
|
|
let predicate: Value = raw.indices.contains(1) ? raw[1] : .undefined
|
|
let source = try Evaluator.evaluateValue(sourceArg, vars: vars)
|
|
var accumulator: Value = raw.indices.contains(2)
|
|
? try Evaluator.evaluateValue(raw[2], vars: vars)
|
|
: .null
|
|
guard case .array(let items) = source else { return accumulator }
|
|
for item in items {
|
|
let itemScope = vars.scoped(to: .object([
|
|
"current": item,
|
|
"accumulator": accumulator
|
|
]))
|
|
accumulator = try Evaluator.evaluateValue(predicate, vars: itemScope)
|
|
}
|
|
return accumulator
|
|
}
|
|
|
|
/// Parse `(items, predicate)` for the item-as-scope iteration
|
|
/// operators (`some`, `all`, `none`, `map`, `filter`). The source
|
|
/// argument is evaluated in the outer scope; the predicate template
|
|
/// is returned unevaluated so the caller can re-evaluate it per
|
|
/// item with the item as scope. `items` is `nil` when the source
|
|
/// does not resolve to an array, so callers can distinguish a
|
|
/// non-array source from a genuinely empty one (`some`/`all` treat
|
|
/// both as `false`, but `none`/`map`/`filter`/`reduce` need the
|
|
/// distinction). A missing predicate defaults to `.undefined` (so it
|
|
/// evaluates to `.undefined` per item, matching `json-logic-js`'s
|
|
/// `apply(undefined, …)`; observable via `map`'s raw results) and
|
|
/// arguments past the second are ignored, matching `json-logic-js`'s
|
|
/// `function(scopedData, scopedLogic)` signature.
|
|
private static func parseIterationArgs(
|
|
_ args: Value,
|
|
vars: Scope
|
|
) throws -> (items: [Value]?, predicate: Value) {
|
|
let raw = Operators.argsAsList(args)
|
|
let sourceArg: Value = raw.indices.contains(0) ? raw[0] : .null
|
|
let predicate: Value = raw.indices.contains(1) ? raw[1] : .undefined
|
|
let source = try Evaluator.evaluateValue(sourceArg, vars: vars)
|
|
guard case .array(let items) = source else { return (nil, predicate) }
|
|
return (items, predicate)
|
|
}
|
|
}
|
|
}
|