Skip to content

Commit c94be6a

Browse files
DominicGBauerDominicGBauer
andauthored
feat(reat): allow compilable query as hook argument (#150)
Co-authored-by: DominicGBauer <dominic@nomanini.com>
1 parent c5a9eb5 commit c94be6a

File tree

13 files changed

+334
-17
lines changed

13 files changed

+334
-17
lines changed

.changeset/khaki-apples-hunt.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@powersync/common": minor
3+
"@powersync/react": minor
4+
---
5+
6+
Allow compilable queries to be used as hook arguments

demos/django-react-native-todolist/library/widgets/HeaderWidget.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ export const HeaderWidget: React.FC<{
3939
onPress={() => {
4040
Alert.alert(
4141
'Status',
42-
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
43-
status.lastSyncedAt?.toISOString() ?? '-'
42+
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${status.lastSyncedAt?.toISOString() ?? '-'
4443
}\nVersion: ${powersync.sdkVersion}`
4544
);
4645
}}

demos/react-native-supabase-group-chat/src/app/(app)/contacts/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export default function ContactsIndex() {
112112
icon={<Search size="$1.5" />}
113113
backgroundColor="$brand1"
114114
borderRadius="$3"
115-
// circular
115+
// circular
116116
/>
117117
</XStack>
118118

packages/common/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"homepage": "https://docs.powersync.com/resources/api-reference",
2424
"scripts": {
2525
"build": "tsc -b",
26-
"clean": "rm -rf lib tsconfig.tsbuildinfo"
26+
"clean": "rm -rf lib tsconfig.tsbuildinfo",
27+
"test": "vitest"
2728
},
2829
"dependencies": {
2930
"async-mutex": "^0.4.0",
@@ -37,6 +38,7 @@
3738
"@types/lodash": "^4.14.197",
3839
"@types/node": "^20.5.9",
3940
"@types/uuid": "^9.0.1",
40-
"typescript": "^5.1.3"
41+
"typescript": "^5.1.3",
42+
"vitest": "^1.5.2"
4143
}
4244
}

packages/common/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ export * from './db/schema/TableV2';
3030
export * from './utils/AbortOperation';
3131
export * from './utils/BaseObserver';
3232
export * from './utils/strings';
33+
export * from './utils/parseQuery';
34+
35+
export * from './types/types';

packages/common/src/types/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export interface CompilableQuery<T> {
2+
execute(): Promise<T[]>;
3+
compile(): CompiledQuery;
4+
}
5+
6+
export interface CompiledQuery {
7+
readonly sql: string;
8+
readonly parameters: ReadonlyArray<unknown>;
9+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { CompilableQuery } from '../types/types';
2+
3+
export interface ParsedQuery {
4+
sqlStatement: string;
5+
parameters: any[];
6+
}
7+
8+
export const parseQuery = <T>(query: string | CompilableQuery<T>, parameters: any[]): ParsedQuery => {
9+
let sqlStatement: string;
10+
11+
if (typeof query == 'string') {
12+
sqlStatement = query;
13+
} else {
14+
const hasAdditionalParameters = parameters.length > 0;
15+
if (hasAdditionalParameters) {
16+
throw new Error('You cannot pass parameters to a compiled query.');
17+
}
18+
19+
const compiled = query.compile();
20+
sqlStatement = compiled.sql;
21+
parameters = compiled.parameters as any[];
22+
}
23+
24+
return { sqlStatement, parameters: parameters };
25+
};

packages/common/tests/tsconfig.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"compilerOptions": {
3+
"baseUrl": "./",
4+
"esModuleInterop": true,
5+
"jsx": "react",
6+
"rootDir": "../",
7+
"composite": true,
8+
"outDir": "./lib",
9+
"lib": ["esnext", "DOM"],
10+
"module": "esnext",
11+
"sourceMap": true,
12+
"moduleResolution": "node",
13+
"noFallthroughCasesInSwitch": true,
14+
"noImplicitReturns": true,
15+
"noImplicitUseStrict": false,
16+
"noStrictGenericChecks": false,
17+
"resolveJsonModule": true,
18+
"skipLibCheck": true,
19+
"target": "esnext"
20+
},
21+
"include": ["../src/**/*"]
22+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from 'vitest';
2+
import * as SUT from '../../src/utils/parseQuery';
3+
4+
describe('parseQuery', () => {
5+
it('should do nothing if the query is a string', () => {
6+
const query = 'SELECT * FROM table';
7+
const parameters = ['one'];
8+
const result = SUT.parseQuery(query, parameters);
9+
10+
expect(result).toEqual({ sqlStatement: query, parameters: ['one'] });
11+
});
12+
13+
it('should compile the query and return the sql statement and parameters if the query is compilable', () => {
14+
const sqlStatement = 'SELECT * FROM table';
15+
const parameters = [];
16+
const query = {
17+
compile: () => ({ sql: sqlStatement, parameters: ['test'] }),
18+
execute: () => Promise.resolve([])
19+
};
20+
const result = SUT.parseQuery(query, parameters);
21+
22+
expect(result).toEqual({ sqlStatement, parameters: ['test'] });
23+
});
24+
25+
it('should throw an error if there is an additional parameter included in a compiled query', () => {
26+
const sqlStatement = 'SELECT * FROM table';
27+
const parameters = ['additional parameter'];
28+
const query = {
29+
compile: () => ({ sql: sqlStatement, parameters: ['test'] }),
30+
execute: () => Promise.resolve([])
31+
};
32+
const result = () => SUT.parseQuery(query, parameters);
33+
34+
expect(result).toThrowError('You cannot pass parameters to a compiled query.');
35+
});
36+
});

packages/react/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,14 @@ const Component = () => {
6363

6464
### Queries
6565

66-
Queries will automatically update when a dependant table is updated unless you set the `runQueryOnce` flag.
66+
Queries will automatically update when a dependant table is updated unless you set the `runQueryOnce` flag. You are also able to use a compilable query (e.g. [Kysely queries](https://github.com/powersync-ja/powersync-js/tree/main/packages/kysely-driver)) as a query argument in place of a SQL statement string.
6767

6868
```JSX
6969
// TodoListDisplay.jsx
7070
import { useQuery } from "@powersync/react";
7171

7272
export const TodoListDisplay = () => {
73-
const { data: todoLists } = useQuery('SELECT * from lists');
73+
const { data: todoLists } = useQuery('SELECT * FROM lists WHERE id = ?', ['id-1'], {runQueryOnce: false});
7474

7575
return <View>
7676
{todoLists.map((l) => (

packages/react/src/hooks/useQuery.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { SQLWatchOptions } from '@powersync/common';
1+
import { type SQLWatchOptions, parseQuery, type CompilableQuery, type ParsedQuery } from '@powersync/common';
22
import React from 'react';
33
import { usePowerSync } from './PowerSyncContext';
44

5-
interface AdditionalOptions extends Omit<SQLWatchOptions, 'signal'> {
5+
export interface AdditionalOptions extends Omit<SQLWatchOptions, 'signal'> {
66
runQueryOnce?: boolean;
77
}
88

@@ -37,7 +37,7 @@ export type QueryResult<T> = {
3737
* }
3838
*/
3939
export const useQuery = <T = any>(
40-
sqlStatement: string,
40+
query: string | CompilableQuery<T>,
4141
parameters: any[] = [],
4242
options: AdditionalOptions = { runQueryOnce: false }
4343
): QueryResult<T> => {
@@ -46,13 +46,23 @@ export const useQuery = <T = any>(
4646
return { isLoading: false, isFetching: false, data: [], error: new Error('PowerSync not configured.') };
4747
}
4848

49+
let parsedQuery: ParsedQuery;
50+
try {
51+
parsedQuery = parseQuery(query, parameters);
52+
} catch (error) {
53+
console.error('Failed to parse query:', error);
54+
return { isLoading: false, isFetching: false, data: [], error };
55+
}
56+
57+
const { sqlStatement, parameters: queryParameters } = parsedQuery;
58+
4959
const [data, setData] = React.useState<T[]>([]);
5060
const [error, setError] = React.useState<Error | undefined>(undefined);
5161
const [isLoading, setIsLoading] = React.useState(true);
5262
const [isFetching, setIsFetching] = React.useState(true);
5363
const [tables, setTables] = React.useState([]);
5464

55-
const memoizedParams = React.useMemo(() => parameters, [...parameters]);
65+
const memoizedParams = React.useMemo(() => parameters, [...queryParameters]);
5666
const memoizedOptions = React.useMemo(() => options, [JSON.stringify(options)]);
5767
const abortController = React.useRef(new AbortController());
5868

@@ -78,6 +88,7 @@ export const useQuery = <T = any>(
7888
const result = await powerSync.getAll<T>(sqlStatement, parameters);
7989
handleResult(result);
8090
} catch (e) {
91+
console.error('Failed to fetch data:', e);
8192
handleError(e);
8293
}
8394
};
@@ -87,6 +98,7 @@ export const useQuery = <T = any>(
8798
const tables = await powerSync.resolveTables(sqlStatement, memoizedParams, memoizedOptions);
8899
setTables(tables);
89100
} catch (e) {
101+
console.error('Failed to fetch tables:', e);
90102
handleError(e);
91103
}
92104
};

packages/react/tests/useQuery.test.tsx

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { renderHook, waitFor } from '@testing-library/react';
33
import { vi, describe, expect, it, afterEach } from 'vitest';
44
import { useQuery } from '../src/hooks/useQuery';
55
import { PowerSyncContext } from '../src/hooks/PowerSyncContext';
6+
import * as commonSdk from '@powersync/common';
67

78
const mockPowerSync = {
89
currentStatus: { status: 'initial' },
@@ -25,7 +26,7 @@ describe('useQuery', () => {
2526

2627
it('should error when PowerSync is not set', async () => {
2728
const { result } = renderHook(() => useQuery('SELECT * from lists'));
28-
const currentResult = await result.current;
29+
const currentResult = result.current;
2930
expect(currentResult.error).toEqual(Error('PowerSync not configured.'));
3031
expect(currentResult.isLoading).toEqual(false);
3132
expect(currentResult.data).toEqual([]);
@@ -37,7 +38,7 @@ describe('useQuery', () => {
3738
);
3839

3940
const { result } = renderHook(() => useQuery('SELECT * from lists'), { wrapper });
40-
const currentResult = await result.current;
41+
const currentResult = result.current;
4142
expect(currentResult.isLoading).toEqual(true);
4243
});
4344

@@ -47,7 +48,7 @@ describe('useQuery', () => {
4748
);
4849

4950
const { result } = renderHook(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }), { wrapper });
50-
const currentResult = await result.current;
51+
const currentResult = result.current;
5152
expect(currentResult.isLoading).toEqual(true);
5253

5354
waitFor(
@@ -68,7 +69,7 @@ describe('useQuery', () => {
6869
);
6970

7071
const { result } = renderHook(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }), { wrapper });
71-
const currentResult = await result.current;
72+
const currentResult = result.current;
7273
expect(currentResult.isLoading).toEqual(true);
7374

7475
let refresh;
@@ -104,7 +105,7 @@ describe('useQuery', () => {
104105
);
105106

106107
const { result } = renderHook(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }), { wrapper });
107-
const currentResult = await result.current;
108+
const currentResult = result.current;
108109

109110
waitFor(
110111
async () => {
@@ -132,7 +133,7 @@ describe('useQuery', () => {
132133
);
133134

134135
const { result } = renderHook(() => useQuery('SELECT * from lists', []), { wrapper });
135-
const currentResult = await result.current;
136+
const currentResult = result.current;
136137

137138
waitFor(
138139
async () => {
@@ -142,5 +143,47 @@ describe('useQuery', () => {
142143
);
143144
});
144145

146+
it('should accept compilable queries', async () => {
147+
const wrapper = ({ children }) => (
148+
<PowerSyncContext.Provider value={mockPowerSync as any}>{children}</PowerSyncContext.Provider>
149+
);
150+
151+
const { result } = renderHook(
152+
() => useQuery({ execute: () => [] as any, compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) }),
153+
{ wrapper }
154+
);
155+
const currentResult = result.current;
156+
expect(currentResult.isLoading).toEqual(true);
157+
});
158+
159+
// The test returns unhandled errors when run with all the others.
160+
// TODO: Fix the test so that there are no unhandled errors (this may be a vitest or @testing-library/react issue)
161+
it.skip('should show an error if parsing the query results in an error', async () => {
162+
const wrapper = ({ children }) => (
163+
<PowerSyncContext.Provider value={mockPowerSync as any}>{children}</PowerSyncContext.Provider>
164+
);
165+
vi.spyOn(commonSdk, 'parseQuery').mockReturnValue(Error('error') as any);
166+
167+
const { result } = renderHook(
168+
() =>
169+
useQuery({
170+
execute: () => [] as any,
171+
compile: () => ({ sql: 'SELECT * from lists', parameters: ['param'] })
172+
}),
173+
{ wrapper }
174+
);
175+
const currentResult = result.current;
176+
177+
waitFor(
178+
async () => {
179+
expect(currentResult.isLoading).toEqual(false);
180+
expect(currentResult.isFetching).toEqual(false);
181+
expect(currentResult.data).toEqual([]);
182+
expect(currentResult.error).toEqual(Error('error'));
183+
},
184+
{ timeout: 100 }
185+
);
186+
});
187+
145188
// TODO: Add tests for powersync.onChangeWithCallback path
146189
});

0 commit comments

Comments
 (0)