diff --git a/.changeset/khaki-apples-hunt.md b/.changeset/khaki-apples-hunt.md new file mode 100644 index 00000000..f870da7e --- /dev/null +++ b/.changeset/khaki-apples-hunt.md @@ -0,0 +1,6 @@ +--- +"@powersync/common": minor +"@powersync/react": minor +--- + +Allow compilable queries to be used as hook arguments diff --git a/demos/django-react-native-todolist/library/widgets/HeaderWidget.tsx b/demos/django-react-native-todolist/library/widgets/HeaderWidget.tsx index 0dff32a2..f139be2a 100644 --- a/demos/django-react-native-todolist/library/widgets/HeaderWidget.tsx +++ b/demos/django-react-native-todolist/library/widgets/HeaderWidget.tsx @@ -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}` ); }} diff --git a/demos/react-native-supabase-group-chat/src/app/(app)/contacts/index.tsx b/demos/react-native-supabase-group-chat/src/app/(app)/contacts/index.tsx index 9cdee5ff..6d476a3c 100644 --- a/demos/react-native-supabase-group-chat/src/app/(app)/contacts/index.tsx +++ b/demos/react-native-supabase-group-chat/src/app/(app)/contacts/index.tsx @@ -112,7 +112,7 @@ export default function ContactsIndex() { icon={} backgroundColor="$brand1" borderRadius="$3" - // circular + // circular /> diff --git a/packages/common/package.json b/packages/common/package.json index 1359fb26..d29f3cfb 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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", @@ -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" } } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index b40d1cdb..db32a6a8 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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'; diff --git a/packages/common/src/types/types.ts b/packages/common/src/types/types.ts new file mode 100644 index 00000000..b7f48db5 --- /dev/null +++ b/packages/common/src/types/types.ts @@ -0,0 +1,9 @@ +export interface CompilableQuery { + execute(): Promise; + compile(): CompiledQuery; +} + +export interface CompiledQuery { + readonly sql: string; + readonly parameters: ReadonlyArray; +} diff --git a/packages/common/src/utils/parseQuery.ts b/packages/common/src/utils/parseQuery.ts new file mode 100644 index 00000000..8981cae1 --- /dev/null +++ b/packages/common/src/utils/parseQuery.ts @@ -0,0 +1,25 @@ +import type { CompilableQuery } from '../types/types'; + +export interface ParsedQuery { + sqlStatement: string; + parameters: any[]; +} + +export const parseQuery = (query: string | CompilableQuery, 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 }; +}; diff --git a/packages/common/tests/tsconfig.json b/packages/common/tests/tsconfig.json new file mode 100644 index 00000000..41af59e8 --- /dev/null +++ b/packages/common/tests/tsconfig.json @@ -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/**/*"] +} diff --git a/packages/common/tests/utils/parseQuery.test.ts b/packages/common/tests/utils/parseQuery.test.ts new file mode 100644 index 00000000..52b97698 --- /dev/null +++ b/packages/common/tests/utils/parseQuery.test.ts @@ -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.'); + }); +}); diff --git a/packages/react/README.md b/packages/react/README.md index b2e9096b..0519057d 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -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 {todoLists.map((l) => ( diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index 706b61d0..234a0c8a 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -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 { +export interface AdditionalOptions extends Omit { runQueryOnce?: boolean; } @@ -37,7 +37,7 @@ export type QueryResult = { * } */ export const useQuery = ( - sqlStatement: string, + query: string | CompilableQuery, parameters: any[] = [], options: AdditionalOptions = { runQueryOnce: false } ): QueryResult => { @@ -46,13 +46,23 @@ export const useQuery = ( 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([]); const [error, setError] = React.useState(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()); @@ -78,6 +88,7 @@ export const useQuery = ( const result = await powerSync.getAll(sqlStatement, parameters); handleResult(result); } catch (e) { + console.error('Failed to fetch data:', e); handleError(e); } }; @@ -87,6 +98,7 @@ export const useQuery = ( const tables = await powerSync.resolveTables(sqlStatement, memoizedParams, memoizedOptions); setTables(tables); } catch (e) { + console.error('Failed to fetch tables:', e); handleError(e); } }; diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 7206b468..a7ea4d3f 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -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' }, @@ -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([]); @@ -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); }); @@ -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( @@ -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; @@ -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 () => { @@ -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 () => { @@ -142,5 +143,47 @@ describe('useQuery', () => { ); }); + it('should accept compilable queries', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + 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 }) => ( + {children} + ); + 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 }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e056f6f..d443c80e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1154,6 +1154,9 @@ importers: typescript: specifier: ^5.1.3 version: 5.4.5 + vitest: + specifier: ^1.5.2 + version: 1.5.2(@types/node@20.12.5) packages/kysely-driver: dependencies: @@ -17385,6 +17388,14 @@ packages: chai: 4.4.1 dev: true + /@vitest/expect@1.5.2: + resolution: {integrity: sha512-rf7MTD1WCoDlN3FfYJ9Llfp0PbdtOMZ3FIF0AVkDnKbp3oiMW1c8AmvRZBcqbAhDUAvF52e9zx4WQM1r3oraVA==} + dependencies: + '@vitest/spy': 1.5.2 + '@vitest/utils': 1.5.2 + chai: 4.4.1 + dev: true + /@vitest/runner@1.3.1: resolution: {integrity: sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==} dependencies: @@ -17393,6 +17404,14 @@ packages: pathe: 1.1.2 dev: true + /@vitest/runner@1.5.2: + resolution: {integrity: sha512-7IJ7sJhMZrqx7HIEpv3WrMYcq8ZNz9L6alo81Y6f8hV5mIE6yVZsFoivLZmr0D777klm1ReqonE9LyChdcmw6g==} + dependencies: + '@vitest/utils': 1.5.2 + p-limit: 5.0.0 + pathe: 1.1.2 + dev: true + /@vitest/snapshot@1.3.1: resolution: {integrity: sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==} dependencies: @@ -17401,12 +17420,26 @@ packages: pretty-format: 29.7.0 dev: true + /@vitest/snapshot@1.5.2: + resolution: {integrity: sha512-CTEp/lTYos8fuCc9+Z55Ga5NVPKUgExritjF5VY7heRFUfheoAqBneUlvXSUJHUZPjnPmyZA96yLRJDP1QATFQ==} + dependencies: + magic-string: 0.30.9 + pathe: 1.1.2 + pretty-format: 29.7.0 + dev: true + /@vitest/spy@1.3.1: resolution: {integrity: sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==} dependencies: tinyspy: 2.2.1 dev: true + /@vitest/spy@1.5.2: + resolution: {integrity: sha512-xCcPvI8JpCtgikT9nLpHPL1/81AYqZy1GCy4+MCHBE7xi8jgsYkULpW5hrx5PGLgOQjUpb6fd15lqcriJ40tfQ==} + dependencies: + tinyspy: 2.2.1 + dev: true + /@vitest/utils@1.3.1: resolution: {integrity: sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==} dependencies: @@ -17416,6 +17449,15 @@ packages: pretty-format: 29.7.0 dev: true + /@vitest/utils@1.5.2: + resolution: {integrity: sha512-sWOmyofuXLJ85VvXNsroZur7mOJGiQeM0JN3/0D1uU8U9bGFM69X1iqHaRXl6R8BwaLY6yPCogP257zxTzkUdA==} + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + dev: true + /@volar/language-core@2.2.0-alpha.7: resolution: {integrity: sha512-igpp+nTkyl8faVzRJMpSCeA4XlBJ5UVSyc/WGyksmUmP10YbfufbcQCFlxEXv2uMBV+a3L4JVCj+Vju+08FOSA==} dependencies: @@ -34160,6 +34202,11 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + dev: true + /tinyspy@2.2.1: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} @@ -35191,6 +35238,27 @@ packages: - terser dev: true + /vite-node@1.5.2(@types/node@20.12.5): + resolution: {integrity: sha512-Y8p91kz9zU+bWtF7HGt6DVw2JbhyuB2RlZix3FPYAYmUyZ3n7iTp8eSyLyY6sxtPegvxQtmlTMhfPhUfCUF93A==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.4(supports-color@8.1.1) + pathe: 1.1.2 + picocolors: 1.0.0 + vite: 5.2.8(@types/node@20.12.5) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vite-plugin-pwa@0.19.2(vite@5.1.5)(workbox-build@7.0.0)(workbox-window@7.0.0): resolution: {integrity: sha512-LSQJFPxCAQYbRuSyc9EbRLRqLpaBA9onIZuQFomfUYjWSgHuQLonahetDlPSC9zsxmkSEhQH8dXZN8yL978h3w==} engines: {node: '>=16.0.0'} @@ -35616,6 +35684,42 @@ packages: fsevents: 2.3.3 dev: true + /vite@5.2.8(@types/node@20.12.5): + resolution: {integrity: sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 20.12.5 + esbuild: 0.20.2 + postcss: 8.4.38 + rollup: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /vite@5.2.8(sass@1.71.1): resolution: {integrity: sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -35708,6 +35812,62 @@ packages: - terser dev: true + /vitest@1.5.2(@types/node@20.12.5): + resolution: {integrity: sha512-l9gwIkq16ug3xY7BxHwcBQovLZG75zZL0PlsiYQbf76Rz6QGs54416UWMtC0jXeihvHvcHrf2ROEjkQRVpoZYw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.5.2 + '@vitest/ui': 1.5.2 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/node': 20.12.5 + '@vitest/expect': 1.5.2 + '@vitest/runner': 1.5.2 + '@vitest/snapshot': 1.5.2 + '@vitest/spy': 1.5.2 + '@vitest/utils': 1.5.2 + acorn-walk: 8.3.2 + chai: 4.4.1 + debug: 4.3.4(supports-color@8.1.1) + execa: 8.0.1 + local-pkg: 0.5.0 + magic-string: 0.30.9 + pathe: 1.1.2 + picocolors: 1.0.0 + std-env: 3.7.0 + strip-literal: 2.0.0 + tinybench: 2.6.0 + tinypool: 0.8.4 + vite: 5.2.8(@types/node@20.12.5) + vite-node: 1.5.2(@types/node@20.12.5) + why-is-node-running: 2.2.2 + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==}