Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [Unreleased]

### Fixed

- **The query builder's aggregates emitted SQL-style calls SurrealDB refuses.** `count()`, `sum()`, `avg()`, `min()`, and `max()` on `ReadQL` (what `client.query(...)` returns) and on `AggregationQueryBuilder` rendered `COUNT(*)`, `SUM(f)`, `AVG(f)`, `MIN(f)`, and `MAX(f)`. SurrealQL has none of these: `COUNT(*)` is a parse error because the parser takes no `*` argument, and `SUM` / `AVG` / `MIN` / `MAX` are not function names (`Invalid function/constant path`), so every query that used one of these methods failed before it ran. A row count now renders `count()`, a field count renders `count(field)` (the rows where that field is truthy), and the others render `math::sum(f)`, `math::mean(f)`, `math::min(f)`, and `math::max(f)`. The column aliases are unchanged (`count`, `sum_<field>`, `avg_<field>`, `min_<field>`, `max_<field>`), and `count('*')` is still accepted and renders `count()`.

The aggregate calls inside the `having(...)` examples (the JSDoc, `docs/queries.md`, `docs/examples/index.md`, and `examples/`) used the same SQL spellings and are corrected the same way.

## [1.8.0] - 2026-08-12

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const stats = await client.query<SaleStats>('sales')
.count()
.sum('amount')
.avg('amount')
.having('SUM(amount)', Op.GREATER_THAN, 10000)
.having('math::sum(amount)', Op.GREATER_THAN, 10000)
.orderBy('sum_amount', SortDirection.DESC)
.execute()
```
Expand Down
4 changes: 2 additions & 2 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ const highValue = await client.query('orders')
.groupBy('customer_id')
.sum('total')
.count()
.having('SUM(total)', Op.GREATER_THAN, 1000)
.having('COUNT(*)', Op.GREATER_THAN, 5)
.having('math::sum(total)', Op.GREATER_THAN, 1000)
.having('count()', Op.GREATER_THAN, 5)
.execute()
```

Expand Down
4 changes: 2 additions & 2 deletions examples/basicCrud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const premiumInsights = await client.query('orders')
.sum('total_amount')
.count('order_id')
.avg('order_value')
.having('SUM(total_amount)', Op.GREATER_THAN, 10000)
.having('COUNT(order_id)', Op.GREATER_THAN, 20)
.having('math::sum(total_amount)', Op.GREATER_THAN, 10000)
.having('count(order_id)', Op.GREATER_THAN, 20)
.orderBy('sum_total_amount', SortDirection.DESC)
.page(1, 50)
.execute()
Expand Down
2 changes: 1 addition & 1 deletion examples/userManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const userStats = await client.query('user_sessions')
.groupBy('device_type', 'location')
.count('session_id')
.sum('duration_minutes')
.having('COUNT(session_id)', Op.GREATER_THAN, 5)
.having('count(session_id)', Op.GREATER_THAN, 5)
.orderBy('sum_duration_minutes', SortDirection.DESC)
.execute()

Expand Down
35 changes: 21 additions & 14 deletions src/capabilities/aggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ export abstract class AggregationQueryBuilder<R extends { id: RecordId }, T> ext
private aggregations: string[] = []

/**
* Add COUNT aggregation
* Add a `count()` aggregation, aliased `count`
*
* @param field - Field to count (defaults to '*' for all records)
* With no field this renders `count()`, the number of rows in each group. With a
* field it renders `count(field)`, the number of rows whose value for that field
* is truthy. `'*'` is accepted as a synonym for "no field" and also renders
* `count()`; SurrealQL has no `COUNT(*)` form.
*
* @param field - Field to count (omit to count every row)
* @returns this - For method chaining
* @example
* const results = await query('orders')
Expand All @@ -35,16 +40,18 @@ export abstract class AggregationQueryBuilder<R extends { id: RecordId }, T> ext
* .count('category')
* .execute()
*/
count(field = '*'): this {
if (field !== '*') {
this.validateFieldName(field)
count(field?: string): this {
if (field === undefined || field === '*') {
this.aggregations.push('count() as count')
return this
}
this.aggregations.push(`COUNT(${field}) as count`)
this.validateFieldName(field)
this.aggregations.push(`count(${field}) as count`)
return this
}

/**
* Add SUM aggregation
* Add a `math::sum()` aggregation, aliased `sum_<field>`
*
* @param field - Field to sum
* @returns this - For method chaining
Expand All @@ -56,12 +63,12 @@ export abstract class AggregationQueryBuilder<R extends { id: RecordId }, T> ext
*/
sum(field: string): this {
this.validateFieldName(field)
this.aggregations.push(`SUM(${field}) as sum_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
this.aggregations.push(`math::sum(${field}) as sum_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
return this
}

/**
* Add AVG aggregation
* Add a `math::mean()` aggregation, aliased `avg_<field>`
*
* @param field - Field to average
* @returns this - For method chaining
Expand All @@ -73,12 +80,12 @@ export abstract class AggregationQueryBuilder<R extends { id: RecordId }, T> ext
*/
avg(field: string): this {
this.validateFieldName(field)
this.aggregations.push(`AVG(${field}) as avg_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
this.aggregations.push(`math::mean(${field}) as avg_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
return this
}

/**
* Add MIN aggregation
* Add a `math::min()` aggregation, aliased `min_<field>`
*
* @param field - Field to find minimum value
* @returns this - For method chaining
Expand All @@ -90,12 +97,12 @@ export abstract class AggregationQueryBuilder<R extends { id: RecordId }, T> ext
*/
min(field: string): this {
this.validateFieldName(field)
this.aggregations.push(`MIN(${field}) as min_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
this.aggregations.push(`math::min(${field}) as min_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
return this
}

/**
* Add MAX aggregation
* Add a `math::max()` aggregation, aliased `max_<field>`
*
* @param field - Field to find maximum value
* @returns this - For method chaining
Expand All @@ -107,7 +114,7 @@ export abstract class AggregationQueryBuilder<R extends { id: RecordId }, T> ext
*/
max(field: string): this {
this.validateFieldName(field)
this.aggregations.push(`MAX(${field}) as max_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
this.aggregations.push(`math::max(${field}) as max_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
return this
}

Expand Down
6 changes: 3 additions & 3 deletions src/capabilities/having.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ export abstract class HavingQueryBuilder<R extends { id: RecordId }, T> extends
* // Direct condition
* const results = await query('orders')
* .groupBy('customer_id')
* .having('COUNT(*) > 5')
* .having('count() > 5')
* .execute()
*
* // Fluent style
* const results = await query('sales')
* .groupBy('product_id')
* .having('SUM(amount)', Op.GREATER_THAN, 1000)
* .having('math::sum(amount)', Op.GREATER_THAN, 1000)
* .execute()
*/
having(conditionOrField: string, operator?: Op, value?: unknown): this {
Expand All @@ -52,7 +52,7 @@ export abstract class HavingQueryBuilder<R extends { id: RecordId }, T> extends
}
this.havingConditions.push(`${conditionOrField} ${operator} $${paramName}`)
} else {
// Direct condition: having('COUNT(*) > 10')
// Direct condition: having('count() > 10')
this.validateHavingCondition(conditionOrField)
this.havingConditions.push(conditionOrField)
}
Expand Down
39 changes: 23 additions & 16 deletions src/crud/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,13 @@ export class ReadQL<R extends { id: RecordId }, T = unknown> extends QueryBuilde
* // Direct condition
* const results = await query('orders')
* .groupBy('customer_id')
* .having('COUNT(*) > 5')
* .having('count() > 5')
* .execute()
*
* // Fluent style
* const results = await query('sales')
* .groupBy('product_id')
* .having('SUM(amount)', Op.GREATER_THAN, 1000)
* .having('math::sum(amount)', Op.GREATER_THAN, 1000)
* .execute()
*/
having(conditionOrField: string, operator?: Op, value?: unknown): this {
Expand All @@ -247,26 +247,33 @@ export class ReadQL<R extends { id: RecordId }, T = unknown> extends QueryBuilde
}

/**
* Add COUNT aggregation
* Add a `count()` aggregation, aliased `count`
*
* @param field - Field to count (defaults to '*' for all records)
* With no field this renders `count()`, the number of rows in each group. With a
* field it renders `count(field)`, the number of rows whose value for that field
* is truthy. `'*'` is accepted as a synonym for "no field" and also renders
* `count()`; SurrealQL has no `COUNT(*)` form.
*
* @param field - Field to count (omit to count every row)
* @returns this - For method chaining
* @example
* const results = await query('orders')
* .groupBy('customer_id')
* .count()
* .execute()
*/
count(field = '*'): this {
if (field !== '*') {
this.validateFieldName(field)
count(field?: string): this {
if (field === undefined || field === '*') {
this.aggregations.push('count() as count')
return this
}
this.aggregations.push(`COUNT(${field}) as count`)
this.validateFieldName(field)
this.aggregations.push(`count(${field}) as count`)
return this
}

/**
* Add SUM aggregation
* Add a `math::sum()` aggregation, aliased `sum_<field>`
*
* @param field - Field to sum
* @returns this - For method chaining
Expand All @@ -278,12 +285,12 @@ export class ReadQL<R extends { id: RecordId }, T = unknown> extends QueryBuilde
*/
sum(field: string): this {
this.validateFieldName(field)
this.aggregations.push(`SUM(${field}) as sum_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
this.aggregations.push(`math::sum(${field}) as sum_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
return this
}

/**
* Add AVG aggregation
* Add a `math::mean()` aggregation, aliased `avg_<field>`
*
* @param field - Field to average
* @returns this - For method chaining
Expand All @@ -295,12 +302,12 @@ export class ReadQL<R extends { id: RecordId }, T = unknown> extends QueryBuilde
*/
avg(field: string): this {
this.validateFieldName(field)
this.aggregations.push(`AVG(${field}) as avg_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
this.aggregations.push(`math::mean(${field}) as avg_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
return this
}

/**
* Add MIN aggregation
* Add a `math::min()` aggregation, aliased `min_<field>`
*
* @param field - Field to find minimum value
* @returns this - For method chaining
Expand All @@ -312,12 +319,12 @@ export class ReadQL<R extends { id: RecordId }, T = unknown> extends QueryBuilde
*/
min(field: string): this {
this.validateFieldName(field)
this.aggregations.push(`MIN(${field}) as min_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
this.aggregations.push(`math::min(${field}) as min_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
return this
}

/**
* Add MAX aggregation
* Add a `math::max()` aggregation, aliased `max_<field>`
*
* @param field - Field to find maximum value
* @returns this - For method chaining
Expand All @@ -329,7 +336,7 @@ export class ReadQL<R extends { id: RecordId }, T = unknown> extends QueryBuilde
*/
max(field: string): this {
this.validateFieldName(field)
this.aggregations.push(`MAX(${field}) as max_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
this.aggregations.push(`math::max(${field}) as max_${field.replace(/[^a-zA-Z0-9]/g, '_')}`)
return this
}

Expand Down
52 changes: 35 additions & 17 deletions src/test/capabilities_extended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,25 @@ class TestPaginationBuilder<R extends { id: RecordId }, T> extends PaginationQue

describe('AggregationQueryBuilder', () => {
describe('count()', () => {
it('should add COUNT(*) aggregation by default', () => {
it('should add a count() row count by default', () => {
const builder = new TestAggregationBuilder(mockConnectionProvider, 'orders')
builder.count()
const fields = builder.testBuildAggregationFields()
assertEquals(fields, ['COUNT(*) as count'])
assertEquals(fields, ['count() as count'])
})

it('should add COUNT(field) aggregation when field is given', () => {
it("should treat '*' as a row count and render count(), never COUNT(*)", () => {
const builder = new TestAggregationBuilder(mockConnectionProvider, 'orders')
builder.count('*')
const fields = builder.testBuildAggregationFields()
assertEquals(fields, ['count() as count'])
})

it('should add count(field) when field is given', () => {
const builder = new TestAggregationBuilder(mockConnectionProvider, 'orders')
builder.count('customer_id')
const fields = builder.testBuildAggregationFields()
assertEquals(fields, ['COUNT(customer_id) as count'])
assertEquals(fields, ['count(customer_id) as count'])
})

it('should return this for chaining', () => {
Expand All @@ -101,11 +108,11 @@ describe('AggregationQueryBuilder', () => {
})

describe('sum()', () => {
it('should add SUM aggregation with alias', () => {
it('should add a math::sum() aggregation with alias', () => {
const builder = new TestAggregationBuilder(mockConnectionProvider, 'orders')
builder.sum('total_amount')
const fields = builder.testBuildAggregationFields()
assertEquals(fields, ['SUM(total_amount) as sum_total_amount'])
assertEquals(fields, ['math::sum(total_amount) as sum_total_amount'])
})

it('should return this for chaining', () => {
Expand All @@ -120,11 +127,11 @@ describe('AggregationQueryBuilder', () => {
})

describe('avg()', () => {
it('should add AVG aggregation with alias', () => {
it('should add a math::mean() aggregation with alias', () => {
const builder = new TestAggregationBuilder(mockConnectionProvider, 'orders')
builder.avg('price')
const fields = builder.testBuildAggregationFields()
assertEquals(fields, ['AVG(price) as avg_price'])
assertEquals(fields, ['math::mean(price) as avg_price'])
})

it('should return this for chaining', () => {
Expand All @@ -134,11 +141,11 @@ describe('AggregationQueryBuilder', () => {
})

describe('min()', () => {
it('should add MIN aggregation with alias', () => {
it('should add a math::min() aggregation with alias', () => {
const builder = new TestAggregationBuilder(mockConnectionProvider, 'products')
builder.min('price')
const fields = builder.testBuildAggregationFields()
assertEquals(fields, ['MIN(price) as min_price'])
assertEquals(fields, ['math::min(price) as min_price'])
})

it('should return this for chaining', () => {
Expand All @@ -148,11 +155,11 @@ describe('AggregationQueryBuilder', () => {
})

describe('max()', () => {
it('should add MAX aggregation with alias', () => {
it('should add a math::max() aggregation with alias', () => {
const builder = new TestAggregationBuilder(mockConnectionProvider, 'products')
builder.max('order_date')
const fields = builder.testBuildAggregationFields()
assertEquals(fields, ['MAX(order_date) as max_order_date'])
assertEquals(fields, ['math::max(order_date) as max_order_date'])
})

it('should return this for chaining', () => {
Expand All @@ -167,11 +174,22 @@ describe('AggregationQueryBuilder', () => {
builder.count().sum('revenue').avg('discount').min('price').max('price')
const fields = builder.testBuildAggregationFields()
assertEquals(fields.length, 5)
assertEquals(fields[0], 'COUNT(*) as count')
assertEquals(fields[1], 'SUM(revenue) as sum_revenue')
assertEquals(fields[2], 'AVG(discount) as avg_discount')
assertEquals(fields[3], 'MIN(price) as min_price')
assertEquals(fields[4], 'MAX(price) as max_price')
assertEquals(fields[0], 'count() as count')
assertEquals(fields[1], 'math::sum(revenue) as sum_revenue')
assertEquals(fields[2], 'math::mean(discount) as avg_discount')
assertEquals(fields[3], 'math::min(price) as min_price')
assertEquals(fields[4], 'math::max(price) as max_price')
})

it('should never render an SQL-style aggregate SurrealDB fails to parse', () => {
const builder = new TestAggregationBuilder(mockConnectionProvider, 'orders')
builder.count().count('*').count('paid').sum('revenue').avg('discount').min('price').max('price')
const rejected = [/COUNT\(/, /\(\*\)/, /(?<!::)\b(?:sum|avg|min|max)\(/i]
for (const field of builder.testBuildAggregationFields()) {
for (const pattern of rejected) {
assertEquals(pattern.test(field), false, `${field} matches ${pattern}`)
}
}
})
})

Expand Down
Loading
Loading