Skip to content

Commit e1eacc3

Browse files
committed
Enhance documentation to clarify support for nested object and array properties, including details on dotted-path filtering and indexing limitations. Update sections on property types, query capabilities, and examples for TypeScript and Rust to reflect the new features and best practices for handling nested structures. Ensure consistency across guides and improve user understanding of property handling in queries and mutations.
1 parent 38795ec commit e1eacc3

11 files changed

Lines changed: 387 additions & 14 deletions

File tree

database/data-model.mdx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,17 @@ Nodes and edges are identified by 64-bit unsigned IDs. Edges are directed: each
1313
node and a target node. Labels are stored as the reserved `$label` property and are used for
1414
type-based filtering and label-scoped secondary, vector, and text indexes.
1515

16-
Properties are strongly typed. Supported types: boolean, integer, floating-point, string, bytes,
17-
and array variants of each.
16+
Properties are strongly typed. Supported types include boolean, integer,
17+
floating-point, string, bytes, typed primitive arrays, generic arrays, and object
18+
maps. Object maps may be nested; query property names such as
19+
`metadata.externalID` read nested fields with exact-first dotted-path lookup. A
20+
stored top-level property literally named `metadata.externalID` wins over the
21+
nested `metadata.externalID` path during scans.
22+
23+
Nested object fields are queryable in filters and projections, but V1 indexing is
24+
top-level only. Keep secondary, text, and vector indexed values as top-level
25+
properties. Generic arrays are stored as values and returned as values, but array
26+
index path syntax such as `tags.0` is not supported.
1827

1928
## Multigraph
2029

database/indexing/secondary.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ storage. Only index properties that are frequently filtered on. Unindexed proper
1717
queryable via `.where_(Predicate::...)`, but the route will scan the label rather than seek the
1818
index.
1919

20+
Indexes address top-level properties only. A query may filter a nested object field with a dotted
21+
path such as `metadata.externalID`, but that filter is scan-only in V1 and cannot be declared as an
22+
equality or range index. Store frequently indexed metadata as explicit top-level properties.
23+
2024
The DSL fragments below are bare traversals. To execute them, wrap each one in a `read_batch()`
2125
or `write_batch()` route as shown in [Querying](/database/querying).
2226

database/indexing/text.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ Text indexes provide BM25 full-text search on node and edge properties. They are
88
like other indexes rather than rebuilt transiently per query. Indexed values can be `String` or
99
`StringArray`, with configurable analyzer presets and optional term positions.
1010

11+
Text index properties are top-level properties. Nested object fields are not flattened into BM25;
12+
store searchable text directly on the property you pass to the text-search builder.
13+
1114
The third argument to the index-creation builders is an optional tenant property name; pass
1215
`None::<&str>` for a global index. Tenant-partitioned text indexes currently require the
1316
partition property name to be `tenant_id` — see [Multi-Tenancy](/database/multi-tenancy) for the

database/indexing/vector.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ Helix supports vector indexes on both nodes and edges. Values are normalized to
99
indexing, and supported distance metrics are cosine, euclidean, and manhattan. The query vector
1010
must match the configured index dimension exactly.
1111

12+
Vector index properties are top-level properties. Nested object fields are not indexed as vectors;
13+
store embeddings directly on the node or edge property you pass to the vector-search builder.
14+
1215
Vector search is approximate by design, trading a small amount of recall for significantly faster
1316
search over large datasets. The third argument to the index-creation builders is an optional
1417
tenant property name; pass `None::<&str>` for a global index, or see

database/limits.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ fundamental architectural boundaries.
1212
| Limit | Value |
1313
| ---------------------- | ---------------------------------------------------------------------------------------------- |
1414
| Node and edge ID range | 64-bit unsigned integer (max 2^63) |
15-
| Property value types | boolean, integer, float, string, bytes, and array variants |
16-
| Nested structures | Not supported. Properties are flat key-value pairs. |
15+
| Property value types | boolean, integer, float, string, bytes, typed primitive arrays, generic arrays, and objects |
16+
| Nested structures | Stored object/array values are supported. Dotted-path lookup such as `metadata.externalID` works in scan-time filters, expressions, projections, `values`, filtered `valueMap`, and fallback ordering. Arrays are opaque; there is no array-index path syntax. |
1717
| Reserved property keys | `$label` (used for label-based filtering and label-scoped secondary, vector, and text indexes) |
1818

1919
## Vector Indexes
@@ -32,7 +32,7 @@ fundamental architectural boundaries.
3232
| Limit | Value |
3333
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3434
| Scope | Node and edge properties |
35-
| Supported property types | `String` and `StringArray` |
35+
| Supported property types | Top-level `String` and `StringArray` properties. Nested object fields are not flattened for BM25. |
3636
| Unsupported values | `null` and non-string values are rejected |
3737
| Analyzer config | Preset analyzers: `standard`, `standard_stem_en`, `whitespace_lowercase` |
3838
| Term positions | Optional |
@@ -42,8 +42,8 @@ fundamental architectural boundaries.
4242

4343
| Limit | Value |
4444
| ---------------- | ---------------------------------------------------------------------------------------------------------- |
45-
| Equality indexes | Supported on node and edge properties |
46-
| Range indexes | Supported on numeric and string properties |
45+
| Equality indexes | Supported on top-level node and edge properties. Nested dotted paths are scan-only in V1. |
46+
| Range indexes | Supported on top-level numeric and string properties. Nested dotted paths are scan-only in V1. |
4747
| Encoding | Range indexes use lexicographic string encoding. Values must be encoded consistently for correct ordering. |
4848

4949
## Queries

database/querying-guide/advanced.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,10 @@ A few notes specific to `forEachParam`:
455455
- The parameter must be typed `{"Array": "Object"}` — an array of plain JSON
456456
objects. `defineParams({ users: param.array(param.object(...)) })` produces this
457457
shape automatically.
458+
- Each loop iteration exposes the current object's top-level fields as scoped
459+
parameters. If a field itself is an object, pass it through as a whole property
460+
value, for example `PropertyInput.param("metadata")`, then query the stored
461+
nested fields later with dotted paths such as `metadata.externalID`.
458462

459463
## Next Steps
460464

database/querying-guide/filtering.mdx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,68 @@ Use a `SourcePredicate` when you want the filter to participate in index selecti
176176
the source step. Use `.where(Predicate.*)` when you need anything outside that set,
177177
or when you want the filter to run *after* a graph traversal step.
178178

179+
## Nested property paths
180+
181+
Object properties can be filtered with dotted paths. The runtime first checks for
182+
an exact top-level property name, then walks nested object fields when the name
183+
contains `.`.
184+
185+
<CodeGroup>
186+
```ts TypeScript
187+
import { g, Predicate, readBatch } from "@helix-db/helix-db";
188+
189+
const usersByExternalId = readBatch()
190+
.varAs(
191+
"users",
192+
g()
193+
.nWithLabel("User")
194+
.where(Predicate.eq("metadata.externalID", "crm-42"))
195+
.valueMap(["$id", "username", "metadata.externalID"]),
196+
)
197+
.returning(["users"]);
198+
```
199+
200+
```rust Rust
201+
use helix_db::dsl::prelude::*;
202+
203+
#[register]
204+
pub fn users_by_external_id() -> ReadBatch {
205+
read_batch()
206+
.var_as(
207+
"users",
208+
g().n_with_label("User")
209+
.where_(Predicate::eq("metadata.externalID", "crm-42"))
210+
.value_map(Some(vec!["$id", "username", "metadata.externalID"])),
211+
)
212+
.returning(["users"])
213+
}
214+
```
215+
216+
```json JSON
217+
{
218+
"queries": [
219+
{
220+
"Query": {
221+
"name": "users",
222+
"steps": [
223+
{ "NWhere": { "Eq": ["$label", { "String": "User" }] } },
224+
{ "Where": { "Eq": ["metadata.externalID", { "String": "crm-42" }] } },
225+
{ "ValueMap": ["$id", "username", "metadata.externalID"] }
226+
],
227+
"condition": null
228+
}
229+
}
230+
],
231+
"returns": ["users"]
232+
}
233+
```
234+
</CodeGroup>
235+
236+
Dotted paths are scan-only in V1. Pair them with an indexed top-level anchor such
237+
as label, tenant, or status when the label is large; the indexed predicate narrows
238+
candidate rows and the dotted predicate runs as a residual filter. Arrays are
239+
opaque, so `metadata.tags.0` is not supported.
240+
179241
## Parameter-bound predicates
180242

181243
To filter by a value the caller supplies at request time, use the `*Param` family.

database/querying-guide/mutations.mdx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,121 @@ A few wire-format details:
9595
- The result of `addN` is a node stream, so the same chain can keep going —
9696
here, `.project([...])` collects the new id.
9797

98+
## Nested object and array properties
99+
100+
Node and edge properties can store object and array values. Use nested objects for
101+
metadata you want to return or filter by scan-time dotted paths; keep frequently
102+
indexed values as top-level properties.
103+
104+
<CodeGroup>
105+
```ts TypeScript
106+
import { g, writeBatch } from "@helix-db/helix-db";
107+
108+
const addUserWithMetadata = writeBatch()
109+
.varAs(
110+
"user",
111+
g()
112+
.addN("User", {
113+
username: "alice",
114+
metadata: {
115+
externalID: "crm-42",
116+
score: 20,
117+
tags: ["trial", 7],
118+
preferences: { locale: "en-US" },
119+
},
120+
})
121+
.valueMap(["username", "metadata.externalID", "metadata.preferences.locale"]),
122+
)
123+
.returning(["user"]);
124+
```
125+
126+
```rust Rust
127+
use helix_db::dsl::prelude::*;
128+
129+
#[register]
130+
pub fn add_user_with_metadata() -> WriteBatch {
131+
let metadata = PropertyValue::object(vec![
132+
("externalID", PropertyValue::from("crm-42")),
133+
("score", PropertyValue::from(20i64)),
134+
(
135+
"tags",
136+
PropertyValue::array(vec![
137+
PropertyValue::from("trial"),
138+
PropertyValue::from(7i64),
139+
]),
140+
),
141+
(
142+
"preferences",
143+
PropertyValue::object(vec![("locale", PropertyValue::from("en-US"))]),
144+
),
145+
]);
146+
147+
write_batch()
148+
.var_as(
149+
"user",
150+
g().add_n(
151+
"User",
152+
vec![
153+
("username", PropertyInput::from("alice")),
154+
("metadata", PropertyInput::from(metadata)),
155+
],
156+
)
157+
.value_map(Some(vec![
158+
"username",
159+
"metadata.externalID",
160+
"metadata.preferences.locale",
161+
])),
162+
)
163+
.returning(["user"])
164+
}
165+
```
166+
167+
```json JSON
168+
{
169+
"queries": [
170+
{
171+
"Query": {
172+
"name": "user",
173+
"steps": [
174+
{
175+
"AddN": {
176+
"label": "User",
177+
"properties": [
178+
["username", { "Value": { "String": "alice" } }],
179+
[
180+
"metadata",
181+
{
182+
"Value": {
183+
"Object": {
184+
"externalID": { "String": "crm-42" },
185+
"score": { "I64": 20 },
186+
"tags": { "Array": [{ "String": "trial" }, { "I64": 7 }] },
187+
"preferences": {
188+
"Object": { "locale": { "String": "en-US" } }
189+
}
190+
}
191+
}
192+
}
193+
]
194+
]
195+
}
196+
},
197+
{ "ValueMap": ["username", "metadata.externalID", "metadata.preferences.locale"] }
198+
],
199+
"condition": null
200+
}
201+
}
202+
],
203+
"returns": ["user"]
204+
}
205+
```
206+
</CodeGroup>
207+
208+
Nested property lookup is exact-first. A top-level property literally named
209+
`metadata.externalID` is read before walking the nested `metadata` object. Dotted
210+
paths only walk object values; arrays are returned as values and do not support
211+
path syntax such as `tags.0`.
212+
98213
## Adding edges between bound nodes
99214

100215
`addE` joins two nodes by label. The `to` argument is a `NodeRef` — most often

database/querying-guide/parameters-bundles.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,36 @@ A complete envelope produced by `toDynamicJson`:
340340
}
341341
```
342342

343+
Nested object and array parameter values are still bare JSON at envelope
344+
position. When a parameter is used as a property value, the runtime converts that
345+
JSON object into a stored object property:
346+
347+
```ts
348+
const params = defineParams({ metadata: param.object(param.value()) });
349+
350+
writeBatch().varAs(
351+
"user",
352+
g().addN("User", { metadata: PropertyInput.param("metadata") }),
353+
);
354+
```
355+
356+
```json
357+
{
358+
"parameters": {
359+
"metadata": {
360+
"externalID": "crm-42",
361+
"preferences": { "locale": "en-US" },
362+
"tags": ["trial", 7]
363+
}
364+
},
365+
"parameter_types": { "metadata": "Object" }
366+
}
367+
```
368+
369+
After storage, query nested object fields with dotted paths such as
370+
`metadata.externalID`. The top-level `parameters` map itself does not use tagged
371+
`PropertyValue` wrappers.
372+
343373
## Number handling: `bigint` for `i64`
344374

345375
JavaScript `number` only has 53 bits of integer precision. The DSL accepts a plain

database/querying-guide/projections.mdx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,21 @@ pub fn users_for_admin_panel() -> ReadBatch {
154154
always available even though they are not declared on your schema. `$from`/`$to`
155155
appear on edge streams; `$distance` appears on vector / text search hits.
156156

157+
Filtered `values(...)` and `valueMap(...)` also accept dotted paths into object
158+
properties, such as `metadata.externalID`. `valueMap(null)` returns stored
159+
top-level properties as-is and does not flatten nested objects. When you request a
160+
dotted path explicitly, the returned object is keyed by the requested source
161+
string unless you rename it with `project(...)`.
162+
163+
```ts
164+
g().nWithLabel("User").valueMap(["$id", "metadata.externalID"]);
165+
```
166+
167+
```rust
168+
g().n_with_label("User")
169+
.value_map(Some(vec!["$id", "metadata.externalID"]));
170+
```
171+
157172
## `.project(...)` with renames and expressions
158173

159174
`project([...])` is the most flexible terminal — it accepts a list of items, each one
@@ -166,6 +181,25 @@ either:
166181
Use it when you want to flatten ids out from under `$id`, return a different name
167182
than the schema's, or compute a derived field.
168183

184+
`PropertyProjection` and `Expr.prop(...)` use the same property lookup rules as
185+
filters. This means nested object fields can be projected and renamed:
186+
187+
```ts
188+
g().nWithLabel("User").project([
189+
PropertyProjection.renamed("metadata.externalID", "external_id"),
190+
]);
191+
```
192+
193+
```rust
194+
g().n_with_label("User").project(vec![
195+
PropertyProjection::renamed("metadata.externalID", "external_id"),
196+
]);
197+
```
198+
199+
Dotted paths are also valid in fallback ordering, for example
200+
`.orderBy("metadata.score", Order.Desc)`, but they are not backed by secondary
201+
indexes in V1.
202+
169203
A typical `project` call mixes plain property pulls with one or two renames:
170204

171205
<CodeGroup>

0 commit comments

Comments
 (0)