Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ 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.

Dynamic text operands also reject `null` and undefined. This applies to
operator fields such as `$get.key`, `$objectSet.key`, `$binding.name`,
`$steps.step`, `$appendChange.op`, and `$pointerSet.op`. A static empty string
is still allowed when it is explicitly authored.

Use `$pointerJoin` when building document paths from dynamic path segments. Each
item is treated as one JSON Pointer segment and escaped safely:

Expand Down Expand Up @@ -379,8 +384,25 @@ $join:
| `$gt`, `$gte`, `$lt`, `$lte` | Numeric comparisons. |
| `$and`, `$or`, `$not` | Boolean logic with short-circuiting. |
| `$truthy`, `$empty` | Truthiness checks. |
| `$exists` | Return false only for undefined values. |
| `$coalesce` | First non-empty value. |

`$exists` is useful for optional-field validation because it distinguishes a
missing value from present falsy values. It returns `true` for `null`, `false`,
`0`, empty text, and empty list/object values:

```yaml
$or:
- $not:
$exists:
$event: /message/request/note
- $is:
node:
$event: /message/request/note
pattern:
type: Text
```

### Numeric

| Operator | Purpose |
Expand Down Expand Up @@ -440,6 +462,29 @@ BEX-looking operators inside Blue type-definition fields such as `type`,
| `$return` | Return the result value. |
| `$fail` | Fail deterministically. |

`$forEach` can bind list indexes and object keys when those are needed for
patch paths:

```yaml
$forEach:
in:
$event: /message/request/orders
item: order
index: i
do:
- $appendChange:
op: replace
path:
$pointerJoin:
- orders
- $var: i
- status
val: received
```

For object iteration, use `key` and `item` to bind the object key and value
separately. The older form with only `item` still binds `{ key, val }`.

## Results And Accumulators

`BexExecutionResult` contains:
Expand All @@ -453,6 +498,13 @@ 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.

`$resultValue` reads the document value after applying accumulated patches in
order. Parent reads reflect descendant object patches, so reading
`/hotelOrder` after replacing `/hotelOrder/status` returns the original
`hotelOrder` object with the updated status. Current materialization supports
object paths and list index replacement. Array insertion/removal semantics are
not modeled as full JSON Patch array shifts.

`$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.
Expand Down Expand Up @@ -480,7 +532,7 @@ The engine is compiled-first:
- static pointers are parsed at compile time;
- document and binding reads use cursor-backed values where possible;
- `$objectSet` and `$pointerSet` use overlay values;
- `$resultValue` uses an indexed overlay;
- `$resultValue` materializes accumulated patch overlays for reads;
- output conversion to `Node`, `FrozenNode`, or simple Java values is explicit.

Every result includes `BexMetrics`:
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies {

testImplementation(platform("org.junit:junit-bom:5.10.2"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.yaml:snakeyaml:1.31")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

Expand Down
25 changes: 22 additions & 3 deletions src/main/java/blue/bex/compile/BexCompiler.java
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,15 @@ private CompiledStatement compileStatement(FrozenNode statement, CompileScope sc
compileStatements(prop(body, "then"), scope, bodyPointer + "/then"),
compileStatements(prop(body, "else"), scope, bodyPointer + "/else"));
} else if ("$forEach".equals(op)) {
int slot = scope.declareOrGetSlot(requiredText(prop(body, "item"), "$forEach.item"));
String itemName = requiredText(prop(body, "item"), "$forEach.item");
String keyName = prop(body, "key") != null ? requiredText(prop(body, "key"), "$forEach.key") : null;
String indexName = prop(body, "index") != null ? requiredText(prop(body, "index"), "$forEach.index") : null;
validateDistinctForEachBindings(itemName, keyName, indexName);
int slot = scope.declareOrGetSlot(itemName);
int keySlot = keyName != null ? scope.declareOrGetSlot(keyName) : -1;
int indexSlot = indexName != null ? scope.declareOrGetSlot(indexName) : -1;
compiled = new ForEachStatement(compileExpr(required(prop(body, "in"), "$forEach.in"), scope, bodyPointer + "/in"),
slot, compileStatements(prop(body, "do"), scope, bodyPointer + "/do"));
slot, keySlot, indexSlot, compileStatements(prop(body, "do"), scope, bodyPointer + "/do"));
} else if ("$appendChange".equals(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"),
Expand Down Expand Up @@ -393,6 +399,7 @@ private CompiledExpression compileOperator(String op, FrozenNode body, CompileSc
if ("$not".equals(op)) return new NotExpr(compileExpr(body, scope, pointer));
if ("$truthy".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.TRUTHY);
if ("$empty".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.EMPTY);
if ("$exists".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.EXISTS);
if ("$coalesce".equals(op)) return new CoalesceExpr(compileExprList(body, scope, pointer));
if ("$default".equals(op)) return new CoalesceExpr(compileExprList(body, scope, pointer));
if ("$add".equals(op)) return new NumericExpr(compileExprList(body, scope, pointer), NumericOp.ADD);
Expand Down Expand Up @@ -531,7 +538,7 @@ private TextOperand textOrExpr(FrozenNode node, CompileScope scope, String defau
if (node.getValue() != null && node.getProperties() == null && node.getItems() == null) {
return new StaticTextExpr(String.valueOf(node.getValue()));
}
return new DynamicTextExpr(compileExpr(node, scope, pointer));
return new DynamicTextExpr(compileExpr(node, scope, pointer), pointer);
}

private PointerOperand pointerOperand(FrozenNode node, CompileScope scope, String pointer) {
Expand Down Expand Up @@ -671,6 +678,18 @@ private CompiledStatement sourceStatement(String functionName, String pointer, S
return new SourceStatement(BexSourcePath.of(functionName, pointer, operator), statement);
}

private void validateDistinctForEachBindings(String itemName, String keyName, String indexName) {
if (keyName != null && keyName.equals(itemName)) {
throw new BexException("$forEach.key must use a different binding name than $forEach.item");
}
if (indexName != null && indexName.equals(itemName)) {
throw new BexException("$forEach.index must use a different binding name than $forEach.item");
}
if (keyName != null && indexName != null && keyName.equals(indexName)) {
throw new BexException("$forEach.key must use a different binding name than $forEach.index");
}
}

private String escape(String segment) {
return segment.replace("~", "~0").replace("/", "~1");
}
Expand Down
18 changes: 16 additions & 2 deletions src/main/java/blue/bex/compile/BexOperands.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,16 @@ public String get(CompiledFrame frame) {

final class DynamicTextExpr implements TextOperand {
private final CompiledExpression expr;
private final String label;

DynamicTextExpr(CompiledExpression expr) {
DynamicTextExpr(CompiledExpression expr, String label) {
this.expr = expr;
this.label = label;
}

@Override
public String get(CompiledFrame frame) {
return expr.eval(frame).asText();
return TextOperands.text(expr.eval(frame), label);
}
}

Expand Down Expand Up @@ -173,3 +175,15 @@ static String pointerText(BexValue value) {
return value.asText();
}
}

final class TextOperands {
private TextOperands() {
}

static String text(BexValue value, String label) {
if (value == null || value.isUndefined() || value.isNull()) {
throw new BexException(label + " cannot be null or undefined");
}
return value.asText();
}
}
29 changes: 24 additions & 5 deletions src/main/java/blue/bex/compile/BexStatements.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import blue.bex.value.BexValue;
import blue.bex.value.BexValues;

import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -104,11 +105,15 @@ protected Control doExec(CompiledFrame frame) {
final class ForEachStatement extends Stmt {
private final CompiledExpression input;
private final int itemSlot;
private final int keySlot;
private final int indexSlot;
private final List<CompiledStatement> body;

ForEachStatement(CompiledExpression input, int itemSlot, List<CompiledStatement> body) {
ForEachStatement(CompiledExpression input, int itemSlot, int keySlot, int indexSlot, List<CompiledStatement> body) {
this.input = input;
this.itemSlot = itemSlot;
this.keySlot = keySlot;
this.indexSlot = indexSlot;
this.body = body;
}

Expand All @@ -119,10 +124,18 @@ protected Control doExec(CompiledFrame frame) {
for (String key : value.keys()) {
frame.runtime().metrics().incrementLoopIterations();
frame.runtime().gas().charge(frame.runtime().gas().schedule().forEachItem);
Map<String, BexValue> entry = new LinkedHashMap<>();
entry.put("key", BexValues.scalar(key));
entry.put("val", value.get(key));
frame.set(itemSlot, BexValues.map(entry));
if (keySlot >= 0) {
frame.set(keySlot, BexValues.scalar(key));
frame.set(itemSlot, value.get(key));
} else {
Map<String, BexValue> entry = new LinkedHashMap<>();
entry.put("key", BexValues.scalar(key));
entry.put("val", value.get(key));
frame.set(itemSlot, BexValues.map(entry));
}
if (indexSlot >= 0) {
frame.set(indexSlot, BexValues.undefined());
}
for (CompiledStatement statement : body) {
if (statement.exec(frame) == Control.RETURN) return Control.RETURN;
}
Expand All @@ -132,6 +145,12 @@ protected Control doExec(CompiledFrame frame) {
frame.runtime().metrics().incrementLoopIterations();
frame.runtime().gas().charge(frame.runtime().gas().schedule().forEachItem);
frame.set(itemSlot, value.get(String.valueOf(i)));
if (indexSlot >= 0) {
frame.set(indexSlot, BexValues.scalar(BigInteger.valueOf(i)));
}
if (keySlot >= 0) {
frame.set(keySlot, BexValues.undefined());
}
for (CompiledStatement statement : body) {
if (statement.exec(frame) == Control.RETURN) return Control.RETURN;
}
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/blue/bex/compile/TypeStringExpressions.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import java.util.Map;
import java.util.regex.Pattern;

enum UnaryOp { UNWRAP, TEXT, INTEGER, NUMBER, BOOLEAN, OBJECT, LIST, TRUTHY, EMPTY, KEYS, ENTRIES, SIZE }
enum UnaryOp { UNWRAP, TEXT, INTEGER, NUMBER, BOOLEAN, OBJECT, LIST, TRUTHY, EMPTY, EXISTS, KEYS, ENTRIES, SIZE }

final class UnaryExpr extends Expr {
private final CompiledExpression expression;
Expand Down Expand Up @@ -55,6 +55,8 @@ protected BexValue doEval(CompiledFrame frame) {
return BexValues.scalar(BexValues.truthy(value));
case EMPTY:
return BexValues.scalar(BexValues.empty(value));
case EXISTS:
return BexValues.scalar(!value.isUndefined());
case KEYS:
List<BexValue> keys = new ArrayList<>();
for (String key : value.keys()) keys.add(BexValues.scalar(key));
Expand Down
85 changes: 55 additions & 30 deletions src/main/java/blue/bex/result/BexResultOverlay.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,14 @@
import blue.language.utils.JsonPointer;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* Indexed overlay implementing observable reverse-scan $resultValue semantics.
* Ordered overlay materializing accumulated patch effects for $resultValue reads.
*/
public final class BexResultOverlay {
private final BexDocumentView document;
private final List<BexPatchEntry> entries = new ArrayList<>();
private final Map<String, BexPatchEntry> latestByPath = new LinkedHashMap<>();
private final BexMetrics metrics;

public BexResultOverlay(BexDocumentView document, BexMetrics metrics) {
Expand All @@ -26,43 +23,71 @@ public BexResultOverlay(BexDocumentView document, BexMetrics metrics) {

public void append(BexPatchEntry entry) {
entries.add(entry);
latestByPath.put(entry.absolutePath(), entry);
}

public BexValue valueAt(String absolutePointer, List<String> segments) {
if (metrics != null) {
metrics.incrementResultValueReads();
}
String pointer = JsonPointer.canonicalize(absolutePointer);
BexPatchEntry exact = latestByPath.get(pointer);
if (exact != null) {
if (metrics != null) {
metrics.incrementResultOverlayExactHits();
}
return "remove".equals(exact.op()) ? BexValues.undefined() : exact.val();
}
List<String> selected = segments != null ? segments : JsonPointer.split(pointer);
for (int length = selected.size() - 1; length >= 0; length--) {
String ancestorPointer = JsonPointer.toPointer(selected.subList(0, length));
BexPatchEntry ancestor = latestByPath.get(ancestorPointer);
if (ancestor == null) {
continue;
}
if ("remove".equals(ancestor.op())) {
if (metrics != null) {
metrics.incrementResultOverlayAncestorHits();
}
return BexValues.undefined();
}
BexValue value = ancestor.val().at(selected.subList(length, selected.size()));
if (metrics != null) {
metrics.incrementResultOverlayAncestorHits();
recordOverlayMetric(pointer, selected);
if (entries.isEmpty()) {
return document.canonicalAt(pointer);
}
BexValue materialized = document.canonicalAt("/");
for (BexPatchEntry entry : entries) {
materialized = apply(materialized, entry);
}
return materialized.at(selected);
}

private BexValue apply(BexValue root, BexPatchEntry entry) {
if (entry.absoluteSegments().isEmpty()) {
return "remove".equals(entry.op()) ? BexValues.undefined() : entry.val();
}
return BexValues.pointerSet(root, entry.absoluteSegments(), entry.val(),
"remove".equals(entry.op()) ? "remove" : "set");
}

private void recordOverlayMetric(String pointer, List<String> selected) {
if (metrics == null) {
return;
}
boolean exact = false;
boolean ancestor = false;
boolean descendant = false;
for (BexPatchEntry entry : entries) {
if (entry.absolutePath().equals(pointer)) {
exact = true;
} else if (isPrefix(entry.absoluteSegments(), selected)) {
ancestor = true;
} else if (isPrefix(selected, entry.absoluteSegments())) {
descendant = true;
}
return value;
}
if (metrics != null) {
if (exact) {
metrics.incrementResultOverlayExactHits();
return;
}
if (ancestor) {
metrics.incrementResultOverlayAncestorHits();
return;
}
if (!descendant) {
metrics.incrementResultOverlayDocumentFallbacks();
}
return document.canonicalAt(pointer);
}

private boolean isPrefix(List<String> prefix, List<String> segments) {
if (prefix.size() >= segments.size()) {
return false;
}
for (int i = 0; i < prefix.size(); i++) {
if (!prefix.get(i).equals(segments.get(i))) {
return false;
}
}
return true;
}
}
Loading
Loading