diff --git a/README.md b/README.md index f980e14..2751eea 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,18 @@ BexProgramSource source = BexProgramSource.withDefinition( ); ``` +## Constants + +`$const` must reference a declared program constant. Unknown constants fail at +compile time instead of evaluating to undefined: + +```yaml +constants: + amount: 400 +expr: + $const: amount +``` + ## Function Arguments And Blue Patterns BEX function arguments may declare Blue type or shape patterns. BEX does not @@ -262,6 +274,44 @@ Hosts often provide common bindings such as `event`, `steps`, and For every other host binding, use `$binding`. +## Pointer Kinds + +BEX has two pointer contexts. + +Document pointers are resolved relative to the current document scope: + +- `$document`; +- `$resultValue`; +- `$appendChange.path`; +- `$appendChanges` entry `path`. + +Value pointers are resolved inside the selected value and are not affected by +the document scope: + +- `$event`; +- `$currentContract`; +- `$steps.path`; +- `$binding.path`; +- `$pointerGet.path`; +- `$pointerSet.path`. + +Static omitted/default paths may intentionally read a root/default location. +Dynamic pointer expressions that evaluate to `null` or undefined fail instead +of silently becoming the current document scope or root value. + +Use `$pointerJoin` when building document paths from dynamic path segments. Each +item is treated as one JSON Pointer segment and escaped safely: + +```yaml +$pointerJoin: + - orders + - $var: orderId + - status +``` + +If `orderId` is `abc/def~ghi`, the result is +`/orders/abc~1def~0ghi/status`. + ## Expression Operators BEX operators are Blue objects whose single key starts with `$`. @@ -307,6 +357,7 @@ $is: | Operator | Purpose | |---|---| | `$concat` | Concatenate text. | +| `$pointerJoin` | Safely build a JSON Pointer from path segments. | | `$join` | Join list items with a separator. | | `$split` | Split text. | | `$startsWith` | Check a prefix. | @@ -402,6 +453,14 @@ BEX-looking operators inside Blue type-definition fields such as `type`, BEX computes these values only. The host decides whether patches are applied, events are emitted, or accumulators are treated as ordinary data. +`$appendChanges` validates each patch entry the same way as `$appendChange`. +Supported patch operations are `add`, `replace`, and `remove`. `add` and +`replace` require a non-undefined `val`; `remove` does not include a value. + +`$appendEvents` validates each item the same way as `$appendEvent`. Undefined +event values are rejected. BEX core does not require events to be objects; hosts +decide what event shape they accept. + ## Determinism BEX execution is deterministic for a fixed program, context, document view, diff --git a/src/main/java/blue/bex/compile/BexCompiledProgram.java b/src/main/java/blue/bex/compile/BexCompiledProgram.java index 4ee9062..bf76bd6 100644 --- a/src/main/java/blue/bex/compile/BexCompiledProgram.java +++ b/src/main/java/blue/bex/compile/BexCompiledProgram.java @@ -50,7 +50,10 @@ public BexValue execute(BexRuntime runtime) { public BexValue constant(String name) { BexValue value = constants.get(name); - return value != null ? value : BexValues.undefined(); + if (value == null) { + throw new BexException("Unknown constant: " + name); + } + return value; } /** diff --git a/src/main/java/blue/bex/compile/BexCompiler.java b/src/main/java/blue/bex/compile/BexCompiler.java index f2ce447..14f5c35 100644 --- a/src/main/java/blue/bex/compile/BexCompiler.java +++ b/src/main/java/blue/bex/compile/BexCompiler.java @@ -30,6 +30,7 @@ public final class BexCompiler { private final BexContainsCache containsCache = new BexContainsCache(); private final BexMetrics metrics; private Map functionSignatures = Collections.emptyMap(); + private Map constants = Collections.emptyMap(); private String currentFunction = "$root"; public BexCompiler(BexMetrics metrics) { @@ -40,9 +41,10 @@ public BexCompiledProgram compile(blue.bex.api.BexProgramSource source) { FrozenNode step = source.programNode(); FrozenNode definition = source.definitionNode().orElse(null); - Map constants = new LinkedHashMap<>(); - loadConstants(constants, prop(definition, "constants")); - loadConstants(constants, prop(step, "constants")); + Map loadedConstants = new LinkedHashMap<>(); + loadConstants(loadedConstants, prop(definition, "constants")); + loadConstants(loadedConstants, prop(step, "constants")); + constants = Collections.unmodifiableMap(new LinkedHashMap<>(loadedConstants)); Map functionNodes = new LinkedHashMap<>(); loadFunctions(functionNodes, prop(definition, "functions")); @@ -274,7 +276,7 @@ private CompiledStatement compileStatement(FrozenNode statement, CompileScope sc compiled = new ForEachStatement(compileExpr(required(prop(body, "in"), "$forEach.in"), scope, bodyPointer + "/in"), slot, compileStatements(prop(body, "do"), scope, bodyPointer + "/do")); } else if ("$appendChange".equals(op)) { - compiled = new AppendChangeStatement(textOrExpr(prop(body, "op"), scope, "replace", bodyPointer + "/op"), + compiled = new AppendChangeStatement(textOrExpr(required(prop(body, "op"), "$appendChange.op"), scope, null, bodyPointer + "/op"), pointerOperand(required(prop(body, "path"), "$appendChange.path"), scope, bodyPointer + "/path"), prop(body, "val") != null ? compileExpr(prop(body, "val"), scope, bodyPointer + "/val") : null); } else if ("$appendChanges".equals(op)) { @@ -355,7 +357,13 @@ private CompiledExpression compileOperator(String op, FrozenNode body, CompileSc if ("$steps".equals(op)) return stepsExpr(body, scope, pointer); if ("$currentContract".equals(op)) return contextPointerExpr(body, scope, ContextKind.CURRENT_CONTRACT, pointer); if ("$var".equals(op)) return new VarExpr(scope.resolveSlot(requiredText(body, "$var"))); - if ("$const".equals(op)) return new ConstExpr(requiredText(body, "$const")); + if ("$const".equals(op)) { + String name = requiredText(body, "$const"); + if (!constants.containsKey(name)) { + throw new BexException("Unknown constant: " + name); + } + return new ConstExpr(name); + } if ("$get".equals(op)) return new GetExpr(compileExpr(required(prop(body, "object"), "$get.object"), scope, pointer + "/object"), textOrExpr(required(prop(body, "key"), "$get.key"), scope, null, pointer + "/key")); if ("$changeset".equals(op)) return new ChangesetExpr(); if ("$events".equals(op)) return new EventsExpr(); @@ -369,6 +377,7 @@ private CompiledExpression compileOperator(String op, FrozenNode body, CompileSc if ("$object".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.OBJECT); if ("$list".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.LIST); if ("$concat".equals(op)) return new VariadicExpr(compileExprList(body, scope, pointer), VariadicOp.CONCAT); + if ("$pointerJoin".equals(op)) return new PointerJoinExpr(compileExprList(body, scope, pointer)); if ("$join".equals(op)) return new JoinExpr(compileExpr(required(prop(body, "list"), "$join.list"), scope, pointer + "/list"), compileExpr(required(prop(body, "separator"), "$join.separator"), scope, pointer + "/separator")); if ("$split".equals(op)) return new SplitExpr(compileExpr(required(prop(body, "text"), "$split.text"), scope, pointer + "/text"), compileExpr(required(prop(body, "separator"), "$split.separator"), scope, pointer + "/separator"), prop(body, "limit") != null ? compileExpr(prop(body, "limit"), scope, pointer + "/limit") : null); if ("$startsWith".equals(op)) return new BinaryTextExpr(compileExprList(body, scope, pointer), BinaryTextOp.STARTS_WITH); @@ -397,8 +406,8 @@ private CompiledExpression compileOperator(String op, FrozenNode body, CompileSc if ("$listConcat".equals(op)) return new VariadicExpr(compileExprList(body, scope, pointer), VariadicOp.LIST_CONCAT); if ("$merge".equals(op)) return new VariadicExpr(compileExprList(body, scope, pointer), VariadicOp.MERGE); if ("$objectSet".equals(op)) return new ObjectSetExpr(compileExpr(required(prop(body, "object"), "$objectSet.object"), scope, pointer + "/object"), textOrExpr(required(prop(body, "key"), "$objectSet.key"), scope, null, pointer + "/key"), compileExpr(required(prop(body, "val"), "$objectSet.val"), scope, pointer + "/val")); - if ("$pointerGet".equals(op)) return new PointerGetExpr(compileExpr(required(prop(body, "object"), "$pointerGet.object"), scope, pointer + "/object"), pointerOperand(required(prop(body, "path"), "$pointerGet.path"), scope, pointer + "/path"), prop(body, "default") != null ? compileExpr(prop(body, "default"), scope, pointer + "/default") : null); - if ("$pointerSet".equals(op)) return new PointerSetExpr(compileExpr(required(prop(body, "object"), "$pointerSet.object"), scope, pointer + "/object"), textOrExpr(prop(body, "op"), scope, "set", pointer + "/op"), pointerOperand(required(prop(body, "path"), "$pointerSet.path"), scope, pointer + "/path"), prop(body, "val") != null ? compileExpr(prop(body, "val"), scope, pointer + "/val") : null); + if ("$pointerGet".equals(op)) return new PointerGetExpr(compileExpr(required(prop(body, "object"), "$pointerGet.object"), scope, pointer + "/object"), valuePointerOperand(required(prop(body, "path"), "$pointerGet.path"), scope, pointer + "/path"), prop(body, "default") != null ? compileExpr(prop(body, "default"), scope, pointer + "/default") : null); + if ("$pointerSet".equals(op)) return new PointerSetExpr(compileExpr(required(prop(body, "object"), "$pointerSet.object"), scope, pointer + "/object"), textOrExpr(prop(body, "op"), scope, "set", pointer + "/op"), valuePointerOperand(required(prop(body, "path"), "$pointerSet.path"), scope, pointer + "/path"), prop(body, "val") != null ? compileExpr(prop(body, "val"), scope, pointer + "/val") : null); if ("$choose".equals(op)) return new ChooseExpr(compileExpr(required(prop(body, "cond"), "$choose.cond"), scope, pointer + "/cond"), compileExpr(required(prop(body, "then"), "$choose.then"), scope, pointer + "/then"), prop(body, "else") != null ? compileExpr(prop(body, "else"), scope, pointer + "/else") : new LiteralExpr(BexValues.undefined())); if ("$call".equals(op)) return compileCall(body, scope, pointer); throw new BexException("Unknown expression operator: " + op); @@ -427,7 +436,7 @@ private CompiledExpression documentExpr(FrozenNode body, CompileScope scope, Str } private CompiledExpression contextPointerExpr(FrozenNode body, CompileScope scope, ContextKind kind, String pointer) { - return new ContextPointerExpr(pointerOperand(body, scope, pointer), kind); + return new ContextPointerExpr(valuePointerOperand(body, scope, pointer), kind); } private CompiledExpression bindingExpr(FrozenNode body, CompileScope scope, String pointer) { @@ -455,10 +464,10 @@ private CompiledExpression stepsExpr(FrozenNode body, CompileScope scope, String int dot = selector.indexOf('.'); String step = dot >= 0 ? selector.substring(0, dot) : selector; String path = dot >= 0 ? "/" + selector.substring(dot + 1).replace('.', '/') : "/"; - return new StepsExpr(new StaticTextExpr(step), StaticPointerOperand.absolute(path)); + return new StepsExpr(new StaticTextExpr(step), StaticValuePointerOperand.of(path)); } return new StepsExpr(textOrExpr(required(prop(body, "step"), "$steps.step"), scope, null, pointer + "/step"), - pointerOperand(prop(body, "path") != null ? prop(body, "path") : scalarNode("/"), scope, pointer + "/path")); + valuePointerOperand(prop(body, "path") != null ? prop(body, "path") : scalarNode("/"), scope, pointer + "/path")); } private CallExpr compileCall(FrozenNode body, CompileScope scope, String pointer) { diff --git a/src/main/java/blue/bex/compile/BexOperands.java b/src/main/java/blue/bex/compile/BexOperands.java index 96e1a1b..8e473a1 100644 --- a/src/main/java/blue/bex/compile/BexOperands.java +++ b/src/main/java/blue/bex/compile/BexOperands.java @@ -1,8 +1,10 @@ package blue.bex.compile; +import blue.bex.BexException; import blue.bex.pointer.BexPointer; import blue.bex.runtime.CompiledExpression; import blue.bex.runtime.CompiledFrame; +import blue.bex.value.BexValue; import java.util.List; @@ -89,7 +91,7 @@ final class DynamicPointerOperand implements PointerOperand { @Override public String authored(CompiledFrame frame) { - return expr.eval(frame).asText(); + return PointerOperands.pointerText(expr.eval(frame)); } @Override @@ -146,7 +148,7 @@ final class DynamicValuePointerOperand implements PointerOperand { @Override public String authored(CompiledFrame frame) { - return StaticValuePointerOperand.normalize(expr.eval(frame).asText()); + return StaticValuePointerOperand.normalize(PointerOperands.pointerText(expr.eval(frame))); } @Override @@ -159,3 +161,15 @@ public List segments(CompiledFrame frame) { return frame.runtime().parseDynamicPointer(absolute(frame)); } } + +final class PointerOperands { + private PointerOperands() { + } + + static String pointerText(BexValue value) { + if (value == null || value.isUndefined() || value.isNull()) { + throw new BexException("Pointer operand cannot be null or undefined"); + } + return value.asText(); + } +} diff --git a/src/main/java/blue/bex/compile/BexPatchEntryParser.java b/src/main/java/blue/bex/compile/BexPatchEntryParser.java new file mode 100644 index 0000000..eab4c06 --- /dev/null +++ b/src/main/java/blue/bex/compile/BexPatchEntryParser.java @@ -0,0 +1,60 @@ +package blue.bex.compile; + +import blue.bex.BexException; +import blue.bex.result.BexPatchEntry; +import blue.bex.runtime.CompiledFrame; +import blue.bex.value.BexValue; +import blue.bex.value.BexValues; + +final class BexPatchEntryParser { + private BexPatchEntryParser() { + } + + static BexPatchEntry fromFields(CompiledFrame frame, + String op, + String authoredPath, + boolean hasVal, + BexValue val) { + validateOp(op); + if (authoredPath == null) { + throw new BexException("Patch path is required"); + } + BexValue normalizedVal = BexValues.undefined(); + if ("add".equals(op) || "replace".equals(op)) { + if (!hasVal || val == null || val.isUndefined()) { + throw new BexException("Patch op " + op + " requires val"); + } + normalizedVal = val; + } + String absolute = frame.runtime().resolvePointer(authoredPath); + return new BexPatchEntry(op, authoredPath, absolute, normalizedVal); + } + + static boolean requiresValue(String op) { + validateOp(op); + return !"remove".equals(op); + } + + static BexPatchEntry fromValue(CompiledFrame frame, BexValue entry) { + if (entry == null || !entry.isObject()) { + throw new BexException("Patch entry must be an object"); + } + String op = requiredText(entry.get("op"), "patch.op"); + String path = requiredText(entry.get("path"), "patch.path"); + BexValue val = entry.get("val"); + return fromFields(frame, op, path, !val.isUndefined(), val); + } + + private static void validateOp(String op) { + if (!"add".equals(op) && !"replace".equals(op) && !"remove".equals(op)) { + throw new BexException("Unsupported patch op: " + op); + } + } + + private static String requiredText(BexValue value, String label) { + if (value == null || value.isUndefined() || value.isNull()) { + throw new BexException("Missing required text field: " + label); + } + return value.asText(); + } +} diff --git a/src/main/java/blue/bex/compile/BexStatements.java b/src/main/java/blue/bex/compile/BexStatements.java index bff35ad..bbc49df 100644 --- a/src/main/java/blue/bex/compile/BexStatements.java +++ b/src/main/java/blue/bex/compile/BexStatements.java @@ -143,6 +143,24 @@ protected Control doExec(CompiledFrame frame) { } } +final class BexStatementEffects { + private BexStatementEffects() { + } + + static void appendChange(CompiledFrame frame, BexPatchEntry entry) { + frame.runtime().gas().chargeValue(frame.runtime().gas().schedule().appendChangeBase, entry.val()); + frame.accumulator().appendChange(entry); + } + + static void appendEvent(CompiledFrame frame, BexValue value) { + if (value.isUndefined()) { + throw new BexException("Undefined cannot be emitted as an event"); + } + frame.runtime().gas().chargeValue(frame.runtime().gas().schedule().appendEventBase, value); + frame.accumulator().appendEvent(value); + } +} + final class AppendChangeStatement extends Stmt { private final TextOperand op; private final PointerOperand pointer; @@ -157,16 +175,16 @@ final class AppendChangeStatement extends Stmt { @Override protected Control doExec(CompiledFrame frame) { String operation = op.get(frame); - if (!"add".equals(operation) && !"replace".equals(operation) && !"remove".equals(operation)) { - throw new BexException("Unsupported patch op: " + operation); - } - BexValue value = "remove".equals(operation) ? BexValues.undefined() : val.eval(frame); - if (!"remove".equals(operation) && value.isUndefined()) { - throw new BexException("Undefined cannot be emitted as a patch value"); + boolean requiresVal = BexPatchEntryParser.requiresValue(operation); + BexValue value = BexValues.undefined(); + if (requiresVal) { + if (val == null) { + throw new BexException("Patch op " + operation + " requires val"); + } + value = val.eval(frame); } - String absolute = pointer.absolute(frame); - frame.runtime().gas().charge(frame.runtime().gas().schedule().appendChangeBase + frame.runtime().gas().estimatedSize(value) / 64); - frame.accumulator().appendChange(new BexPatchEntry(operation, pointer.authored(frame), absolute, value)); + BexStatementEffects.appendChange(frame, + BexPatchEntryParser.fromFields(frame, operation, pointer.authored(frame), val != null, value)); return Control.CONTINUE; } } @@ -183,14 +201,8 @@ protected Control doExec(CompiledFrame frame) { BexValue list = expr.eval(frame); if (!list.isList()) throw new BexException("$appendChanges requires a list"); for (int i = 0; i < list.size(); i++) { - BexValue entry = list.get(String.valueOf(i)); - String op = entry.get("op").asText(); - String path = entry.get("path").asText(); - BexValue val = "remove".equals(op) ? BexValues.undefined() : entry.get("val"); - if (!"remove".equals(op) && val.isUndefined()) { - throw new BexException("Undefined cannot be emitted as a patch value"); - } - frame.accumulator().appendChange(new BexPatchEntry(op, path, frame.runtime().resolvePointer(path), val)); + BexStatementEffects.appendChange(frame, + BexPatchEntryParser.fromValue(frame, list.get(String.valueOf(i)))); } return Control.CONTINUE; } @@ -205,10 +217,7 @@ final class AppendEventStatement extends Stmt { @Override protected Control doExec(CompiledFrame frame) { - BexValue value = expr.eval(frame); - if (value.isUndefined()) throw new BexException("Undefined cannot be emitted as an event"); - frame.runtime().gas().charge(frame.runtime().gas().schedule().appendEventBase + frame.runtime().gas().estimatedSize(value) / 64); - frame.accumulator().appendEvent(value); + BexStatementEffects.appendEvent(frame, expr.eval(frame)); return Control.CONTINUE; } } @@ -225,7 +234,7 @@ protected Control doExec(CompiledFrame frame) { BexValue list = expr.eval(frame); if (!list.isList()) throw new BexException("$appendEvents requires a list"); for (int i = 0; i < list.size(); i++) { - frame.accumulator().appendEvent(list.get(String.valueOf(i))); + BexStatementEffects.appendEvent(frame, list.get(String.valueOf(i))); } return Control.CONTINUE; } diff --git a/src/main/java/blue/bex/compile/CallExpr.java b/src/main/java/blue/bex/compile/CallExpr.java index 3fe0789..fe794d8 100644 --- a/src/main/java/blue/bex/compile/CallExpr.java +++ b/src/main/java/blue/bex/compile/CallExpr.java @@ -1,5 +1,6 @@ package blue.bex.compile; +import blue.bex.BexException; import blue.bex.runtime.CompiledExpression; import blue.bex.runtime.CompiledFrame; import blue.bex.value.BexValue; @@ -18,6 +19,9 @@ final class CallExpr extends Expr { @Override protected BexValue doEval(CompiledFrame frame) { BexCompiledProgram.CompiledFunction compiled = frame.runtime().program().functions().get(function); + if (compiled == null) { + throw new BexException("Unknown function: " + function); + } BexValue[] values = new BexValue[argExpressions.length]; for (int i = 0; i < argExpressions.length; i++) { values[i] = argExpressions[i].eval(frame); diff --git a/src/main/java/blue/bex/compile/ObjectResultExpressions.java b/src/main/java/blue/bex/compile/ObjectResultExpressions.java index 5281392..c92dcc6 100644 --- a/src/main/java/blue/bex/compile/ObjectResultExpressions.java +++ b/src/main/java/blue/bex/compile/ObjectResultExpressions.java @@ -43,8 +43,9 @@ final class ObjectSetExpr extends Expr { @Override protected BexValue doEval(CompiledFrame frame) { - frame.runtime().gas().charge(frame.runtime().gas().schedule().objectSetBase); - return BexValues.overlay(object.eval(frame), key.get(frame), value.eval(frame)); + BexValue val = value.eval(frame); + frame.runtime().gas().chargeValue(frame.runtime().gas().schedule().objectSetBase, val); + return BexValues.overlay(object.eval(frame), key.get(frame), val); } } @@ -89,7 +90,7 @@ protected BexValue doEval(CompiledFrame frame) { BexValue val = "remove".equals(operation) ? BexValues.undefined() : value.eval(frame); BexValue base = object.eval(frame); validatePointerSetBase(base, segments); - frame.runtime().gas().charge(frame.runtime().gas().schedule().pointerSetBase + segments.size() + frame.runtime().gas().estimatedSize(val) / 64); + frame.runtime().gas().chargePointerValue(frame.runtime().gas().schedule().pointerSetBase, segments.size(), val); return BexValues.pointerSet(base, segments, val, operation); } diff --git a/src/main/java/blue/bex/compile/TypeStringExpressions.java b/src/main/java/blue/bex/compile/TypeStringExpressions.java index 24ac18f..05891e4 100644 --- a/src/main/java/blue/bex/compile/TypeStringExpressions.java +++ b/src/main/java/blue/bex/compile/TypeStringExpressions.java @@ -152,6 +152,35 @@ protected BexValue doEval(CompiledFrame frame) { } } +final class PointerJoinExpr extends Expr { + private final List segments; + + PointerJoinExpr(List segments) { + this.segments = segments; + } + + @Override + protected BexValue doEval(CompiledFrame frame) { + if (segments.isEmpty()) { + return BexValues.scalar("/"); + } + StringBuilder out = new StringBuilder(); + for (CompiledExpression expression : segments) { + BexValue value = expression.eval(frame); + if (value.isUndefined() || value.isNull()) { + throw new BexException("$pointerJoin segment cannot be null or undefined"); + } + out.append('/'); + out.append(escapeSegment(value.asText())); + } + return BexValues.scalar(out.toString()); + } + + private String escapeSegment(String segment) { + return segment.replace("~", "~0").replace("/", "~1"); + } +} + final class SplitExpr extends Expr { private final CompiledExpression text; private final CompiledExpression separator; diff --git a/src/main/java/blue/bex/gas/BexGasMeter.java b/src/main/java/blue/bex/gas/BexGasMeter.java index 33fab61..67244c8 100644 --- a/src/main/java/blue/bex/gas/BexGasMeter.java +++ b/src/main/java/blue/bex/gas/BexGasMeter.java @@ -40,4 +40,12 @@ public void charge(long amount) { public long estimatedSize(BexValue value) { return sizeEstimator.estimate(value); } + + public void chargeValue(long base, BexValue value) { + charge(base + estimatedSize(value)); + } + + public void chargePointerValue(long base, int pathSegments, BexValue value) { + charge(base + Math.max(0, pathSegments) + estimatedSize(value)); + } } diff --git a/src/main/java/blue/bex/result/BexPatchEntry.java b/src/main/java/blue/bex/result/BexPatchEntry.java index b371f7a..7e4b0c0 100644 --- a/src/main/java/blue/bex/result/BexPatchEntry.java +++ b/src/main/java/blue/bex/result/BexPatchEntry.java @@ -1,6 +1,8 @@ package blue.bex.result; +import blue.bex.BexException; import blue.bex.value.BexValue; +import blue.bex.value.BexValues; import blue.language.utils.JsonPointer; import java.util.Collections; @@ -19,10 +21,13 @@ public final class BexPatchEntry { public BexPatchEntry(String op, String authoredPath, String absolutePath, BexValue val) { this.op = Objects.requireNonNull(op, "op"); + if (!"add".equals(op) && !"replace".equals(op) && !"remove".equals(op)) { + throw new BexException("Unsupported patch op: " + op); + } this.authoredPath = Objects.requireNonNull(authoredPath, "authoredPath"); this.absolutePath = JsonPointer.canonicalize(Objects.requireNonNull(absolutePath, "absolutePath")); this.absoluteSegments = Collections.unmodifiableList(JsonPointer.split(this.absolutePath)); - this.val = val; + this.val = val != null ? val : BexValues.undefined(); } public String op() { diff --git a/src/test/java/blue/bex/BexAccumulatorPointerConsistencyTest.java b/src/test/java/blue/bex/BexAccumulatorPointerConsistencyTest.java new file mode 100644 index 0000000..e693559 --- /dev/null +++ b/src/test/java/blue/bex/BexAccumulatorPointerConsistencyTest.java @@ -0,0 +1,221 @@ +package blue.bex; + +import blue.bex.api.BexEngine; +import blue.bex.api.BexExecutionContext; +import blue.bex.api.BexProgramSource; +import blue.bex.api.BexStepResults; +import blue.bex.api.FrozenBexDocumentView; +import blue.bex.result.BexExecutionResult; +import blue.bex.value.BexValues; +import blue.language.model.Node; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; + +import static blue.bex.test.BexTestFixtures.*; +import static org.junit.jupiter.api.Assertions.*; + +class BexAccumulatorPointerConsistencyTest { + @Test + void knownConstantWorksAndUnknownConstantFailsAtCompileTime() { + assertEquals(BigInteger.valueOf(400), simple(runStep(obj( + "type", "Blue/BEX Program", + "constants", obj("amount", 400), + "expr", op("$const", "amount") + ), defaultContext()).value())); + + assertThrows(BexException.class, () -> compile(obj( + "type", "Blue/BEX Program", + "constants", obj("amount", 400), + "expr", op("$const", "amuont") + ))); + } + + @Test + void literalPayloadMayContainUnknownConstant() { + BexExecutionResult result = runStep(obj( + "type", "Blue/BEX Program", + "expr", op("$literal", op("$const", "missing")) + ), defaultContext()); + + assertEquals(m("$const", "missing"), simple(result.value())); + } + + @Test + void valueLocalPointersIgnoreDocumentScope() { + BexExecutionContext context = scopedContext(); + + assertEquals(BigInteger.valueOf(7), simple(runStep(stepExpr(op("$event", "message/request/amount")), context).value())); + assertEquals("main", simple(runStep(stepExpr(op("$currentContract", "channel/name")), context).value())); + assertEquals(l(m("op", "replace", "path", "/status", "val", "ready")), + simple(runStep(stepExpr(op("$steps", obj("step", "Build", "path", "changeset"))), context).value())); + assertEquals(BigInteger.ONE, simple(runStep(stepExpr(op("$pointerGet", obj( + "object", obj("a", obj("b", 1)), + "path", "a/b" + ))), context).value())); + assertEquals(m("a", m("b", BigInteger.ONE, "c", BigInteger.valueOf(2))), + simple(runStep(stepExpr(op("$pointerSet", obj( + "object", obj("a", obj("b", 1)), + "path", "a/c", + "val", 2 + ))), context).value())); + } + + @Test + void documentPointersRemainDocumentScopeRelative() { + BexExecutionContext context = scopedContext(); + + assertEquals("scoped", simple(runStep(stepExpr(op("$document", "status")), context).value())); + + BexExecutionResult result = runStep(stepDo(list( + op("$appendChange", obj("op", "replace", "path", "status", "val", "done")), + op("$appendChanges", list(obj("op", "replace", "path", "status", "val", "batch"))), + op("$return", obj()) + )), context); + + assertEquals("/contracts/current/status", result.changeset().entries().get(0).absolutePath()); + assertEquals("/contracts/current/status", result.changeset().entries().get(1).absolutePath()); + } + + @Test + void dynamicPointerOperandsRejectNullAndUndefined() { + assertThrows(BexException.class, () -> runStep(stepDo(list( + op("$appendChange", obj( + "op", "replace", + "path", op("$document", "/missing"), + "val", "done" + )) + )), defaultContext())); + assertThrows(BexException.class, () -> runExpr(op("$pointerGet", obj( + "object", obj("a", 1), + "path", op("$document", "/missing") + )))); + assertThrows(BexException.class, () -> runExpr(op("$event", op("$document", "/missing")))); + assertThrows(BexException.class, () -> runExpr(op("$document", op("$literal", null)))); + assertThrows(BexException.class, () -> runExpr(op("$pointerSet", obj( + "object", obj("a", 1), + "path", op("$literal", null), + "val", 2 + )))); + } + + @Test + void pointerJoinEscapesSegmentsAndWorksForPatchPaths() { + assertEquals("/orders/abc~1def~0ghi/status", simple(runExpr(op("$pointerJoin", list( + "orders", + "abc/def~ghi", + "status" + ))).value())); + assertEquals("/", simple(runExpr(op("$pointerJoin", list())).value())); + + BexExecutionResult result = runStep(stepDo(list( + op("$let", obj("name", "id", "expr", "abc/def~ghi")), + op("$appendChange", obj( + "op", "replace", + "path", op("$pointerJoin", list("orders", op("$var", "id"), "status")), + "val", "confirmed" + )), + op("$return", obj()) + )), defaultContext()); + + assertEquals("/orders/abc~1def~0ghi/status", result.changeset().entries().get(0).absolutePath()); + } + + @Test + void appendChangeAndAppendChangesValidatePatchEntriesConsistently() { + assertThrows(BexException.class, () -> runStep(stepDo(list( + op("$appendChange", obj("op", "move", "path", "/x", "val", 1)) + )), defaultContext())); + assertThrows(BexException.class, () -> runStep(stepDo(list( + op("$appendChanges", list(obj("op", "move", "path", "/x", "val", 1))) + )), defaultContext())); + assertThrows(BexException.class, () -> runStep(stepDo(list( + op("$appendChange", obj("op", "replace", "path", "/x")) + )), defaultContext())); + assertThrows(BexException.class, () -> runStep(stepDo(list( + op("$appendChanges", list(obj("op", "replace", "path", "/x"))) + )), defaultContext())); + assertThrows(BexException.class, () -> runStep(stepDo(list( + op("$appendChanges", list("not-an-object")) + )), defaultContext())); + } + + @Test + void removePatchesDoNotRequireValuesAndSingleRemoveDoesNotEvaluateVal() { + BexExecutionResult single = runStep(stepDo(list( + op("$appendChange", obj("op", "remove", "path", "/x", "val", op("$divide", list(1, 0)))), + op("$return", obj()) + )), defaultContext()); + BexExecutionResult batch = runStep(stepDo(list( + op("$appendChanges", list(obj("op", "remove", "path", "/x"))), + op("$return", obj()) + )), defaultContext()); + + assertEquals("remove", single.changeset().entries().get(0).op()); + assertTrue(single.changeset().entries().get(0).val().isUndefined()); + assertEquals("remove", batch.changeset().entries().get(0).op()); + assertTrue(batch.changeset().entries().get(0).val().isUndefined()); + assertEquals(l(m("op", "remove", "path", "/x")), simple(single.changeset().asValue())); + assertEquals(l(m("op", "remove", "path", "/x")), simple(batch.changeset().asValue())); + } + + @Test + void appendEventAndAppendEventsValidateAndPreserveOrder() { + assertThrows(BexException.class, () -> runStep(stepDo(list( + op("$appendEvent", op("$document", "/missing")) + )), defaultContext())); + assertThrows(BexException.class, () -> runStep(stepDo(list( + op("$appendEvents", list(obj("ok", true), op("$document", "/missing"))) + )), defaultContext())); + + BexExecutionResult result = runStep(stepDo(list( + op("$appendEvents", list(obj("kind", "A"), obj("kind", "B"))), + op("$return", obj()) + )), defaultContext()); + + assertEquals(l(m("kind", "A"), m("kind", "B")), simple(result.events().asValue())); + } + + @Test + void appendOutputGasScalesWithValueSizeAndEntryCount() { + long smallEventGas = runStep(stepDo(list( + op("$appendEvents", list(obj("kind", "A"))), + op("$return", obj()) + )), defaultContext()).gasUsed(); + long largeEventGas = runStep(stepDo(list( + op("$appendEvents", list(largeObject(150))), + op("$return", obj()) + )), defaultContext()).gasUsed(); + long onePatchGas = runStep(stepDo(list( + op("$appendChanges", list(obj("op", "replace", "path", "/a", "val", "x"))), + op("$return", obj()) + )), defaultContext()).gasUsed(); + long twoPatchGas = runStep(stepDo(list( + op("$appendChanges", list( + obj("op", "replace", "path", "/a", "val", "x"), + obj("op", "replace", "path", "/b", "val", "x") + )), + op("$return", obj()) + )), defaultContext()).gasUsed(); + + assertTrue(largeEventGas > smallEventGas); + assertTrue(twoPatchGas > onePatchGas); + } + + private static void compile(Node step) { + BexEngine.builder().build().compile(BexProgramSource.inline(frozen(step))); + } + + private static BexExecutionContext scopedContext() { + Node document = obj("contracts", obj("current", obj("status", "scoped"))); + return BexExecutionContext.builder() + .document(new FrozenBexDocumentView(frozen(document), frozen(document), "/contracts/current")) + .event(BexValues.nodeSnapshot(obj("message", obj("request", obj("amount", 7))))) + .currentContract(BexValues.nodeSnapshot(obj("channel", obj("name", "main")))) + .steps(BexStepResults.builder() + .put("Build", BexValues.fromSimple(m("changeset", l(m("op", "replace", "path", "/status", "val", "ready"))))) + .build()) + .gasLimit(1_000_000) + .build(); + } +}