|
1 | 1 | import { |
2 | 2 | AggregateExpr, |
3 | 3 | AndExpr, |
| 4 | + type AnyExpression, |
4 | 5 | BinaryExpr, |
5 | 6 | ColumnRef, |
6 | 7 | ExistsExpr, |
| 8 | + IdentifierRef, |
| 9 | + JsonArrayAggExpr, |
| 10 | + JsonObjectExpr, |
7 | 11 | ListExpression, |
8 | 12 | LiteralExpr, |
| 13 | + NotExpr, |
9 | 14 | NullCheckExpr, |
| 15 | + OperationExpr, |
10 | 16 | OrExpr, |
11 | 17 | ParamRef, |
12 | 18 | ProjectionItem, |
13 | 19 | SelectAst, |
| 20 | + SubqueryExpr, |
14 | 21 | TableSource, |
15 | 22 | } from '@prisma-next/sql-relational-core/ast'; |
16 | 23 | import { describe, expect, it } from 'vitest'; |
17 | 24 | import { compileAggregate, compileGroupedAggregate } from '../src/query-plan'; |
18 | 25 | import { bindWhereExpr } from '../src/where-binding'; |
19 | 26 | import { baseContract } from './collection-fixtures'; |
20 | 27 |
|
| 28 | +const defaultAggSpec = { |
| 29 | + totalViews: { kind: 'aggregate' as const, fn: 'sum' as const, column: 'views' }, |
| 30 | +}; |
| 31 | + |
| 32 | +function compileWithHaving(having: AnyExpression) { |
| 33 | + return compileGroupedAggregate(baseContract, 'posts', [], ['user_id'], defaultAggSpec, having); |
| 34 | +} |
| 35 | + |
21 | 36 | describe('query plan aggregate', () => { |
22 | 37 | const filteredViews = bindWhereExpr( |
23 | 38 | baseContract, |
@@ -182,4 +197,161 @@ describe('query plan aggregate', () => { |
182 | 197 | }, |
183 | 198 | ]); |
184 | 199 | }); |
| 200 | + |
| 201 | + describe('validateGroupedHavingExpr rejects non-predicate expression types', () => { |
| 202 | + it('rejects ColumnRef', () => { |
| 203 | + expect(() => compileWithHaving(ColumnRef.of('posts', 'views'))).toThrow( |
| 204 | + 'Unsupported grouped having expression kind "column-ref"', |
| 205 | + ); |
| 206 | + }); |
| 207 | + |
| 208 | + it('rejects IdentifierRef', () => { |
| 209 | + expect(() => compileWithHaving(IdentifierRef.of('some_name'))).toThrow( |
| 210 | + 'Unsupported grouped having expression kind "identifier-ref"', |
| 211 | + ); |
| 212 | + }); |
| 213 | + |
| 214 | + it('rejects SubqueryExpr', () => { |
| 215 | + const sub = SubqueryExpr.of( |
| 216 | + SelectAst.from(TableSource.named('posts')).withProjection([ |
| 217 | + ProjectionItem.of('id', ColumnRef.of('posts', 'id')), |
| 218 | + ]), |
| 219 | + ); |
| 220 | + expect(() => compileWithHaving(sub)).toThrow( |
| 221 | + 'Unsupported grouped having expression kind "subquery"', |
| 222 | + ); |
| 223 | + }); |
| 224 | + |
| 225 | + it('rejects OperationExpr', () => { |
| 226 | + const op = OperationExpr.function({ |
| 227 | + method: 'contains', |
| 228 | + forTypeId: 'pg/text@1', |
| 229 | + self: ColumnRef.of('posts', 'title'), |
| 230 | + args: [LiteralExpr.of('test')], |
| 231 | + returns: { kind: 'builtin', type: 'boolean' }, |
| 232 | + template: 'position({1} in {0}) > 0', |
| 233 | + }); |
| 234 | + expect(() => compileWithHaving(op)).toThrow( |
| 235 | + 'Unsupported grouped having expression kind "operation"', |
| 236 | + ); |
| 237 | + }); |
| 238 | + |
| 239 | + it('rejects bare AggregateExpr', () => { |
| 240 | + expect(() => compileWithHaving(AggregateExpr.count())).toThrow( |
| 241 | + 'Unsupported grouped having expression kind "aggregate"', |
| 242 | + ); |
| 243 | + }); |
| 244 | + |
| 245 | + it('rejects JsonObjectExpr', () => { |
| 246 | + const json = JsonObjectExpr.fromEntries([ |
| 247 | + JsonObjectExpr.entry('x', ColumnRef.of('posts', 'id')), |
| 248 | + ]); |
| 249 | + expect(() => compileWithHaving(json)).toThrow( |
| 250 | + 'Unsupported grouped having expression kind "json-object"', |
| 251 | + ); |
| 252 | + }); |
| 253 | + |
| 254 | + it('rejects JsonArrayAggExpr', () => { |
| 255 | + const agg = JsonArrayAggExpr.of(ColumnRef.of('posts', 'id')); |
| 256 | + expect(() => compileWithHaving(agg)).toThrow( |
| 257 | + 'Unsupported grouped having expression kind "json-array-agg"', |
| 258 | + ); |
| 259 | + }); |
| 260 | + |
| 261 | + it('rejects LiteralExpr', () => { |
| 262 | + expect(() => compileWithHaving(LiteralExpr.of(true))).toThrow( |
| 263 | + 'Unsupported grouped having expression kind "literal"', |
| 264 | + ); |
| 265 | + }); |
| 266 | + |
| 267 | + it('rejects top-level ParamRef', () => { |
| 268 | + expect(() => compileWithHaving(ParamRef.of(1, { name: 'x', codecId: 'pg/int4@1' }))).toThrow( |
| 269 | + 'ParamRef is not supported in grouped having expressions', |
| 270 | + ); |
| 271 | + }); |
| 272 | + |
| 273 | + it('rejects ListExpression', () => { |
| 274 | + expect(() => |
| 275 | + compileWithHaving(ListExpression.of([LiteralExpr.of(1), LiteralExpr.of(2)])), |
| 276 | + ).toThrow('Unsupported grouped having expression kind "list"'); |
| 277 | + }); |
| 278 | + }); |
| 279 | + |
| 280 | + describe('validateGroupedHavingExpr rejects invalid expressions inside logical operators', () => { |
| 281 | + it('rejects invalid expression inside AND', () => { |
| 282 | + expect(() => |
| 283 | + compileWithHaving( |
| 284 | + AndExpr.of([ |
| 285 | + BinaryExpr.gte(AggregateExpr.count(), LiteralExpr.of(5)), |
| 286 | + ColumnRef.of('posts', 'views'), |
| 287 | + ]), |
| 288 | + ), |
| 289 | + ).toThrow('Unsupported grouped having expression kind "column-ref"'); |
| 290 | + }); |
| 291 | + |
| 292 | + it('rejects invalid expression inside OR', () => { |
| 293 | + expect(() => |
| 294 | + compileWithHaving( |
| 295 | + OrExpr.of([ |
| 296 | + BinaryExpr.gte(AggregateExpr.count(), LiteralExpr.of(5)), |
| 297 | + LiteralExpr.of(true), |
| 298 | + ]), |
| 299 | + ), |
| 300 | + ).toThrow('Unsupported grouped having expression kind "literal"'); |
| 301 | + }); |
| 302 | + |
| 303 | + it('rejects invalid expression inside NOT', () => { |
| 304 | + expect(() => compileWithHaving(new NotExpr(AggregateExpr.count()))).toThrow( |
| 305 | + 'Unsupported grouped having expression kind "aggregate"', |
| 306 | + ); |
| 307 | + }); |
| 308 | + }); |
| 309 | + |
| 310 | + describe('validateGroupedHavingExpr accepts valid predicate expressions', () => { |
| 311 | + it('accepts NOT wrapping a valid binary', () => { |
| 312 | + const plan = compileWithHaving( |
| 313 | + new NotExpr(BinaryExpr.gte(AggregateExpr.count(), LiteralExpr.of(5))), |
| 314 | + ); |
| 315 | + expect((plan.ast as SelectAst).having).toBeInstanceOf(NotExpr); |
| 316 | + }); |
| 317 | + |
| 318 | + it('accepts NOT wrapping NullCheck', () => { |
| 319 | + const plan = compileWithHaving( |
| 320 | + new NotExpr(NullCheckExpr.isNull(AggregateExpr.sum(ColumnRef.of('posts', 'views')))), |
| 321 | + ); |
| 322 | + expect((plan.ast as SelectAst).having).toBeInstanceOf(NotExpr); |
| 323 | + }); |
| 324 | + |
| 325 | + it('accepts nested NOT(AND(binary, binary))', () => { |
| 326 | + const plan = compileWithHaving( |
| 327 | + new NotExpr( |
| 328 | + AndExpr.of([ |
| 329 | + BinaryExpr.gte(AggregateExpr.count(), LiteralExpr.of(1)), |
| 330 | + BinaryExpr.lte(AggregateExpr.sum(ColumnRef.of('posts', 'views')), LiteralExpr.of(100)), |
| 331 | + ]), |
| 332 | + ), |
| 333 | + ); |
| 334 | + expect((plan.ast as SelectAst).having).toBeInstanceOf(NotExpr); |
| 335 | + }); |
| 336 | + }); |
| 337 | + |
| 338 | + describe('validateGroupedComparable rejects invalid right-side expressions', () => { |
| 339 | + it('rejects SubqueryExpr on right side of binary', () => { |
| 340 | + const sub = SubqueryExpr.of( |
| 341 | + SelectAst.from(TableSource.named('posts')).withProjection([ |
| 342 | + ProjectionItem.of('id', ColumnRef.of('posts', 'id')), |
| 343 | + ]), |
| 344 | + ); |
| 345 | + expect(() => compileWithHaving(BinaryExpr.gte(AggregateExpr.count(), sub))).toThrow( |
| 346 | + 'Unsupported comparable kind in grouped having: "subquery"', |
| 347 | + ); |
| 348 | + }); |
| 349 | + |
| 350 | + it('rejects JsonObjectExpr on right side of binary', () => { |
| 351 | + const json = JsonObjectExpr.fromEntries([JsonObjectExpr.entry('x', LiteralExpr.of(1))]); |
| 352 | + expect(() => compileWithHaving(BinaryExpr.gte(AggregateExpr.count(), json))).toThrow( |
| 353 | + 'Unsupported comparable kind in grouped having: "json-object"', |
| 354 | + ); |
| 355 | + }); |
| 356 | + }); |
185 | 357 | }); |
0 commit comments