Skip to content

Commit ebf3d4f

Browse files
committed
feat: add string matching methods (whereLike, whereStartsWith, whereEndsWith) to QueryBuilder and Relation classes; update Kysely adapter to support raw where clauses
1 parent 851c30e commit ebf3d4f

File tree

8 files changed

+168
-10
lines changed

8 files changed

+168
-10
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "arkormx",
3-
"version": "2.0.0-next.21",
3+
"version": "2.0.0-next.22",
44
"description": "Modern TypeScript-first ORM for Node.js.",
55
"keywords": [
66
"orm",

src/QueryBuilder.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,48 @@ export class QueryBuilder<TModel, TDelegate extends PrismaDelegateLike = PrismaD
289289
return this.where({ [key]: { notIn: values } } as DelegateWhere<TDelegate>)
290290
}
291291

292+
/**
293+
* Adds a string contains clause for a single attribute key.
294+
*
295+
* @param key
296+
* @param value
297+
* @returns
298+
*/
299+
public whereLike<TKey extends keyof ModelAttributes<TModel> & string> (
300+
key: TKey,
301+
value: Extract<ModelAttributes<TModel>[TKey], string>
302+
): this {
303+
return this.where({ [key]: { contains: value } } as DelegateWhere<TDelegate>)
304+
}
305+
306+
/**
307+
* Adds a string starts-with clause for a single attribute key.
308+
*
309+
* @param key
310+
* @param value
311+
* @returns
312+
*/
313+
public whereStartsWith<TKey extends keyof ModelAttributes<TModel> & string> (
314+
key: TKey,
315+
value: Extract<ModelAttributes<TModel>[TKey], string>
316+
): this {
317+
return this.where({ [key]: { startsWith: value } } as DelegateWhere<TDelegate>)
318+
}
319+
320+
/**
321+
* Adds a string ends-with clause for a single attribute key.
322+
*
323+
* @param key
324+
* @param value
325+
* @returns
326+
*/
327+
public whereEndsWith<TKey extends keyof ModelAttributes<TModel> & string> (
328+
key: TKey,
329+
value: Extract<ModelAttributes<TModel>[TKey], string>
330+
): this {
331+
return this.where({ [key]: { endsWith: value } } as DelegateWhere<TDelegate>)
332+
}
333+
292334
/**
293335
* Adds a strongly-typed OR NOT IN where clause for a single attribute key.
294336
*

src/adapters/KyselyDatabaseAdapter.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter {
7575
relationLoads: false,
7676
relationAggregates: true,
7777
relationFilters: true,
78-
rawWhere: false,
78+
rawWhere: true,
7979
}
8080

8181
public constructor(
@@ -578,6 +578,30 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter {
578578
return sql<boolean>`${column} ${operator} ${condition.value}`
579579
}
580580

581+
private buildRawWhereCondition (condition: QueryRawCondition): RawBuilder<boolean> {
582+
const segments = condition.sql.split('?')
583+
const bindings = condition.bindings ?? []
584+
585+
if (segments.length !== bindings.length + 1) {
586+
throw new ArkormException('Raw where bindings do not match the number of placeholders.')
587+
}
588+
589+
const parts: RawBuilder<unknown>[] = []
590+
591+
segments.forEach((segment, index) => {
592+
if (segment.length > 0)
593+
parts.push(sql.raw(segment))
594+
595+
if (index < bindings.length)
596+
parts.push(sql`${bindings[index]}`)
597+
})
598+
599+
if (parts.length === 0)
600+
return sql<boolean>`1 = 1`
601+
602+
return sql<boolean>`${sql.join(parts, sql``)}`
603+
}
604+
581605
private buildWhereCondition (target: QueryTarget<any>, condition?: QueryCondition): RawBuilder<boolean> {
582606
if (!condition)
583607
return sql<boolean>`1 = 1`
@@ -607,13 +631,7 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter {
607631
return sql<boolean>`not (${this.buildWhereCondition(target, notCondition.condition)})`
608632
}
609633

610-
throw new UnsupportedAdapterFeatureException('Raw where clauses are not supported by the Kysely adapter.', {
611-
operation: 'adapter.where',
612-
meta: {
613-
feature: 'rawWhere',
614-
sql: (condition as QueryRawCondition).sql,
615-
},
616-
})
634+
return this.buildRawWhereCondition(condition as QueryRawCondition)
617635
}
618636

619637
private buildWhereClause (target: QueryTarget<any>, condition?: QueryCondition): RawBuilder<unknown> {

src/relationship/Relation.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,48 @@ export abstract class Relation<TModel> {
9999
return this.constrain(query => query.whereIn(key, values))
100100
}
101101

102+
/**
103+
* Add a string contains clause to the relationship query.
104+
*
105+
* @param key
106+
* @param value
107+
* @returns
108+
*/
109+
public whereLike<TKey extends keyof ModelAttributes<TModel> & string> (
110+
key: TKey,
111+
value: Extract<ModelAttributes<TModel>[TKey], string>
112+
): this {
113+
return this.constrain(query => query.whereLike(key, value))
114+
}
115+
116+
/**
117+
* Add a string starts-with clause to the relationship query.
118+
*
119+
* @param key
120+
* @param value
121+
* @returns
122+
*/
123+
public whereStartsWith<TKey extends keyof ModelAttributes<TModel> & string> (
124+
key: TKey,
125+
value: Extract<ModelAttributes<TModel>[TKey], string>
126+
): this {
127+
return this.constrain(query => query.whereStartsWith(key, value))
128+
}
129+
130+
/**
131+
* Add a string ends-with clause to the relationship query.
132+
*
133+
* @param key
134+
* @param value
135+
* @returns
136+
*/
137+
public whereEndsWith<TKey extends keyof ModelAttributes<TModel> & string> (
138+
key: TKey,
139+
value: Extract<ModelAttributes<TModel>[TKey], string>
140+
): this {
141+
return this.constrain(query => query.whereEndsWith(key, value))
142+
}
143+
102144
/**
103145
* Add an order by clause to the relationship query.
104146
*

tests/base/helpers/core-fixtures.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,24 @@ function matchesWhere (row: Row, where: Record<string, unknown> | undefined): bo
9696
return false
9797
}
9898

99+
if ('contains' in clause) {
100+
const candidate = clause.contains
101+
if (typeof rowValue !== 'string' || typeof candidate !== 'string' || !rowValue.includes(candidate))
102+
return false
103+
}
104+
105+
if ('startsWith' in clause) {
106+
const candidate = clause.startsWith
107+
if (typeof rowValue !== 'string' || typeof candidate !== 'string' || !rowValue.startsWith(candidate))
108+
return false
109+
}
110+
111+
if ('endsWith' in clause) {
112+
const candidate = clause.endsWith
113+
if (typeof rowValue !== 'string' || typeof candidate !== 'string' || !rowValue.endsWith(candidate))
114+
return false
115+
}
116+
99117
if ('gt' in clause) {
100118
const compareTo = clause.gt
101119
const leftValue = toComparable(rowValue, compareTo)

tests/base/query-builder.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,15 @@ describe('QueryBuilder', () => {
469469
const orWhereNotIn = await User.query().whereKey('id', 1).orWhereNotIn('id', [1]).orderBy({ id: 'asc' }).get()
470470
expect(orWhereNotIn.all().map(user => user.getAttribute('id'))).toEqual([1, 2])
471471

472+
const whereLike = await User.query().whereLike('email', '@example.com').orderBy({ id: 'asc' }).get()
473+
expect(whereLike.all().map(user => user.getAttribute('id'))).toEqual([1, 2])
474+
475+
const whereStartsWith = await User.query().whereStartsWith('email', 'jane').get()
476+
expect(whereStartsWith.all().map(user => user.getAttribute('id'))).toEqual([1])
477+
478+
const whereEndsWith = await User.query().whereEndsWith('email', '@example.com').orderBy({ id: 'asc' }).get()
479+
expect(whereEndsWith.all().map(user => user.getAttribute('id'))).toEqual([1, 2])
480+
472481
const whereDate = await User.query().whereDate('createdAt', '2026-03-04').get()
473482
expect(whereDate.all().length).toBe(2)
474483

tests/base/relationships.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,16 +272,28 @@ describe('Model relationships', () => {
272272
expect(user).not.toBeNull()
273273

274274
const posts = await user?.posts()
275-
.where({ title: 'A' })
275+
.whereStartsWith('title', 'A')
276276
.orderBy({ id: 'asc' })
277277
.getResults()
278278

279279
const profile = await user?.profile()
280280
.where({ id: 10 })
281281
.getResults()
282282

283+
const postsEndingWithB = await user?.posts()
284+
.whereEndsWith('title', 'B')
285+
.getResults()
286+
287+
const postsLike = await user?.posts()
288+
.whereLike('title', 'A')
289+
.getResults()
290+
283291
expect((posts as ArkormCollection<Post>).all().length).toBe(1)
284292
expect((posts as ArkormCollection<Post>).all()[0]?.getAttribute('title')).toBe('A')
293+
expect((postsEndingWithB as ArkormCollection<Post>).all().length).toBe(1)
294+
expect((postsEndingWithB as ArkormCollection<Post>).all()[0]?.getAttribute('title')).toBe('B')
295+
expect((postsLike as ArkormCollection<Post>).all().length).toBe(1)
296+
expect((postsLike as ArkormCollection<Post>).all()[0]?.getAttribute('title')).toBe('A')
285297
expect(profile).not.toBeNull()
286298
expect((profile as Profile).getAttribute('id')).toBe(10)
287299
})

tests/postgres/kysely-adapter.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,23 @@ describe('PostgreSQL Kysely adapter', () => {
204204
expect(trashedArticles.all().map(article => article.getAttribute('title'))).toEqual(['Archived'])
205205
})
206206

207+
it('supports raw where clauses through the Kysely adapter', async () => {
208+
setPostgresModelAdapter(kyselyAdapter)
209+
210+
const normalizedLocalPart = 'jane'
211+
const query = DbUser.query().whereRaw(
212+
'LOWER("email") = ? OR LOWER("email") LIKE ?',
213+
[`${normalizedLocalPart}@example.com`, `%${normalizedLocalPart}@%`],
214+
)
215+
216+
const users = await query.get()
217+
218+
expect(users.all().map(user => user.getAttribute('email'))).toEqual(['[email protected]'])
219+
220+
const normalizedSql = executedQueries.join('\n').replace(/\s+/g, ' ')
221+
expect(normalizedSql).toContain('LOWER("email") = $1 OR LOWER("email") LIKE $2')
222+
})
223+
207224
it('supports SQL-backed direct relation filters and aggregates through QueryBuilder', async () => {
208225
setPostgresModelAdapter(kyselyAdapter)
209226

0 commit comments

Comments
 (0)