Skip to content

Commit

Permalink
feat(reat): allow compilable query as hook argument (#150)
Browse files Browse the repository at this point in the history
Co-authored-by: DominicGBauer <[email protected]>
  • Loading branch information
DominicGBauer and DominicGBauer authored May 2, 2024
1 parent c5a9eb5 commit c94be6a
Show file tree
Hide file tree
Showing 13 changed files with 334 additions and 17 deletions.
6 changes: 6 additions & 0 deletions .changeset/khaki-apples-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@powersync/common": minor
"@powersync/react": minor
---

Allow compilable queries to be used as hook arguments
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ export const HeaderWidget: React.FC<{
onPress={() => {
Alert.alert(
'Status',
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
status.lastSyncedAt?.toISOString() ?? '-'
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${status.lastSyncedAt?.toISOString() ?? '-'
}\nVersion: ${powersync.sdkVersion}`
);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export default function ContactsIndex() {
icon={<Search size="$1.5" />}
backgroundColor="$brand1"
borderRadius="$3"
// circular
// circular
/>
</XStack>

Expand Down
6 changes: 4 additions & 2 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"homepage": "https://docs.powersync.com/resources/api-reference",
"scripts": {
"build": "tsc -b",
"clean": "rm -rf lib tsconfig.tsbuildinfo"
"clean": "rm -rf lib tsconfig.tsbuildinfo",
"test": "vitest"
},
"dependencies": {
"async-mutex": "^0.4.0",
Expand All @@ -37,6 +38,7 @@
"@types/lodash": "^4.14.197",
"@types/node": "^20.5.9",
"@types/uuid": "^9.0.1",
"typescript": "^5.1.3"
"typescript": "^5.1.3",
"vitest": "^1.5.2"
}
}
3 changes: 3 additions & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ export * from './db/schema/TableV2';
export * from './utils/AbortOperation';
export * from './utils/BaseObserver';
export * from './utils/strings';
export * from './utils/parseQuery';

export * from './types/types';
9 changes: 9 additions & 0 deletions packages/common/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface CompilableQuery<T> {
execute(): Promise<T[]>;
compile(): CompiledQuery;
}

export interface CompiledQuery {
readonly sql: string;
readonly parameters: ReadonlyArray<unknown>;
}
25 changes: 25 additions & 0 deletions packages/common/src/utils/parseQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { CompilableQuery } from '../types/types';

export interface ParsedQuery {
sqlStatement: string;
parameters: any[];
}

export const parseQuery = <T>(query: string | CompilableQuery<T>, parameters: any[]): ParsedQuery => {
let sqlStatement: string;

if (typeof query == 'string') {
sqlStatement = query;
} else {
const hasAdditionalParameters = parameters.length > 0;
if (hasAdditionalParameters) {
throw new Error('You cannot pass parameters to a compiled query.');
}

const compiled = query.compile();
sqlStatement = compiled.sql;
parameters = compiled.parameters as any[];
}

return { sqlStatement, parameters: parameters };
};
22 changes: 22 additions & 0 deletions packages/common/tests/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"baseUrl": "./",
"esModuleInterop": true,
"jsx": "react",
"rootDir": "../",
"composite": true,
"outDir": "./lib",
"lib": ["esnext", "DOM"],
"module": "esnext",
"sourceMap": true,
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitUseStrict": false,
"noStrictGenericChecks": false,
"resolveJsonModule": true,
"skipLibCheck": true,
"target": "esnext"
},
"include": ["../src/**/*"]
}
36 changes: 36 additions & 0 deletions packages/common/tests/utils/parseQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import * as SUT from '../../src/utils/parseQuery';

describe('parseQuery', () => {
it('should do nothing if the query is a string', () => {
const query = 'SELECT * FROM table';
const parameters = ['one'];
const result = SUT.parseQuery(query, parameters);

expect(result).toEqual({ sqlStatement: query, parameters: ['one'] });
});

it('should compile the query and return the sql statement and parameters if the query is compilable', () => {
const sqlStatement = 'SELECT * FROM table';
const parameters = [];
const query = {
compile: () => ({ sql: sqlStatement, parameters: ['test'] }),
execute: () => Promise.resolve([])
};
const result = SUT.parseQuery(query, parameters);

expect(result).toEqual({ sqlStatement, parameters: ['test'] });
});

it('should throw an error if there is an additional parameter included in a compiled query', () => {
const sqlStatement = 'SELECT * FROM table';
const parameters = ['additional parameter'];
const query = {
compile: () => ({ sql: sqlStatement, parameters: ['test'] }),
execute: () => Promise.resolve([])
};
const result = () => SUT.parseQuery(query, parameters);

expect(result).toThrowError('You cannot pass parameters to a compiled query.');
});
});
4 changes: 2 additions & 2 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,14 @@ const Component = () => {

### Queries

Queries will automatically update when a dependant table is updated unless you set the `runQueryOnce` flag.
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.

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

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

return <View>
{todoLists.map((l) => (
Expand Down
20 changes: 16 additions & 4 deletions packages/react/src/hooks/useQuery.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { SQLWatchOptions } from '@powersync/common';
import { type SQLWatchOptions, parseQuery, type CompilableQuery, type ParsedQuery } from '@powersync/common';
import React from 'react';
import { usePowerSync } from './PowerSyncContext';

interface AdditionalOptions extends Omit<SQLWatchOptions, 'signal'> {
export interface AdditionalOptions extends Omit<SQLWatchOptions, 'signal'> {
runQueryOnce?: boolean;
}

Expand Down Expand Up @@ -37,7 +37,7 @@ export type QueryResult<T> = {
* }
*/
export const useQuery = <T = any>(
sqlStatement: string,
query: string | CompilableQuery<T>,
parameters: any[] = [],
options: AdditionalOptions = { runQueryOnce: false }
): QueryResult<T> => {
Expand All @@ -46,13 +46,23 @@ export const useQuery = <T = any>(
return { isLoading: false, isFetching: false, data: [], error: new Error('PowerSync not configured.') };
}

let parsedQuery: ParsedQuery;
try {
parsedQuery = parseQuery(query, parameters);
} catch (error) {
console.error('Failed to parse query:', error);
return { isLoading: false, isFetching: false, data: [], error };
}

const { sqlStatement, parameters: queryParameters } = parsedQuery;

const [data, setData] = React.useState<T[]>([]);
const [error, setError] = React.useState<Error | undefined>(undefined);
const [isLoading, setIsLoading] = React.useState(true);
const [isFetching, setIsFetching] = React.useState(true);
const [tables, setTables] = React.useState([]);

const memoizedParams = React.useMemo(() => parameters, [...parameters]);
const memoizedParams = React.useMemo(() => parameters, [...queryParameters]);
const memoizedOptions = React.useMemo(() => options, [JSON.stringify(options)]);
const abortController = React.useRef(new AbortController());

Expand All @@ -78,6 +88,7 @@ export const useQuery = <T = any>(
const result = await powerSync.getAll<T>(sqlStatement, parameters);
handleResult(result);
} catch (e) {
console.error('Failed to fetch data:', e);
handleError(e);
}
};
Expand All @@ -87,6 +98,7 @@ export const useQuery = <T = any>(
const tables = await powerSync.resolveTables(sqlStatement, memoizedParams, memoizedOptions);
setTables(tables);
} catch (e) {
console.error('Failed to fetch tables:', e);
handleError(e);
}
};
Expand Down
55 changes: 49 additions & 6 deletions packages/react/tests/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import { vi, describe, expect, it, afterEach } from 'vitest';
import { useQuery } from '../src/hooks/useQuery';
import { PowerSyncContext } from '../src/hooks/PowerSyncContext';
import * as commonSdk from '@powersync/common';

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

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

const { result } = renderHook(() => useQuery('SELECT * from lists'), { wrapper });
const currentResult = await result.current;
const currentResult = result.current;
expect(currentResult.isLoading).toEqual(true);
});

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

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

waitFor(
Expand All @@ -68,7 +69,7 @@ describe('useQuery', () => {
);

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

let refresh;
Expand Down Expand Up @@ -104,7 +105,7 @@ describe('useQuery', () => {
);

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

waitFor(
async () => {
Expand Down Expand Up @@ -132,7 +133,7 @@ describe('useQuery', () => {
);

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

waitFor(
async () => {
Expand All @@ -142,5 +143,47 @@ describe('useQuery', () => {
);
});

it('should accept compilable queries', async () => {
const wrapper = ({ children }) => (
<PowerSyncContext.Provider value={mockPowerSync as any}>{children}</PowerSyncContext.Provider>
);

const { result } = renderHook(
() => useQuery({ execute: () => [] as any, compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) }),
{ wrapper }
);
const currentResult = result.current;
expect(currentResult.isLoading).toEqual(true);
});

// The test returns unhandled errors when run with all the others.
// TODO: Fix the test so that there are no unhandled errors (this may be a vitest or @testing-library/react issue)
it.skip('should show an error if parsing the query results in an error', async () => {
const wrapper = ({ children }) => (
<PowerSyncContext.Provider value={mockPowerSync as any}>{children}</PowerSyncContext.Provider>
);
vi.spyOn(commonSdk, 'parseQuery').mockReturnValue(Error('error') as any);

const { result } = renderHook(
() =>
useQuery({
execute: () => [] as any,
compile: () => ({ sql: 'SELECT * from lists', parameters: ['param'] })
}),
{ wrapper }
);
const currentResult = result.current;

waitFor(
async () => {
expect(currentResult.isLoading).toEqual(false);
expect(currentResult.isFetching).toEqual(false);
expect(currentResult.data).toEqual([]);
expect(currentResult.error).toEqual(Error('error'));
},
{ timeout: 100 }
);
});

// TODO: Add tests for powersync.onChangeWithCallback path
});
Loading

0 comments on commit c94be6a

Please sign in to comment.