Skip to content

Support binding record-id ranges as query parameters#195

Open
welpie21 wants to merge 3 commits into
surrealdb:mainfrom
welpie21:feature/range-bind-params
Open

Support binding record-id ranges as query parameters#195
welpie21 wants to merge 3 commits into
surrealdb:mainfrom
welpie21:feature/range-bind-params

Conversation

@welpie21

Copy link
Copy Markdown

JS-parity gap

In the JS SDK, RecordIdRange and Range (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 use RecordIdRange with the dedicated select/update/delete/upsert methods — there was no way to create/bind a range value: ValueMut had no range factory and ValueBuilder did not recognize RecordIdRange.

Reproduction (before this change)

surreal.query("RETURN $r",
    Collections.singletonMap("r", new RecordIdRange("person", Id.from(1), Id.from(100))));
// => binds the object { end: 100, start: 1, table: 'person' }  (generic POJO conversion)

Value range = surreal.query("RETURN 1..10").take(0);   // isRange() == true
surreal.query("RETURN $r", Collections.singletonMap("r", range));
// => binds the object { ptr: 34681013008 }  (the native pointer field, POJO-converted)

No exception is thrown in either case — the parameters are silently bound as garbage objects.

Fix

  • ValueMut.createRecordIdRange(RecordIdRange) — new JNI export Java_com_surrealdb_ValueMut_newRecordIdRange builds the same native value as select(RecordIdRange) (reuses build_range_value in surreal.rs: a RecordId with a RecordIdKey::Range key, inclusive bounds, null = unbounded). The RecordIdRange and its Id bounds stay reusable.
  • ValueMut.createRange(ValueMut start, ValueMut end, boolean startIncluded, boolean endIncluded) — general range factory (Value::Range with Bound::Included/Excluded/Unbounded), matching the JS Range with BoundIncluded/BoundExcluded. null start/end = unbounded; bound values are moved, consistent with createArray(List<ValueMut>).
  • ValueMut.createValue(Value) — clones an immutable Value into a ValueMut, so a value read from a result (e.g. a range from RETURN 1..10) can be re-bound as a parameter instead of being POJO-converted into { ptr: ... }.
  • Wired all three into ValueBuilder (used by query(sql, params), so nested use in collections/maps falls out naturally) and ValueBoxing (used by Array.of(...) / Id.from(...)).

Tests

  • RecordIdRangeTests: binding a RecordIdRange param to SELECT * FROM $r returns exactly the same records as select(RecordIdRange) over person:1..person:5 (bounded, unbounded start, unbounded end); RETURN $r yields the record id person:1..=100; nested use inside a List param.
  • ValueTypesTests: RETURN $r round-trips for general ranges — 1..10, 1..=10, and ..10 (unbounded start) — verified via isRange()/getRangeStart()/getRangeEnd() and SQL text; re-binding a range Value read from a previous query.

Full suite: 342 tests green.

🤖 Generated with Claude Code

welpie21 and others added 2 commits July 10, 2026 14:53
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>
@welpie21 welpie21 marked this pull request as ready for review July 10, 2026 14:26

@emmanuel-keller emmanuel-keller left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread src/main/rust/valuemut.rs
if start_included {
Bound::Included(value)
} else {
Bound::Excluded(value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants