Skip to content

Commit 60c4c78

Browse files
feat: Add support for D1 JSON functions in WHERE clause
This commit introduces the ability to use Cloudflare D1's JSON functions (e.g., json_extract, json_array_length, json_type) within the `.where()` method of the modular query builder. A new `json(expression, ...bindings)` helper function has been added to allow users to specify JSON operations. The `where` method has been updated to process these expressions and integrate them correctly into the generated SQL query along with their parameters. Relevant interfaces have been updated (`WhereClause`, `WhereInput`) to support these changes, and comprehensive unit tests have been added to verify the new functionality across various scenarios.
1 parent d4ea6e4 commit 60c4c78

File tree

4 files changed

+213
-34
lines changed

4 files changed

+213
-34
lines changed

src/interfaces.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ export type QueryBuilderOptions<IsAsync extends boolean = true> = {
1515
export type DefaultObject = Record<string, Primitive>
1616
export type DefaultReturnObject = Record<string, null | string | number | boolean | bigint>
1717

18-
export type Where =
18+
export type WhereClause = {
19+
conditions: Array<string>
20+
params: Array<Primitive>
21+
}
22+
23+
// The Where type for individual calls can still be flexible,
24+
// but internally SelectOne and SelectAll will use WhereClause.
25+
export type WhereInput =
1926
| {
2027
conditions: string | Array<string>
2128
// TODO: enable named parameters with DefaultObject
@@ -34,7 +41,7 @@ export type Join = {
3441
export type SelectOne = {
3542
tableName: string
3643
fields?: string | Array<string>
37-
where?: Where
44+
where?: WhereClause // Changed from Where to WhereClause
3845
join?: Join | Array<Join>
3946
groupBy?: string | Array<string>
4047
having?: string | Array<string>
@@ -66,7 +73,7 @@ export type SelectAll = SelectOne & {
6673
export type ConflictUpsert = {
6774
column: string | Array<string>
6875
data: DefaultObject
69-
where?: Where
76+
where?: WhereInput
7077
}
7178

7279
export type Insert = {
@@ -93,7 +100,7 @@ export type test<I extends Insert = Insert> = I
93100
export type Update = {
94101
tableName: string
95102
data: DefaultObject
96-
where?: Where
103+
where?: WhereInput
97104
returning?: string | Array<string>
98105
onConflict?: string | ConflictTypes
99106
}
@@ -105,7 +112,7 @@ export type UpdateWithoutReturning = Omit<Update, 'returning'>
105112

106113
export type Delete = {
107114
tableName: string
108-
where: Where // This field is optional, but is kept required in type to warn users of delete without where
115+
where: WhereInput // This field is optional, but is kept required in type to warn users of delete without where
109116
returning?: string | Array<string>
110117
orderBy?: string | Array<string> | Record<string, string | OrderTypes>
111118
limit?: number

src/modularBuilder.ts

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import {
77
Primitive,
88
SelectAll,
99
SelectOne,
10+
WhereClause,
1011
} from './interfaces'
11-
import { Query, QueryWithExtra } from './tools'
12+
import { JsonExpression, Query, QueryWithExtra, Raw } from './tools'
1213

1314
export interface SelectExecuteOptions {
1415
lazy?: boolean
@@ -49,32 +50,71 @@ export class SelectBuilder<GenericResultWrapper, GenericResult = DefaultReturnOb
4950
return this._parseArray('fields', this._options.fields, fields)
5051
}
5152

53+
// Overload signatures for the where method
5254
where(
53-
conditions: string | Array<string>,
55+
condition: string,
5456
params?: Primitive | Primitive[]
57+
): SelectBuilder<GenericResultWrapper, GenericResult, IsAsync>
58+
where(
59+
conditions: Array<string>,
60+
params?: Primitive | Primitive[]
61+
): SelectBuilder<GenericResultWrapper, GenericResult, IsAsync>
62+
where(
63+
field: string | JsonExpression,
64+
operator: string,
65+
value: Primitive
66+
): SelectBuilder<GenericResultWrapper, GenericResult, IsAsync>
67+
where(
68+
arg1: string | Array<string> | JsonExpression,
69+
arg2?: Primitive | Primitive[] | string,
70+
arg3?: Primitive
5571
): 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)
66-
}
67-
68-
if ((this._options.where as any)?.params) {
69-
params = (this._options.where as any).params.concat(params)
72+
const currentWhere: WhereClause = this._options.where || { conditions: [], params: [] }
73+
const newConditions = [...currentWhere.conditions]
74+
const newParams = [...currentWhere.params]
75+
76+
if (arg3 !== undefined && typeof arg2 === 'string') {
77+
// Overload: where(field: string | JsonExpression, operator: string, value: Primitive)
78+
const fieldOrJsonExpr = arg1 as string | JsonExpression
79+
const operator = arg2
80+
const value = arg3
81+
82+
if ((fieldOrJsonExpr as JsonExpression).isJsonExpression) {
83+
const jsonExpr = fieldOrJsonExpr as JsonExpression
84+
newConditions.push(`${jsonExpr.expression} ${operator} ?`)
85+
newParams.push(...jsonExpr.bindings, value)
86+
} else {
87+
// Assuming fieldOrJsonExpr is a string (field name)
88+
// We should also handle if it's a Raw instance for safety, though less common here
89+
let fieldStr = fieldOrJsonExpr as string
90+
if((fieldOrJsonExpr as Raw).isRaw) {
91+
fieldStr = (fieldOrJsonExpr as Raw).content;
92+
}
93+
newConditions.push(`${fieldStr} ${operator} ?`)
94+
newParams.push(value)
95+
}
96+
} else {
97+
// Overload: where(conditions: string | Array<string>, params?: Primitive | Primitive[])
98+
let conditionsArg = arg1 as string | Array<string>
99+
let paramsArg = (arg2 || []) as Primitive | Primitive[]
100+
101+
if (!Array.isArray(conditionsArg)) {
102+
conditionsArg = [conditionsArg]
103+
}
104+
if (!Array.isArray(paramsArg)) {
105+
paramsArg = [paramsArg]
106+
}
107+
108+
newConditions.push(...conditionsArg)
109+
newParams.push(...paramsArg)
70110
}
71111

72112
return new SelectBuilder<GenericResultWrapper, GenericResult, IsAsync>(
73113
{
74114
...this._options,
75115
where: {
76-
conditions: conditions,
77-
params: params,
116+
conditions: newConditions,
117+
params: newParams,
78118
},
79119
},
80120
this._fetchAll,
@@ -124,22 +164,16 @@ export class SelectBuilder<GenericResultWrapper, GenericResult = DefaultReturnOb
124164
whereInParams = values.flat()
125165
}
126166

127-
let conditions: string | Array<string> = [whereInCondition]
128-
let params: Primitive[] = whereInParams
129-
if ((this._options.where as any)?.conditions) {
130-
conditions = (this._options.where as any)?.conditions.concat(conditions)
131-
}
132-
133-
if ((this._options.where as any)?.params) {
134-
params = (this._options.where as any)?.params.concat(params)
135-
}
167+
const currentWhere: WhereClause = this._options.where || { conditions: [], params: [] };
168+
const newConditions = [...currentWhere.conditions, whereInCondition];
169+
const newParams = [...currentWhere.params, ...whereInParams];
136170

137171
return new SelectBuilder<GenericResultWrapper, GenericResult, IsAsync>(
138172
{
139173
...this._options,
140174
where: {
141-
conditions: conditions,
142-
params: params,
175+
conditions: newConditions,
176+
params: newParams,
143177
},
144178
},
145179
this._fetchAll,

src/tools.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,12 @@ export class QueryWithExtra<GenericResultWrapper, Result = any, IsAsync extends
6767
export function trimQuery(query: string): string {
6868
return query.replace(/\s\s+/g, ' ')
6969
}
70+
71+
export class JsonExpression {
72+
public isJsonExpression = true
73+
constructor(public readonly expression: string, public readonly bindings: Primitive[] = []) {}
74+
}
75+
76+
export function json(expression: string, ...bindings: Primitive[]): JsonExpression {
77+
return new JsonExpression(expression, bindings);
78+
}

tests/unit/select.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22
import { JoinTypes, OrderTypes } from '../../src/enums'
3+
import { json } from '../../src/tools'
34
import { QuerybuilderTest } from '../utils'
45

56
describe('Select Builder', () => {
@@ -933,4 +934,132 @@ describe('Select Builder', () => {
933934
expect(result.fetchType).toEqual('ALL')
934935
}
935936
})
937+
938+
describe('JSON Functions in WHERE clause', () => {
939+
it('select with json_extract', () => {
940+
const result = new QuerybuilderTest()
941+
.select('dataTable')
942+
.where(json("json_extract(data, '$.name')"), '=', 'John Doe')
943+
.getQueryAll()
944+
945+
expect(result.query).toEqual("SELECT * FROM dataTable WHERE json_extract(data, '$.name') = ?")
946+
expect(result.arguments).toEqual(['John Doe'])
947+
expect(result.fetchType).toEqual('ALL')
948+
})
949+
950+
it('select with json_extract and path binding', () => {
951+
const result = new QuerybuilderTest()
952+
.select('dataTable')
953+
.where(json('json_extract(data, ?)', '$.name'), '=', 'Jane Doe')
954+
.getQueryAll()
955+
956+
expect(result.query).toEqual('SELECT * FROM dataTable WHERE json_extract(data, ?) = ?')
957+
expect(result.arguments).toEqual(['$.name', 'Jane Doe'])
958+
expect(result.fetchType).toEqual('ALL')
959+
})
960+
961+
it('select with json_extract and multiple path bindings', () => {
962+
const result = new QuerybuilderTest()
963+
.select('dataTable')
964+
.where(json('json_extract(data, ? || ?)', '$.users[', 0), '=', 'First User')
965+
.getQueryAll()
966+
967+
expect(result.query).toEqual('SELECT * FROM dataTable WHERE json_extract(data, ? || ?) = ?')
968+
expect(result.arguments).toEqual(['$.users[', 0, 'First User'])
969+
expect(result.fetchType).toEqual('ALL')
970+
})
971+
972+
it('select with json_array_length', () => {
973+
const result = new QuerybuilderTest()
974+
.select('dataTable')
975+
.where(json("json_array_length(logins, '$.history')"), '>', 5)
976+
.getQueryAll()
977+
978+
expect(result.query).toEqual("SELECT * FROM dataTable WHERE json_array_length(logins, '$.history') > ?")
979+
expect(result.arguments).toEqual([5])
980+
expect(result.fetchType).toEqual('ALL')
981+
})
982+
983+
it('select with json_type and path binding', () => {
984+
const result = new QuerybuilderTest()
985+
.select('dataTable')
986+
.where(json('json_type(attributes, ?)', '$.settings.theme'), '=', 'dark')
987+
.getQueryAll()
988+
989+
expect(result.query).toEqual('SELECT * FROM dataTable WHERE json_type(attributes, ?) = ?')
990+
expect(result.arguments).toEqual(['$.settings.theme', 'dark'])
991+
expect(result.fetchType).toEqual('ALL')
992+
})
993+
994+
it('select with chained where clauses including JSON functions', () => {
995+
const result = new QuerybuilderTest()
996+
.select('dataTable')
997+
.where('processed = ?', true)
998+
.where(json('json_extract(data, ?)', '$.status'), '=', 'active')
999+
.where('retries', '<', 3)
1000+
.getQueryAll()
1001+
1002+
expect(result.query).toEqual(
1003+
'SELECT * FROM dataTable WHERE (processed = ?) AND (json_extract(data, ?) = ?) AND (retries < ?)'
1004+
)
1005+
expect(result.arguments).toEqual([true, '$.status', 'active', 3])
1006+
expect(result.fetchType).toEqual('ALL')
1007+
})
1008+
1009+
it('select one with json_extract', () => {
1010+
const result = new QuerybuilderTest()
1011+
.select('dataTable')
1012+
.where(json("json_extract(data, '$.name')"), '=', 'John Doe')
1013+
.getQueryOne()
1014+
1015+
expect(result.query).toEqual("SELECT * FROM dataTable WHERE json_extract(data, '$.name') = ? LIMIT 1")
1016+
expect(result.arguments).toEqual(['John Doe'])
1017+
expect(result.fetchType).toEqual('ONE')
1018+
})
1019+
1020+
it('select with json function in where and regular field condition', () => {
1021+
const qb = new QuerybuilderTest().select('myTable');
1022+
const result = qb
1023+
.where(json('JSON_EXTRACT(meta, ?)', '$.isAdmin'), '=', 1)
1024+
.where('age', '>', 30)
1025+
.getQueryAll();
1026+
1027+
expect(result.query).toBe('SELECT * FROM myTable WHERE (JSON_EXTRACT(meta, ?) = ?) AND (age > ?)');
1028+
expect(result.arguments).toEqual(['$.isAdmin', 1, 30]);
1029+
});
1030+
1031+
it('select with regular field condition then json function in where', () => {
1032+
const qb = new QuerybuilderTest().select('myTable');
1033+
const result = qb
1034+
.where('age', '>', 30)
1035+
.where(json('JSON_EXTRACT(meta, ?)', '$.isAdmin'), '=', 1)
1036+
.getQueryAll();
1037+
1038+
expect(result.query).toBe('SELECT * FROM myTable WHERE (age > ?) AND (JSON_EXTRACT(meta, ?) = ?)');
1039+
expect(result.arguments).toEqual([30, '$.isAdmin', 1]);
1040+
});
1041+
1042+
it('select with multiple json functions in where', () => {
1043+
const qb = new QuerybuilderTest().select('myTable');
1044+
const result = qb
1045+
.where(json('JSON_EXTRACT(meta, ?)', '$.isAdmin'), '=', 1)
1046+
.where(json('JSON_TYPE(meta, ?)', '$.tags'), '=', 'array')
1047+
.getQueryAll();
1048+
1049+
expect(result.query).toBe('SELECT * FROM myTable WHERE (JSON_EXTRACT(meta, ?) = ?) AND (JSON_TYPE(meta, ?) = ?)');
1050+
expect(result.arguments).toEqual(['$.isAdmin', 1, '$.tags', 'array']);
1051+
});
1052+
1053+
it('select with json function using direct path and another with bound path', () => {
1054+
const qb = new QuerybuilderTest().select('myTable');
1055+
const result = qb
1056+
.where(json("JSON_EXTRACT(meta, '$.config.enabled')"), '=', true)
1057+
.where(json('JSON_EXTRACT(meta, ?)', '$.user.id'), '=', 'uuid-123')
1058+
.getQueryAll();
1059+
1060+
expect(result.query).toBe("SELECT * FROM myTable WHERE (JSON_EXTRACT(meta, '$.config.enabled') = ?) AND (JSON_EXTRACT(meta, ?) = ?)");
1061+
expect(result.arguments).toEqual([true, '$.user.id', 'uuid-123']);
1062+
});
1063+
1064+
})
9361065
})

0 commit comments

Comments
 (0)