Skip to content

Commit d259709

Browse files
authored
feat: implement raw queries (#69)
* feat: implement raw queries * add coderabbit config * fixes
1 parent c1f1b69 commit d259709

File tree

8 files changed

+179
-7
lines changed

8 files changed

+179
-7
lines changed

.coderabbit.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
2+
language: 'en-US'
3+
early_access: false
4+
reviews:
5+
auto_review:
6+
enabled: true
7+
chat:
8+
auto_reply: true

packages/runtime/src/client/client-impl.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
22
import type { SqliteDialectConfig } from 'kysely';
33
import {
4+
CompiledQuery,
45
DefaultConnectionProvider,
56
DefaultQueryExecutor,
67
Kysely,
78
Log,
89
PostgresDialect,
10+
sql,
911
SqliteDialect,
1012
type KyselyProps,
1113
type PostgresDialectConfig,
@@ -209,6 +211,41 @@ export class ClientImpl<Schema extends SchemaDef> {
209211
get $auth() {
210212
return this.auth;
211213
}
214+
215+
$executeRaw(query: TemplateStringsArray, ...values: any[]) {
216+
return createDeferredPromise(async () => {
217+
const result = await sql(query, ...values).execute(this.kysely);
218+
return Number(result.numAffectedRows ?? 0);
219+
});
220+
}
221+
222+
$executeRawUnsafe(query: string, ...values: any[]) {
223+
return createDeferredPromise(async () => {
224+
const compiledQuery = this.createRawCompiledQuery(query, values);
225+
const result = await this.kysely.executeQuery(compiledQuery);
226+
return Number(result.numAffectedRows ?? 0);
227+
});
228+
}
229+
230+
$queryRaw<T = unknown>(query: TemplateStringsArray, ...values: any[]) {
231+
return createDeferredPromise(async () => {
232+
const result = await sql(query, ...values).execute(this.kysely);
233+
return result.rows as T;
234+
});
235+
}
236+
237+
$queryRawUnsafe<T = unknown>(query: string, ...values: any[]) {
238+
return createDeferredPromise(async () => {
239+
const compiledQuery = this.createRawCompiledQuery(query, values);
240+
const result = await this.kysely.executeQuery(compiledQuery);
241+
return result.rows as T;
242+
});
243+
}
244+
245+
private createRawCompiledQuery(query: string, values: any[]) {
246+
const q = CompiledQuery.raw(query, values);
247+
return { ...q, $raw: true } as CompiledQuery;
248+
}
212249
}
213250

214251
function createClientProxy<Schema extends SchemaDef>(client: ClientImpl<Schema>): ClientImpl<Schema> {

packages/runtime/src/client/contract.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,44 @@ export type ClientContract<Schema extends SchemaDef> = {
4040
*/
4141
readonly $options: ClientOptions<Schema>;
4242

43+
/**
44+
* Executes a prepared raw query and returns the number of affected rows.
45+
* @example
46+
* ```
47+
* const result = await client.$executeRaw`UPDATE User SET cool = ${true} WHERE email = ${'user@email.com'};`
48+
* ```
49+
*/
50+
$executeRaw(query: TemplateStringsArray, ...values: any[]): Promise<number>;
51+
52+
/**
53+
* Executes a raw query and returns the number of affected rows.
54+
* This method is susceptible to SQL injections.
55+
* @example
56+
* ```
57+
* const result = await client.$executeRawUnsafe('UPDATE User SET cool = $1 WHERE email = $2 ;', true, '[email protected]')
58+
* ```
59+
*/
60+
$executeRawUnsafe(query: string, ...values: any[]): Promise<number>;
61+
62+
/**
63+
* Performs a prepared raw query and returns the `SELECT` data.
64+
* @example
65+
* ```
66+
* const result = await client.$queryRaw`SELECT * FROM User WHERE id = ${1} OR email = ${'user@email.com'};`
67+
* ```
68+
*/
69+
$queryRaw<T = unknown>(query: TemplateStringsArray, ...values: any[]): Promise<T>;
70+
71+
/**
72+
* Performs a raw query and returns the `SELECT` data.
73+
* This method is susceptible to SQL injections.
74+
* @example
75+
* ```
76+
* const result = await client.$queryRawUnsafe('SELECT * FROM User WHERE id = $1 OR email = $2;', 1, '[email protected]')
77+
* ```
78+
*/
79+
$queryRawUnsafe<T = unknown>(query: string, ...values: any[]): Promise<T>;
80+
4381
/**
4482
* The current user identity.
4583
*/

packages/runtime/src/client/executor/zenstack-query-executor.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ export class ZenStackQueryExecutor<Schema extends SchemaDef> extends DefaultQuer
8181
}
8282

8383
// proceed with the query with kysely interceptors
84-
const result = await this.proceedQueryWithKyselyInterceptors(queryNode, queryId);
84+
// if the query is a raw query, we need to carry over the parameters
85+
const queryParams = (compiledQuery as any).$raw ? compiledQuery.parameters : undefined;
86+
const result = await this.proceedQueryWithKyselyInterceptors(queryNode, queryParams, queryId);
8587

8688
// call after mutation hooks
8789
await this.callAfterQueryInterceptionFilters(result, queryNode, mutationInterceptionInfo);
@@ -96,8 +98,12 @@ export class ZenStackQueryExecutor<Schema extends SchemaDef> extends DefaultQuer
9698
return this.executeWithTransaction(task, !!mutationInterceptionInfo?.useTransactionForMutation);
9799
}
98100

99-
private proceedQueryWithKyselyInterceptors(queryNode: RootOperationNode, queryId: QueryId) {
100-
let proceed = (q: RootOperationNode) => this.proceedQuery(q, queryId);
101+
private proceedQueryWithKyselyInterceptors(
102+
queryNode: RootOperationNode,
103+
parameters: readonly unknown[] | undefined,
104+
queryId: QueryId,
105+
) {
106+
let proceed = (q: RootOperationNode) => this.proceedQuery(q, parameters, queryId);
101107

102108
const makeTx = (p: typeof proceed) => (callback: OnKyselyQueryTransactionCallback) => {
103109
return this.executeWithTransaction(() => callback(p));
@@ -125,10 +131,13 @@ export class ZenStackQueryExecutor<Schema extends SchemaDef> extends DefaultQuer
125131
return proceed(queryNode);
126132
}
127133

128-
private async proceedQuery(query: RootOperationNode, queryId: QueryId) {
134+
private async proceedQuery(query: RootOperationNode, parameters: readonly unknown[] | undefined, queryId: QueryId) {
129135
// run built-in transformers
130136
const finalQuery = this.nameMapper.transformNode(query);
131-
const compiled = this.compileQuery(finalQuery);
137+
let compiled = this.compileQuery(finalQuery);
138+
if (parameters) {
139+
compiled = { ...compiled, parameters };
140+
}
132141
try {
133142
return this.driver.txConnection
134143
? await super

packages/runtime/test/client-api/client-specs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getSchema, schema } from '../test-schema';
33
import { makePostgresClient, makeSqliteClient } from '../utils';
44
import type { ClientContract } from '../../src';
55

6-
export function createClientSpecs(dbName: string, logQueries = false, providers = ['sqlite', 'postgresql'] as const) {
6+
export function createClientSpecs(dbName: string, logQueries = false, providers: string[] = ['sqlite', 'postgresql']) {
77
const logger = (provider: string) => (event: LogEvent) => {
88
if (event.level === 'query') {
99
console.log(`query(${provider}):`, event.query.sql, event.query.parameters);
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import type { ClientContract } from '../../src/client';
3+
import { schema } from '../test-schema';
4+
import { createClientSpecs } from './client-specs';
5+
6+
const PG_DB_NAME = 'client-api-raw-query-tests';
7+
8+
describe.each(createClientSpecs(PG_DB_NAME, true))('Client raw query tests', ({ createClient, provider }) => {
9+
let client: ClientContract<typeof schema>;
10+
11+
beforeEach(async () => {
12+
client = await createClient();
13+
});
14+
15+
afterEach(async () => {
16+
await client?.$disconnect();
17+
});
18+
19+
it('works with executeRaw', async () => {
20+
await client.user.create({
21+
data: {
22+
id: '1',
23+
24+
},
25+
});
26+
27+
await expect(
28+
client.$executeRaw`UPDATE "User" SET "email" = ${'[email protected]'} WHERE "id" = ${'1'}`,
29+
).resolves.toBe(1);
30+
await expect(client.user.findFirst()).resolves.toMatchObject({ email: '[email protected]' });
31+
});
32+
33+
it('works with executeRawUnsafe', async () => {
34+
await client.user.create({
35+
data: {
36+
id: '1',
37+
38+
},
39+
});
40+
41+
const sql =
42+
provider === 'postgresql'
43+
? `UPDATE "User" SET "email" = $1 WHERE "id" = $2`
44+
: `UPDATE "User" SET "email" = ? WHERE "id" = ?`;
45+
await expect(client.$executeRawUnsafe(sql, '[email protected]', '1')).resolves.toBe(1);
46+
await expect(client.user.findFirst()).resolves.toMatchObject({ email: '[email protected]' });
47+
});
48+
49+
it('works with queryRaw', async () => {
50+
await client.user.create({
51+
data: {
52+
id: '1',
53+
54+
},
55+
});
56+
57+
const uid = '1';
58+
const users = await client.$queryRaw<
59+
{ id: string; email: string }[]
60+
>`SELECT "User"."id", "User"."email" FROM "User" WHERE "User"."id" = ${uid}`;
61+
expect(users).toEqual([{ id: '1', email: '[email protected]' }]);
62+
});
63+
64+
it('works with queryRawUnsafe', async () => {
65+
await client.user.create({
66+
data: {
67+
id: '1',
68+
69+
},
70+
});
71+
72+
const sql =
73+
provider === 'postgresql'
74+
? `SELECT "User"."id", "User"."email" FROM "User" WHERE "User"."id" = $1`
75+
: `SELECT "User"."id", "User"."email" FROM "User" WHERE "User"."id" = ?`;
76+
const users = await client.$queryRawUnsafe<{ id: string; email: string }[]>(sql, '1');
77+
expect(users).toEqual([{ id: '1', email: '[email protected]' }]);
78+
});
79+
});

packages/testtools/src/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export async function generateTsSchema(
3535
extraSourceFiles?: Record<string, string>,
3636
) {
3737
const workDir = createTestProject();
38-
console.log(`Working directory: ${workDir}`);
38+
console.log(`Work directory: ${workDir}`);
3939

4040
const zmodelPath = path.join(workDir, 'schema.zmodel');
4141
const noPrelude = schemaText.includes('datasource ');

turbo.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"tasks": {
44
"build": {
55
"dependsOn": ["^build"],
6+
"inputs": ["src/**"],
67
"outputs": ["dist/**"]
78
},
89
"lint": {

0 commit comments

Comments
 (0)