Support binding record-id ranges as query parameters#195
Conversation
Binding a RecordIdRange as a query parameter silently went through the
generic POJO converter and produced an object like
{ end: 100, start: 1, table: 'person' } instead of a record-id range;
binding a Value read from a previous result produced { ptr: <address> }.
Add ValueMut.createRecordIdRange(RecordIdRange) (inclusive bounds,
null = unbounded, mirroring select(RecordIdRange)), a general
ValueMut.createRange(start, end, startIncluded, endIncluded) building a
native range value, and ValueMut.createValue(Value) cloning an immutable
Value so results can be re-bound. Wire all three into ValueBuilder and
ValueBoxing so query(sql, params), nested collections, Array.of() and
Id.from() accept them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
emmanuel-keller
left a comment
There was a problem hiding this comment.
Review — approve with nits.
Bound inclusivity is correct: createRecordIdRange reuses the exact build_range_value path as select/delete/update, so a bound RecordIdRange is byte-for-byte identical to the select(range) path (Bound::Included, null → Bound::Unbounded), and createRange maps the include flags to Included/Excluded correctly. Memory handling matches the established newArrayOf/createArray move pattern (consume via take_value_mut_instance! + Java .moved()), and the borrow-and-clone factories leave the source reusable. No unwrap/panic in the new Rust.
Test gaps: exclusive-start with a real value (1>..10) is the one inclusivity path with no test; an end-unbounded general range (1..); and general ranges are asserted only structurally (isRange()/getRangeStart/End) rather than by filtering rows and checking endpoint membership (record-id ranges do assert membership, so this is minor).
| if start_included { | ||
| Bound::Included(value) | ||
| } else { | ||
| Bound::Excluded(value) |
There was a problem hiding this comment.
The exclusive-start branch (Bound::Excluded(value) for a bounded start, i.e. 1>..10) is the one inclusivity path with no test. Given exclusive bounds are the whole point of this general factory, worth one assertion.
There was a problem hiding this comment.
Added valueRangeBindParamExclusiveStart in ValueTypesTests (58b8f6a): binds createRange(1, 10, false, true) as a param and asserts the round-tripped value renders as 1>..=10 with the expected getRangeStart/getRangeEnd values. The > prefix in the text form confirms the Bound::Excluded start branch was taken (an inclusive start would render 1..=10).
| * @param endIncluded | ||
| * whether the end bound is inclusive | ||
| */ | ||
| public static ValueMut createRange(ValueMut start, ValueMut end, boolean startIncluded, boolean endIncluded) { |
There was a problem hiding this comment.
Minor: when start/end is null the corresponding startIncluded/endIncluded flag is silently ignored (correctly, since unbounded has no inclusivity). A one-line Javadoc note would save a caller from expecting it to matter.
There was a problem hiding this comment.
Done in 58b8f6a — the createRange Javadoc now notes that a null bound is unbounded and has no inclusivity, so the corresponding startIncluded/endIncluded flag is ignored.
…clusivity for null bounds Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JS-parity gap
In the JS SDK,
RecordIdRangeandRange(packages/sqon:record-id-range.ts,value/range.ts) are first-class values that can be passed directly as query bindings. The Java SDK could only read ranges from results (Value.isRange()/getRangeStart()/getRangeEnd()) and useRecordIdRangewith the dedicatedselect/update/delete/upsertmethods — there was no way to create/bind a range value:ValueMuthad no range factory andValueBuilderdid not recognizeRecordIdRange.Reproduction (before this change)
No exception is thrown in either case — the parameters are silently bound as garbage objects.
Fix
ValueMut.createRecordIdRange(RecordIdRange)— new JNI exportJava_com_surrealdb_ValueMut_newRecordIdRangebuilds the same native value asselect(RecordIdRange)(reusesbuild_range_valueinsurreal.rs: aRecordIdwith aRecordIdKey::Rangekey, inclusive bounds,null= unbounded). TheRecordIdRangeand itsIdbounds stay reusable.ValueMut.createRange(ValueMut start, ValueMut end, boolean startIncluded, boolean endIncluded)— general range factory (Value::RangewithBound::Included/Excluded/Unbounded), matching the JSRangewithBoundIncluded/BoundExcluded.nullstart/end = unbounded; bound values are moved, consistent withcreateArray(List<ValueMut>).ValueMut.createValue(Value)— clones an immutableValueinto aValueMut, so a value read from a result (e.g. a range fromRETURN 1..10) can be re-bound as a parameter instead of being POJO-converted into{ ptr: ... }.ValueBuilder(used byquery(sql, params), so nested use in collections/maps falls out naturally) andValueBoxing(used byArray.of(...)/Id.from(...)).Tests
RecordIdRangeTests: binding aRecordIdRangeparam toSELECT * FROM $rreturns exactly the same records asselect(RecordIdRange)overperson:1..person:5(bounded, unbounded start, unbounded end);RETURN $ryields the record idperson:1..=100; nested use inside aListparam.ValueTypesTests:RETURN $rround-trips for general ranges —1..10,1..=10, and..10(unbounded start) — verified viaisRange()/getRangeStart()/getRangeEnd()and SQL text; re-binding a rangeValueread from a previous query.Full suite: 342 tests green.
🤖 Generated with Claude Code