Skip to content

Commit 1bd71df

Browse files
authored
Merge pull request #265 from rush-db/feat/timeBucket-aggregations
Add timeBucket aggregations
2 parents ac3ef61 + 54f645e commit 1bd71df

File tree

7 files changed

+373
-71
lines changed

7 files changed

+373
-71
lines changed

.changeset/honest-parrots-chew.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@rushdb/javascript-sdk': minor
3+
'rushdb-core': minor
4+
'rushdb-docs': minor
5+
'rushdb-dashboard': minor
6+
'rushdb-website': minor
7+
---
8+
9+
Add timeBucket aggregations

docs/docs/concepts/search/aggregations.md

Lines changed: 112 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ The following aggregation functions are supported:
4141
- `min` - Get minimum value from a field
4242
- `sum` - Calculate sum of a numeric field
4343
- `collect` - Gather field values or entire records into an array
44+
- `timeBucket` - Bucket a datetime field into calendar intervals (day/week/month/quarter/year or custom N-month size)
4445
- `gds.similarity.*` - Calculate vector similarity using various algorithms:
4546
- `cosine` - Cosine similarity [-1,1]
4647
- `euclidean` - Euclidean distance normalized to (0,1]
@@ -57,8 +58,8 @@ The following aggregation functions are supported:
5758
{
5859
labels: ['ORDER'],
5960
aggregate: {
60-
count: { fn: 'count', alias: '$record' },
61-
avgTotal: { fn: 'avg', field: 'total', alias: '$record' }
61+
count: { fn: 'count' },
62+
avgTotal: { fn: 'avg', field: 'total' }
6263
},
6364
groupBy: ['$record.status'],
6465
orderBy: { count: 'desc' }
@@ -71,8 +72,8 @@ The following aggregation functions are supported:
7172
{
7273
labels: ['ORDER'],
7374
aggregate: {
74-
totalRevenue: { fn: 'sum', field: 'total', alias: '$record' },
75-
orderCount: { fn: 'count', alias: '$record' }
75+
totalRevenue: { fn: 'sum', field: 'total' },
76+
orderCount: { fn: 'count' }
7677
},
7778
groupBy: ['totalRevenue', 'orderCount']
7879
}
@@ -144,7 +145,7 @@ See also: [Pagination & Order guide](./pagination-order.md#ordering-with-aggrega
144145

145146
## Aliases
146147

147-
Every aggregation clause requires an `alias` parameter that specifies which record from graph traversal should be used. To reference fields from related records in aggregations, you need to define aliases in the `where` clause using the `$alias` parameter. By default, the root record has alias `$record`:
148+
Each aggregation function can specify an `alias` indicating which traversed record to read from. If you omit `alias`, RushDB defaults to the root record alias `$record`. To pull values from related records, introduce aliases in the `where` clause with `$alias` and then reference them in aggregations.
148149

149150
```typescript
150151
{
@@ -164,11 +165,7 @@ Every aggregation clause requires an `alias` parameter that specifies which reco
164165
// Referencing to root record using '$record' alias
165166
companyName: '$record.name',
166167
// Now can use $employee in aggregations
167-
avgSalary: {
168-
fn: 'avg',
169-
field: 'salary',
170-
alias: '$employee'
171-
}
168+
avgSalary: { fn: 'avg', field: 'salary', alias: '$employee' }
172169
}
173170
}
174171
```
@@ -186,7 +183,7 @@ graph LR
186183
**Parameters:**
187184
- `fn`: 'avg' - The aggregation function name
188185
- `field`: string - The field to calculate average for
189-
- `alias`: string - The record alias to use
186+
- `alias?`: string - Record alias (defaults to `$record`)
190187
- `precision?`: number - Optional decimal precision for the result
191188

192189
```typescript
@@ -214,7 +211,7 @@ graph LR
214211
### count
215212
**Parameters:**
216213
- `fn`: 'count' - The aggregation function name
217-
- `alias`: string - The record alias to use
214+
- `alias?`: string - Record alias (defaults to `$record`)
218215
- `field?`: string - Optional field to count
219216
- `unique?`: boolean - Optional flag to count unique values
220217

@@ -240,7 +237,7 @@ graph LR
240237
**Parameters:**
241238
- `fn`: 'max' - The aggregation function name
242239
- `field`: string - The field to find maximum value from
243-
- `alias`: string - The record alias to use
240+
- `alias?`: string - Record alias (defaults to `$record`)
244241

245242
```typescript
246243
{
@@ -264,7 +261,7 @@ graph LR
264261
**Parameters:**
265262
- `fn`: 'min' - The aggregation function name
266263
- `field`: string - The field to find minimum value from
267-
- `alias`: string - The record alias to use
264+
- `alias?`: string - Record alias (defaults to `$record`)
268265

269266
```typescript
270267
{
@@ -288,7 +285,7 @@ graph LR
288285
**Parameters:**
289286
- `fn`: 'sum' - The aggregation function name
290287
- `field`: string - The field to calculate sum for
291-
- `alias`: string - The record alias to use
288+
- `alias?`: string - Record alias (defaults to `$record`)
292289

293290
```typescript
294291
{
@@ -311,7 +308,7 @@ graph LR
311308
### collect
312309
**Parameters:**
313310
- `fn`: 'collect' - The aggregation function name
314-
- `alias`: string - The record alias to use
311+
- `alias?`: string - Record alias (defaults to `$record`)
315312
- `field?`: string - Optional field to collect (if not provided, collects entire records)
316313
- `unique?`: boolean - Optional flag to collect unique values only. True by default.
317314
- `limit?`: number - Optional maximum number of items to collect
@@ -337,6 +334,99 @@ graph LR
337334
}
338335
```
339336

337+
### timeBucket
338+
Temporal bucketing for datetime fields. Produces a normalized bucket start `datetime` value you can group by (and then apply other aggregations like `count`, `sum`, etc.).
339+
340+
**Parameters:**
341+
- `fn`: 'timeBucket' – Function name
342+
- `field`: string – Datetime field to bucket (must be typed as `"datetime"` in the record metadata)
343+
- `alias?`: string – Record alias to read from (defaults to `$record`)
344+
- `granularity`: 'day' | 'week' | 'month' | 'quarter' | 'year' | 'months'
345+
- Use `'months'` when you need a custom N‑month window size (see `size` below)
346+
- `size?`: number – Positive integer required only when `granularity: 'months'` (e.g. 2 = bi‑monthly, 3 = quarterly equivalent, 6 = half‑year)
347+
348+
**Behavior & Guardrails:**
349+
- RushDB checks the field's type metadata (`datetime`) before computing the bucket; if it is not a datetime field the bucket value becomes `null`.
350+
- Bucket value is the start of the interval (e.g. month bucket -> first day of month at 00:00:00, quarter -> first day of the quarter, week uses Neo4j `datetime.truncate('week', ...)`).
351+
- For `granularity: 'months'` the bucket start month is computed with: `1 + size * floor((month - 1)/size)`.
352+
- Setting `size: 3` is equivalent to `granularity: 'quarter'`.
353+
- `quarter` is provided as a semantic shortcut (3‑month periods starting at months 1,4,7,10).
354+
- Difference example: `quarter` -> buckets start at 1,4,7,10; `months` + `size:4` -> buckets start at 1,5,9 (three 4‑month buckets per year). Use `size:3` for quarter‑like grouping when using the generic mode.
355+
356+
#### Example: Daily record counts
357+
```typescript
358+
{
359+
labels: ['EVENT'],
360+
aggregate: {
361+
day: { fn: 'timeBucket', field: 'createdAt', granularity: 'day' },
362+
count: { fn: 'count' }
363+
},
364+
groupBy: ['day'],
365+
orderBy: { day: 'asc' }
366+
}
367+
```
368+
369+
#### Example: Quarterly revenue (semantic `quarter`)
370+
```typescript
371+
{
372+
labels: ['INVOICE'],
373+
aggregate: {
374+
quarterStart: { fn: 'timeBucket', field: 'issuedAt', granularity: 'quarter' },
375+
quarterlyRevenue: { fn: 'sum', field: 'amount' }
376+
},
377+
groupBy: ['quarterStart'],
378+
orderBy: { quarterStart: 'asc' }
379+
}
380+
```
381+
382+
#### Example: Custom bi‑monthly (every 2 months) active user count
383+
```typescript
384+
{
385+
labels: ['SESSION'],
386+
where: { status: 'active' },
387+
aggregate: {
388+
periodStart: { fn: 'timeBucket', field: 'startedAt', granularity: 'months', size: 2 },
389+
activeSessions: { fn: 'count' }
390+
},
391+
groupBy: ['periodStart'],
392+
orderBy: { periodStart: 'asc' }
393+
}
394+
```
395+
396+
#### Example: Half‑year (size=6) average deal value
397+
```typescript
398+
{
399+
labels: ['DEAL'],
400+
aggregate: {
401+
halfYear: { fn: 'timeBucket', field: 'closedAt', granularity: 'months', size: 6 },
402+
avgDeal: { fn: 'avg', field: 'amount', precision: 2 }
403+
},
404+
groupBy: ['halfYear'],
405+
orderBy: { halfYear: 'asc' }
406+
}
407+
```
408+
409+
#### Filtering / Null Buckets
410+
If some records lack the datetime type metadata for the chosen field, their bucket will be `null`. To exclude them, add a `where` condition ensuring the field exists and is properly typed, or post‑filter client side. (A future enhancement could expose `$notNull` filtering on aggregation outputs.)
411+
412+
#### Combining with Other Aggregations (Self‑Group)
413+
You can self‑group just by the bucket and one or more metrics:
414+
```typescript
415+
{
416+
labels: ['ORDER'],
417+
aggregate: {
418+
monthStart: { fn: 'timeBucket', field: 'createdAt', granularity: 'month' },
419+
monthlyRevenue: { fn: 'sum', field: 'total' },
420+
orderCount: { fn: 'count' }
421+
},
422+
groupBy: ['monthStart'],
423+
orderBy: { monthStart: 'asc' }
424+
}
425+
```
426+
Result rows each represent one calendar month start with aggregated metrics.
427+
428+
---
429+
340430
---
341431

342432
### Complete Example
@@ -357,45 +447,20 @@ graph LR
357447
companyName: '$record.name',
358448

359449
// Count unique employees using the defined alias
360-
employeesCount: {
361-
fn: 'count',
362-
unique: true,
363-
alias: '$employee'
364-
},
450+
employeesCount: { fn: 'count', unique: true, alias: '$employee' },
365451

366452
// Calculate total salary using the defined alias
367-
totalWage: {
368-
fn: 'sum',
369-
field: 'salary',
370-
alias: '$employee'
371-
},
453+
totalWage: { fn: 'sum', field: 'salary', alias: '$employee' },
372454

373455
// Collect unique employees names
374-
employeeNames: {
375-
fn: 'collect',
376-
field: 'name',
377-
alias: '$employee'
378-
},
456+
employeeNames: { fn: 'collect', field: 'name', alias: '$employee' },
379457

380458
// Get average salary with precision
381-
avgSalary: {
382-
fn: 'avg',
383-
field: 'salary',
384-
alias: '$employee',
385-
precision: 0
386-
},
459+
avgSalary: { fn: 'avg', field: 'salary', alias: '$employee', precision: 0 },
387460

388461
// Get min and max salary
389-
minSalary: {
390-
fn: 'min',
391-
field: 'salary',
392-
alias: '$employee'
393-
},
394-
maxSalary: {
395-
fn: 'max',
396-
field: 'salary',
397-
alias: '$employee'
398-
}
462+
minSalary: { fn: 'min', field: 'salary', alias: '$employee' },
463+
maxSalary: { fn: 'max', field: 'salary', alias: '$employee' }
399464
}
400465
}
401466
```

packages/javascript-sdk/src/types/query.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,26 @@ export type AggregateCollectNestedFn = Omit<AggregateCollectFn, 'field'> & {
9898
aggregate?: { [field: string]: AggregateCollectNestedFn }
9999
}
100100

101+
export type AggregateCountFn = { field?: string; fn: 'count'; unique?: boolean; alias?: string }
102+
103+
export type AggregateTimeBucketFn = {
104+
field: string
105+
fn: 'timeBucket'
106+
alias?: string
107+
granularity: 'day' | 'week' | 'month' | 'quarter' | 'year' | 'months'
108+
// When granularity === 'months', size (>0) defines the number of months per bucket (e.g. 2, 3, 6, 12)
109+
size?: number
110+
}
111+
101112
// eslint-disable-next-line @typescript-eslint/no-unused-vars
102113
export type AggregateFn<S extends Schema = Schema> =
103-
| { alias: string; field: string; fn: 'avg'; precision?: number }
104-
| { alias: string; field: string; fn: 'max' }
105-
| { alias: string; field: string; fn: 'min' }
106-
| { alias: string; field: string; fn: 'sum' }
107-
| { alias: string; field?: string; fn: 'count'; unique?: boolean }
108-
| { field: string; fn: `gds.similarity.${VectorSearchFn}`; alias: string; query: number[] }
114+
| { field: string; fn: 'avg'; alias?: string; precision?: number }
115+
| AggregateCountFn
116+
| { field: string; fn: 'max'; alias?: string }
117+
| { field: string; fn: 'min'; alias?: string }
118+
| { field: string; fn: 'sum'; alias?: string }
119+
| { field: string; fn: `gds.similarity.${VectorSearchFn}`; alias?: string; query: number[] }
120+
| AggregateTimeBucketFn
109121
| AggregateCollectFn
110122

111123
export type Aggregate =

platform/core/src/core/common/types.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,30 @@ export type AggregateCollectNestedFn = Omit<AggregateCollectFn, 'field'> & {
177177

178178
export type AliasesMap = Record<string, string>
179179

180-
export type AggregateCountFn = { field?: string; fn: 'count'; unique?: boolean; alias: string }
180+
export type AggregateCountFn = {
181+
field?: string
182+
fn: 'count'
183+
unique?: boolean
184+
/** Defaults to '$record' */ alias?: string
185+
}
186+
187+
export type AggregateTimeBucketFn = {
188+
field: string
189+
fn: 'timeBucket'
190+
alias?: string
191+
granularity: 'day' | 'week' | 'month' | 'quarter' | 'year' | 'months'
192+
// When granularity === 'months', size (>0) defines the number of months per bucket (e.g. 2, 3, 6, 12)
193+
size?: number
194+
}
181195

182196
export type AggregateFn<S extends Schema = Schema> =
183-
| { field: string; fn: 'avg'; alias: string; precision?: number }
197+
| { field: string; fn: 'avg'; alias?: string; precision?: number }
184198
| AggregateCountFn
185-
| { field: string; fn: 'max'; alias: string }
186-
| { field: string; fn: 'min'; alias: string }
187-
| { field: string; fn: 'sum'; alias: string }
188-
| { field: string; fn: `gds.similarity.${TVectorSearchFn}`; alias: string; query: number[] }
199+
| { field: string; fn: 'max'; alias?: string }
200+
| { field: string; fn: 'min'; alias?: string }
201+
| { field: string; fn: 'sum'; alias?: string }
202+
| { field: string; fn: `gds.similarity.${TVectorSearchFn}`; alias?: string; query: number[] }
203+
| AggregateTimeBucketFn
189204
| AggregateCollectFn
190205

191206
export type Aggregate =

0 commit comments

Comments
 (0)