Skip to content

Commit c5fb8eb

Browse files
feat: Add subquery support in .where() and .having() clauses
This commit introduces support for using subqueries as parameters within the `.where()` and `.having()` methods of the modular query builder. Key changes include: - Updated `SelectBuilder.where()` to recognize `SelectAll` objects (subquery configurations) as parameters. These are replaced with unique tokens in the condition string, and their definitions are stored. - Enhanced the base `QueryBuilder` (`src/builder.ts`) to process these tokens during SQL generation. It recursively calls the main SQL compiler (`_select`) for each subquery, embedding its SQL and collecting its arguments in the correct order. This change applies to all database adapters extending `QueryBuilder` (PG, D1, DO). - Modified `src/interfaces.ts`: - `Primitive` type now includes `SelectAll`. - `SelectOne` (and `SelectAll`) now have optional `subQueryPlaceholders` and `subQueryTokenNextId` fields to manage subquery state. - Added a comprehensive suite of unit tests in `tests/unit/select.test.ts` to validate various subquery scenarios, including: - `IN (subquery)` - `EXISTS (subquery)` - `= (scalar subquery)` - Subqueries with and without parameters. - Combinations with main query parameters. - Multiple subqueries. - Subqueries in `HAVING` clauses. This allows for more complex and powerful queries to be constructed in a type-safe and organized manner.
1 parent d4ea6e4 commit c5fb8eb

File tree

4 files changed

+368
-67
lines changed

4 files changed

+368
-67
lines changed

src/builder.ts

Lines changed: 168 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,32 @@ export class QueryBuilder<GenericResultWrapper, IsAsync extends boolean = true>
110110
fetchOne<GenericResult = DefaultReturnObject>(
111111
params: SelectOne
112112
): QueryWithExtra<GenericResultWrapper, OneResult<GenericResultWrapper, GenericResult>, IsAsync> {
113+
const queryArgs: any[] = []
114+
const countQueryArgs: any[] = [] // Separate args for count query
115+
116+
// Ensure subQueryPlaceholders are passed to the count query as well, if they exist on params
117+
const selectParamsForCount: SelectAll = {
118+
...params,
119+
fields: 'count(*) as total',
120+
offset: undefined,
121+
groupBy: undefined,
122+
limit: 1,
123+
}
124+
if (params.subQueryPlaceholders) {
125+
selectParamsForCount.subQueryPlaceholders = params.subQueryPlaceholders;
126+
}
127+
128+
129+
const mainSql = this._select({ ...params, limit: 1 } as SelectAll, queryArgs)
130+
const countSql = this._select(selectParamsForCount, countQueryArgs)
131+
113132
return new QueryWithExtra(
114133
(q) => {
115134
return this.execute(q)
116135
},
117-
this._select({ ...params, limit: 1 }),
118-
this._select({
119-
...params,
120-
fields: 'count(*) as total',
121-
offset: undefined,
122-
groupBy: undefined,
123-
limit: 1,
124-
}),
125-
typeof params.where === 'object' && !Array.isArray(params.where) && params.where?.params
126-
? Array.isArray(params.where?.params)
127-
? params.where?.params
128-
: [params.where?.params]
129-
: undefined,
136+
mainSql,
137+
countSql,
138+
queryArgs, // Use the populated queryArgs from the main _select call
130139
FetchTypes.ONE
131140
)
132141
}
@@ -138,6 +147,27 @@ export class QueryBuilder<GenericResultWrapper, IsAsync extends boolean = true>
138147
ArrayResult<GenericResultWrapper, GenericResult, IsAsync, P extends { lazy: true } ? true : false>,
139148
IsAsync
140149
> {
150+
const queryArgs: any[] = []
151+
const countQueryArgs: any[] = [] // Separate args for count query
152+
153+
const mainQueryParams = { ...params, lazy: undefined }
154+
155+
// Ensure subQueryPlaceholders are passed to the count query as well
156+
const countQueryParams: SelectAll = {
157+
...params,
158+
fields: 'count(*) as total',
159+
offset: undefined,
160+
groupBy: undefined,
161+
limit: 1,
162+
lazy: undefined,
163+
}
164+
if (params.subQueryPlaceholders) {
165+
countQueryParams.subQueryPlaceholders = params.subQueryPlaceholders;
166+
}
167+
168+
const mainSql = this._select(mainQueryParams, queryArgs)
169+
const countSql = this._select(countQueryParams, countQueryArgs)
170+
141171
return new QueryWithExtra(
142172
(q) => {
143173
return params.lazy
@@ -147,20 +177,9 @@ export class QueryBuilder<GenericResultWrapper, IsAsync extends boolean = true>
147177
>)
148178
: this.execute(q)
149179
},
150-
this._select({ ...params, lazy: undefined }),
151-
this._select({
152-
...params,
153-
fields: 'count(*) as total',
154-
offset: undefined,
155-
groupBy: undefined,
156-
limit: 1,
157-
lazy: undefined,
158-
}),
159-
typeof params.where === 'object' && !Array.isArray(params.where) && params.where?.params
160-
? Array.isArray(params.where?.params)
161-
? params.where?.params
162-
: [params.where?.params]
163-
: undefined,
180+
mainSql,
181+
countSql,
182+
queryArgs, // Use the populated queryArgs from the main _select call
164183
FetchTypes.ALL
165184
)
166185
}
@@ -415,14 +434,28 @@ export class QueryBuilder<GenericResultWrapper, IsAsync extends boolean = true>
415434
)
416435
}
417436

418-
protected _select(params: SelectAll): string {
437+
protected _select(params: SelectAll, queryArgs?: any[]): string {
438+
const isTopLevelCall = queryArgs === undefined
439+
if (isTopLevelCall) {
440+
queryArgs = []
441+
}
442+
443+
// This assertion tells TypeScript that queryArgs is definitely assigned after the block above.
444+
const currentQueryArgs = queryArgs!
445+
446+
const context = {
447+
subQueryPlaceholders: params.subQueryPlaceholders,
448+
queryArgs: currentQueryArgs,
449+
toSQLCompiler: this._select.bind(this),
450+
}
451+
419452
return (
420453
`SELECT ${this._fields(params.fields)}
421454
FROM ${params.tableName}` +
422-
this._join(params.join) +
423-
this._where(params.where) +
455+
this._join(params.join, context) +
456+
this._where(params.where, context) +
424457
this._groupBy(params.groupBy) +
425-
this._having(params.having) +
458+
this._having(params.having, context) +
426459
this._orderBy(params.orderBy) +
427460
this._limit(params.limit) +
428461
this._offset(params.offset)
@@ -436,40 +469,109 @@ export class QueryBuilder<GenericResultWrapper, IsAsync extends boolean = true>
436469
return value.join(', ')
437470
}
438471

439-
protected _where(value?: Where): string {
472+
protected _where(
473+
value: Where | undefined,
474+
context: {
475+
subQueryPlaceholders?: Record<string, SelectAll>
476+
queryArgs: any[]
477+
// Allow toSQLCompiler to be undefined for calls not originating from _select, though practically it should always be provided.
478+
toSQLCompiler?: (params: SelectAll, queryArgs: any[]) => string
479+
}
480+
): string {
440481
if (!value) return ''
441-
let conditions = value
482+
483+
let conditionStrings: string[]
484+
let primitiveParams: any[] = []
442485

443486
if (typeof value === 'object' && !Array.isArray(value)) {
444-
conditions = value.conditions
487+
conditionStrings = Array.isArray(value.conditions) ? value.conditions : [value.conditions]
488+
if (value.params) {
489+
primitiveParams = Array.isArray(value.params) ? value.params : [value.params]
490+
}
491+
} else if (Array.isArray(value)) {
492+
conditionStrings = value
493+
} else {
494+
// Assuming value is a single string condition
495+
conditionStrings = [value as string]
445496
}
446497

447-
if (typeof conditions === 'string') return ` WHERE ${conditions.toString()}`
448-
449-
if ((conditions as Array<string>).length === 1) return ` WHERE ${(conditions as Array<string>)[0]!.toString()}`
498+
if (conditionStrings.length === 0) return ''
499+
500+
let primitiveParamIndex = 0
501+
const processedConditions: string[] = []
502+
503+
for (const conditionStr of conditionStrings) {
504+
// Regex to split by token or by '?'
505+
const parts = conditionStr.split(/(__SUBQUERY_TOKEN_\d+__|\?)/g).filter(Boolean)
506+
let builtCondition = ''
507+
508+
for (const part of parts) {
509+
if (part === '?') {
510+
if (primitiveParamIndex >= primitiveParams.length) {
511+
throw new Error('SQL generation error: Not enough primitive parameters for "?" placeholders in WHERE clause.')
512+
}
513+
context.queryArgs.push(primitiveParams[primitiveParamIndex++])
514+
builtCondition += '?'
515+
} else if (part.startsWith('__SUBQUERY_TOKEN_') && part.endsWith('__')) {
516+
if (!context.subQueryPlaceholders || !context.toSQLCompiler) {
517+
throw new Error('SQL generation error: Subquery context not provided for token processing.')
518+
}
519+
const subQueryParams = context.subQueryPlaceholders[part]
520+
if (!subQueryParams) {
521+
throw new Error(`SQL generation error: Subquery token ${part} not found in placeholders.`)
522+
}
523+
builtCondition += context.toSQLCompiler(subQueryParams, context.queryArgs)
524+
} else {
525+
builtCondition += part
526+
}
527+
}
528+
// Wrap each individual condition processed this way, as SelectBuilder.where() might send multiple conditions.
529+
// The original logic for multiple conditions was: `WHERE (${(conditions as Array<string>).join(') AND (')})`
530+
// So, we wrap each one and then join by AND.
531+
processedConditions.push(`(${builtCondition})`)
532+
}
450533

451-
if ((conditions as Array<string>).length > 1) {
452-
return ` WHERE (${(conditions as Array<string>).join(') AND (')})`
534+
if (primitiveParamIndex < primitiveParams.length && primitiveParams.length > 0) { // Check primitiveParams.length to avoid error if no params were expected
535+
throw new Error('SQL generation error: Too many primitive parameters provided for "?" placeholders in WHERE clause.')
453536
}
454537

455-
return ''
538+
if (processedConditions.length === 0) return ''
539+
return ` WHERE ${processedConditions.join(' AND ')}`
456540
}
457541

458-
protected _join(value?: Join | Array<Join>): string {
542+
protected _join(
543+
value: Join | Array<Join> | undefined,
544+
context: {
545+
// subQueryPlaceholders are not directly used by _join for its own structure,
546+
// but toSQLCompiler will need them if item.table is a SelectAll object
547+
// that itself has subQueryPlaceholders.
548+
subQueryPlaceholders?: Record<string, SelectAll>
549+
queryArgs: any[]
550+
toSQLCompiler: (params: SelectAll, queryArgs: any[]) => string
551+
}
552+
): string {
459553
if (!value) return ''
460554

555+
let joinArray: Join[]
461556
if (!Array.isArray(value)) {
462-
value = [value]
557+
joinArray = [value]
558+
} else {
559+
joinArray = value
463560
}
464561

465562
const joinQuery: Array<string> = []
466-
value.forEach((item: Join) => {
563+
joinArray.forEach((item: Join) => {
467564
const type = item.type ? `${item.type} ` : ''
468-
joinQuery.push(
469-
`${type}JOIN ${typeof item.table === 'string' ? item.table : `(${this._select(item.table)})`}${
470-
item.alias ? ` AS ${item.alias}` : ''
471-
} ON ${item.on}`
472-
)
565+
let tableSql: string
566+
if (typeof item.table === 'string') {
567+
tableSql = item.table
568+
} else {
569+
// Subquery in JOIN. item.table is SelectAll in this case.
570+
// The toSQLCompiler (this._select) will handle any '?' or tokens within this subquery,
571+
// and push its arguments to context.queryArgs.
572+
tableSql = `(${context.toSQLCompiler(item.table, context.queryArgs)})`
573+
}
574+
joinQuery.push(`${type}JOIN ${tableSql}${item.alias ? ` AS ${item.alias}` : ''} ON ${item.on}`)
473575
})
474576

475577
return ' ' + joinQuery.join(' ')
@@ -482,11 +584,26 @@ export class QueryBuilder<GenericResultWrapper, IsAsync extends boolean = true>
482584
return ` GROUP BY ${value.join(', ')}`
483585
}
484586

485-
protected _having(value?: string | Array<string>): string {
587+
protected _having(
588+
value: Where | undefined, // Using Where type as Having structure is similar for conditions/params
589+
context: {
590+
subQueryPlaceholders?: Record<string, SelectAll>
591+
queryArgs: any[]
592+
toSQLCompiler?: (params: SelectAll, queryArgs: any[]) => string
593+
}
594+
): string {
486595
if (!value) return ''
487-
if (typeof value === 'string') return ` HAVING ${value}`
488596

489-
return ` HAVING ${value.join(' AND ')}`
597+
// Re-use the _where logic for building HAVING clause structure.
598+
// The _where method already handles token/param processing and populates context.queryArgs.
599+
const whereEquivalentString = this._where(value, context)
600+
601+
if (whereEquivalentString.startsWith(' WHERE ')) {
602+
return ` HAVING ${whereEquivalentString.substring(' WHERE '.length)}`
603+
}
604+
// If _where returned empty (e.g., no conditions) or an unexpected format,
605+
курорт // return an empty string for HAVING as well.
606+
return ''
490607
}
491608

492609
protected _orderBy(value?: string | Array<string> | Record<string, string | OrderTypes>): string {

src/interfaces.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ConflictTypes, FetchTypes, JoinTypes, OrderTypes } from './enums'
22
import { Raw } from './tools'
33
import { Merge } from './typefest'
44

5-
export type Primitive = null | string | number | boolean | bigint | Raw
5+
export type Primitive = null | string | number | boolean | bigint | Raw | SelectAll
66

77
export type QueryLoggerMeta = {
88
duration?: number
@@ -40,6 +40,7 @@ export type SelectOne = {
4040
having?: string | Array<string>
4141
orderBy?: string | Array<string> | Record<string, string | OrderTypes>
4242
offset?: number
43+
subQueryPlaceholders?: Record<string, SelectAll>
4344
}
4445

4546
export type RawQuery = {

src/modularBuilder.ts

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,28 +53,66 @@ export class SelectBuilder<GenericResultWrapper, GenericResult = DefaultReturnOb
5353
conditions: string | Array<string>,
5454
params?: Primitive | Primitive[]
5555
): SelectBuilder<GenericResultWrapper, GenericResult, IsAsync> {
56-
if (!Array.isArray(conditions)) {
57-
conditions = [conditions]
58-
}
59-
if (params === undefined) params = []
60-
if (!Array.isArray(params)) {
61-
params = [params]
62-
}
63-
64-
if ((this._options.where as any)?.conditions) {
65-
conditions = (this._options.where as any).conditions.concat(conditions)
56+
// Ensure _options has the necessary fields for subquery handling
57+
this._options.subQueryPlaceholders = this._options.subQueryPlaceholders ?? {}
58+
this._options.subQueryTokenNextId = this._options.subQueryTokenNextId ?? 0
59+
60+
const existingConditions = (this._options.where?.conditions ?? []) as string[]
61+
const existingParams = (this._options.where?.params ?? []) as Primitive[]
62+
63+
const currentInputConditions = Array.isArray(conditions) ? conditions : [conditions]
64+
const currentInputParams = params === undefined ? [] : Array.isArray(params) ? params : [params]
65+
66+
const processedNewConditions: string[] = []
67+
const collectedPrimitiveParams: Primitive[] = []
68+
let paramIndex = 0
69+
70+
for (const conditionStr of currentInputConditions) {
71+
if (!conditionStr.includes('?')) {
72+
processedNewConditions.push(conditionStr)
73+
continue
74+
}
75+
76+
const conditionParts = conditionStr.split('?')
77+
let builtCondition = conditionParts[0]
78+
79+
for (let j = 0; j < conditionParts.length - 1; j++) {
80+
if (paramIndex >= currentInputParams.length) {
81+
throw new Error('Mismatch between "?" placeholders and parameters in where clause.')
82+
}
83+
const currentParam = currentInputParams[paramIndex++]
84+
85+
// Check if currentParam is a SelectAll object (subquery)
86+
// It should be an object, not null, have a 'tableName' property, and not be a 'Raw' object.
87+
const isSubQuery =
88+
typeof currentParam === 'object' &&
89+
currentParam !== null &&
90+
'tableName' in currentParam &&
91+
!currentParam.hasOwnProperty('_raw')
92+
93+
if (isSubQuery) {
94+
const token = `__SUBQUERY_TOKEN_${this._options.subQueryTokenNextId++}__`
95+
this._options.subQueryPlaceholders[token] = currentParam as SelectAll
96+
builtCondition += `(${token})`
97+
} else {
98+
builtCondition += '?'
99+
collectedPrimitiveParams.push(currentParam)
100+
}
101+
builtCondition += conditionParts[j + 1]
102+
}
103+
processedNewConditions.push(builtCondition)
66104
}
67105

68-
if ((this._options.where as any)?.params) {
69-
params = (this._options.where as any).params.concat(params)
106+
if (paramIndex < currentInputParams.length) {
107+
throw new Error('Too many parameters provided for the given "?" placeholders in where clause.')
70108
}
71109

72110
return new SelectBuilder<GenericResultWrapper, GenericResult, IsAsync>(
73111
{
74-
...this._options,
112+
...this._options, // subQueryPlaceholders and subQueryTokenNextId are already updated on _options
75113
where: {
76-
conditions: conditions,
77-
params: params,
114+
conditions: existingConditions.concat(processedNewConditions),
115+
params: existingParams.concat(collectedPrimitiveParams),
78116
},
79117
},
80118
this._fetchAll,

0 commit comments

Comments
 (0)