Range queries let you efficiently find documents whose property values fall within a specified range. triple_slice and quad_slice push range constraints directly into the storage engine for O(log n) lookups instead of scanning every triple.
Why Range Queries Matter
When you need to find documents within a time window, a numeric band, or a lexicographic string range, the traditional WOQL approach requires fetching every value and filtering in the query layer:
// The slow way: fetch all timestamps, then filter
let v = Vars("doc", "time");
and(
triple(v.doc, "timestamp", v.time),
greater(v.time, literal("2025-01-01T00:00:00Z", "xsd:dateTime")),
less(v.time, literal("2026-01-01T00:00:00Z", "xsd:dateTime"))
)This works, but it materializes every triple for the timestamp predicate before discarding the ones outside the range. For large datasets, this is prohibitively slow.
triple_slice solves this by binary-searching the sorted value dictionary and iterating only the matching range. The storage engine never touches values outside your bounds.
When to Use triple_slice
- Time-series data: Find events, logs, or measurements within a time window
- Numeric filtering: Select records in a price band, age range, or score bracket
- String ranges: Lexicographic slicing for alphabetical partitions
- Membership checks: Verify that a known value falls within expected bounds
What triple_slice Does
Signature
triple_slice(Subject, Predicate, Object, Low, High)
quad_slice(Subject, Predicate, Object, Low, High, Graph)triple_slice is a superset of triple. It adds two optional bound parameters, Low and High, that constrain the Object to a half-open range [Low, High). When both bounds are unbound, it behaves identically to triple.
quad_slice is the same predicate with an explicit graph selector (like quad extends triple).
Half-Open Range [Low, High)
- Low is inclusive: the first matching value is >= Low
- High is exclusive: all matching values are strictly < High
- Adjacent slices
[A, B)and[B, C)partition the space cleanly with no overlap and no gaps
Binding Modes
triple_slice works as both a generator (when Object is unbound, it produces matching triples) and a pattern matcher (when Object is ground, it checks membership).
| Object | Low | High | Behavior |
|---|---|---|---|
| unbound | unbound | unbound | Same as triple — generates all triples. Low and High unify with Object. |
| unbound | bound | unbound | Generates triples where Object >= Low. |
| unbound | unbound | bound | Generates triples where Object < High. |
| unbound | bound | bound | Generates triples where Low <= Object < High. |
| ground | unbound | unbound | Same as triple — membership check. Low and High unify with Object. |
| ground | bound | unbound | Checks triple exists AND Object >= Low. Fails if Object < Low. |
| ground | unbound | bound | Checks triple exists AND Object < High. Fails if Object >= High. |
| ground | bound | bound | Checks triple exists AND Low <= Object < High. Fails if out of range, even if the triple exists. |
The key subtlety: when Object is ground and the triple exists but the value falls outside the range, the predicate fails. The range constraint always takes precedence.
Automatic Type Inference
When the predicate is ground (which is the common case), triple_slice infers the correct XSD type from the schema and automatically casts untyped bounds. You do not need explicit typecast calls.
For example, if the schema declares timestamp as xsd:dateTime, then the string "2025-01-01" is automatically cast to xsd:dateTime. If the cast fails (e.g., "not-a-date" to xsd:dateTime), an error is raised — there are no silent failures.
When the predicate is unbound, bounds must either be already typed or be unbound variables.
Supported Types
All data is stored in ordered form with succinct data structures, including the XSD types. Examples of the ordered types that can be used with triple_slice:
- Date/Time:
xsd:dateTime,xsd:dateTimeStamp,xsd:date,xsd:time - Numeric:
xsd:integer,xsd:decimal,xsd:float,xsd:double,xsd:nonNegativeInteger,xsd:positiveInteger,xsd:long,xsd:int,xsd:short,xsd:byte - String:
xsd:string
How to Use triple_slice — Worked Examples
The following examples use a schema with a SensorReading class:
{
"@type": "Class",
"@id": "SensorReading",
"@key": { "@type": "Random" },
"sensor_id": "xsd:string",
"timestamp": "xsd:dateTime",
"temperature": "xsd:decimal",
"label": { "@type": "Optional", "@class": "xsd:string" }
}With sample data: five readings across January 2025 with temperatures from 18.5 to 23.1 and labels "A" through "E".
Example 1 — Classic DateTime Range
Find all sensor readings from the first half of January 2025.
let v = Vars("doc", "time");
triple_slice(v.doc, "timestamp", v.time, "2025-01-01T00:00:00Z", "2025-01-15T00:00:00Z")What happens: The engine looks up timestamp in the schema, sees it is xsd:dateTime, casts the string bounds to xsd:dateTime, and binary-searches the value dictionary. Only readings with timestamps in [Jan 1, Jan 15) are returned.
Example 2 — Open-Ended High: From a Date Onward
Find all readings from January 20 onward (no upper bound).
let v = Vars("doc", "time");
triple_slice(v.doc, "timestamp", v.time, "2025-01-20T00:00:00Z")With Low bound and High unbound, this generates all triples where timestamp >= Jan 20.
Example 3 — Open-Ended Low: Before a Date
Find all readings before January 10.
let v = Vars("doc", "time", "low");
and(
eq(v.low, literal("2025-01-10T00:00:00Z", "xsd:dateTime")),
triple_slice(v.doc, "timestamp", v.time, null, v.low)
)With Low unbound and High bound, this generates all triples where timestamp < Jan 10.
Example 4 — No Bounds: Degenerates to triple
When neither Low nor High are set, triple_slice behaves identically to triple.
let v = Vars("doc", "time", "low", "high");
triple_slice(v.doc, "timestamp", v.time, v.low, v.high)Returns all timestamp triples. For each result, low and high both unify with the timestamp value, so low == high == time.
Example 5 — Membership Check: Object In Range
Check that a specific reading's timestamp falls within the expected window.
let v = Vars("doc");
and(
isa(v.doc, "SensorReading"),
triple_slice(v.doc, "timestamp",
literal("2025-01-10T08:30:00Z", "xsd:dateTime"),
"2025-01-01T00:00:00Z", "2025-01-15T00:00:00Z")
)The Object is ground. The predicate checks: does this triple exist AND does 2025-01-10T08:30:00Z fall in [Jan 1, Jan 15)? If yes, succeeds. If the timestamp exists but is outside the range, fails.
Example 6 — Membership Check: Object Out of Range
This demonstrates the key subtlety: a triple can exist but still fail the range check.
let v = Vars("doc");
and(
isa(v.doc, "SensorReading"),
triple_slice(v.doc, "timestamp",
literal("2025-01-25T14:00:00Z", "xsd:dateTime"),
"2025-01-01T00:00:00Z", "2025-01-15T00:00:00Z")
)The timestamp Jan 25 exists in the database, but it is outside [Jan 1, Jan 15), so the predicate fails. This is not an error — it is correct behavior. The range constraint takes precedence over triple existence.
Example 7 — Numeric Range: Integer Values
Find readings with temperature between 19.0 and 22.0.
let v = Vars("doc", "temp");
triple_slice(v.doc, "temperature", v.temp, "19.0", "22.0")The engine infers xsd:decimal from the schema for temperature and casts the string bounds accordingly.
Example 8 — String Range: Lexicographic Slice
Find readings with labels in the lexicographic range [B, D).
let v = Vars("doc", "lbl");
triple_slice(v.doc, "label", v.lbl, "B", "D")Returns labels "B" and "C" (but not "D", since the high bound is exclusive).
Example 9 — Adjacent Non-Overlapping Slices
Two adjacent slices partition the data cleanly — no double-counting, no gaps.
let v = Vars("doc", "time");
// First half of January
let slice1 = triple_slice(v.doc, "timestamp", v.time,
"2025-01-01T00:00:00Z", "2025-01-15T00:00:00Z");
// Second half of January
let slice2 = triple_slice(v.doc, "timestamp", v.time,
"2025-01-15T00:00:00Z", "2025-02-01T00:00:00Z");The union of slice1 and slice2 equals the full month of January. A reading at exactly 2025-01-15T00:00:00Z appears only in slice2 (since Low is inclusive and High is exclusive).
Example 10 — Unbound Predicate with Typed Bounds
When the predicate is unbound, auto-type-inference cannot determine the target type. Bounds must be explicitly typed.
let v = Vars("doc", "pred", "val");
triple_slice(v.doc, v.pred, v.val,
literal("2025-01-01T00:00:00Z", "xsd:dateTime"),
literal("2025-01-15T00:00:00Z", "xsd:dateTime"))This iterates over all predicates on each document, returning any triple whose object is a xsd:dateTime value in the given range.
Example 11 — Before and After: The Old Way vs triple_slice
Before (full scan + filter):
let v = Vars("doc", "time");
and(
triple(v.doc, "timestamp", v.time),
greater(v.time, literal("2025-01-01T00:00:00Z", "xsd:dateTime")),
less(v.time, literal("2025-01-15T00:00:00Z", "xsd:dateTime"))
)After (range pushed to storage):
let v = Vars("doc", "time");
triple_slice(v.doc, "timestamp", v.time,
"2025-01-01T00:00:00Z", "2025-01-15T00:00:00Z")Both return the same results. The triple_slice version binary-searches the value dictionary and never touches values outside the range.
Example 12 — quad_slice on Instance Graph
quad_slice adds an explicit graph parameter, just like quad extends triple.
let v = Vars("doc", "time");
quad_slice(v.doc, "timestamp", v.time,
"2025-01-01T00:00:00Z", "2025-01-15T00:00:00Z",
"instance")Equivalent to triple_slice but explicitly targets the instance graph.
Example 13 — quad_slice on Schema Graph
Query the schema graph for range information.
let v = Vars("cls", "pred", "val");
quad_slice(v.cls, v.pred, v.val,
literal("A", "xsd:string"),
literal("M", "xsd:string"),
"schema")Searches the schema graph for any triple whose object is a string in [A, M).
Reference
Error Conditions
- Invalid cast: Bounds that cannot be cast to the predicate's type produce a clear error (e.g.,
"not-a-date"for anxsd:dateTimepredicate) - Unbound predicate + untyped bounds: If the predicate is unbound and bounds are plain strings (not typed), an error is raised since the target type cannot be inferred
- Non-existent predicate: Behaves like
triple— no results, no error
Performance Characteristics
- Binary search: O(log n) to locate range bounds in the sorted value dictionary
- Streaming: Results are yielded one at a time — memory usage is O(1) regardless of range size
- No full scan: Values outside the range are never touched
- Layer stack: Each layer in the stack requires a separate binary search; rollups improve performance for deep stacks