diff --git a/demos/react-supabase-todolist/src/app/router.tsx b/demos/react-supabase-todolist/src/app/router.tsx index 8268cc66..59ae0d38 100644 --- a/demos/react-supabase-todolist/src/app/router.tsx +++ b/demos/react-supabase-todolist/src/app/router.tsx @@ -2,10 +2,11 @@ import { Outlet, createBrowserRouter } from "react-router-dom"; import LoginPage from "./auth/login/page"; import RegisterPage from "./auth/register/page"; import EntryPage from "./page"; -import TodoEditPage from "./views/todo-lists/edit/page"; -import TodoListsPage from "./views/todo-lists/page"; +import TodoEditPage, { todoPageLoader } from "./views/todo-lists/edit/page"; +import TodoListsPage, { todoListsLoader } from "./views/todo-lists/page"; import ViewsLayout from "./views/layout"; import SQLConsolePage from "./views/sql-console/page"; +import { db } from "@/components/providers/SystemProvider"; export const TODO_LISTS_ROUTE = '/views/todo-lists'; export const TODO_EDIT_ROUTE = '/views/todo-lists/:id'; @@ -38,11 +39,13 @@ export const router = createBrowserRouter([ children: [ { path: TODO_LISTS_ROUTE, - element: + element: , + loader: todoListsLoader(db) }, { path: TODO_EDIT_ROUTE, - element: + element: , + loader: todoPageLoader(db) }, { path: SQL_CONSOLE_ROUTE, diff --git a/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx b/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx index c92f52db..86976758 100644 --- a/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx @@ -1,7 +1,10 @@ +import { NavigationPage } from '@/components/navigation/NavigationPage'; import { useSupabase } from '@/components/providers/SystemProvider'; import { TodoItemWidget } from '@/components/widgets/TodoItemWidget'; +import { TodoListsWidget, loadTodoLists } from '@/components/widgets/TodoListsWidget'; import { LISTS_TABLE, TODOS_TABLE, TodoRecord } from '@/library/powersync/AppSchema'; import { usePowerSync, usePowerSyncWatchedQuery } from '@journeyapps/powersync-react'; +import { AbstractPowerSyncDatabase } from '@journeyapps/powersync-sdk-web'; import AddIcon from '@mui/icons-material/Add'; import { Box, @@ -12,6 +15,7 @@ import { DialogContent, DialogContentText, DialogTitle, + Grid, List, TextField, Typography, @@ -19,8 +23,17 @@ import { } from '@mui/material'; import Fab from '@mui/material/Fab'; import React, { Suspense } from 'react'; -import { useParams } from 'react-router-dom'; -import { NavigationPage } from '@/components/navigation/NavigationPage'; +import { LoaderFunctionArgs, useLoaderData, useParams } from 'react-router-dom'; + +export const todoPageLoader = (db: AbstractPowerSyncDatabase) => async ({ params }: LoaderFunctionArgs) => { + return { + todos: await db.query(`SELECT * FROM ${TODOS_TABLE} WHERE list_id=? ORDER BY created_at DESC, id`, [params.id]).preload(), + list_names: await db.query<{ name: string }>(`SELECT name FROM ${LISTS_TABLE} WHERE id = ?`, [params.id]).preload(), + lists: await loadTodoLists(db) + }; +} + +type QueryLoaderType = Awaited>>; /** * useSearchParams causes the entire element to fall back to client side rendering @@ -31,15 +44,10 @@ const TodoEditSection = () => { const powerSync = usePowerSync(); const supabase = useSupabase(); const { id: listID } = useParams(); + const queryResults = useLoaderData() as QueryLoaderType; - const [listRecord] = usePowerSyncWatchedQuery<{ name: string }>(`SELECT name FROM ${LISTS_TABLE} WHERE id = ?`, [ - listID - ]); - - const todos = usePowerSyncWatchedQuery( - `SELECT * FROM ${TODOS_TABLE} WHERE list_id=? ORDER BY created_at DESC, id`, - [listID] - ); + const [listRecord] = usePowerSyncWatchedQuery(queryResults.list_names); + const todos = usePowerSyncWatchedQuery(queryResults.todos); const [showPrompt, setShowPrompt] = React.useState(false); const nameInputRef = React.createRef(); @@ -104,17 +112,24 @@ const TodoEditSection = () => { - - {todos.map((r) => ( - deleteTodo(r.id)} - isComplete={r.completed == 1} - toggleCompletion={() => toggleCompletion(r, !r.completed)} - /> - ))} - + + + + + + + {todos.map((r) => ( + deleteTodo(r.id)} + isComplete={r.completed == 1} + toggleCompletion={() => toggleCompletion(r, !r.completed)} + /> + ))} + + + {/* TODO use a dialog service in future, this is just a simple example app */} async ({ params }: LoaderFunctionArgs) => { + return { + lists: await loadTodoLists(db) + }; +} + +type QueryLoaderType = Awaited>>; + export default function TodoListsPage() { const powerSync = usePowerSync(); const supabase = useSupabase(); + const queries = useLoaderData() as QueryLoaderType; const [showPrompt, setShowPrompt] = React.useState(false); const nameInputRef = React.createRef(); @@ -50,7 +63,7 @@ export default function TodoListsPage() { - + {/* TODO use a dialog service in future, this is just a simple example app */} } const description = (total: number, completed: number = 0) => { return `${total - completed} pending, ${completed} completed`; }; +export const loadTodoLists = async (db: AbstractPowerSyncDatabase) => { + return await db.query(` + SELECT + ${LISTS_TABLE}.*, COUNT(${TODOS_TABLE}.id) AS total_tasks, SUM(CASE WHEN ${TODOS_TABLE}.completed = true THEN 1 ELSE 0 END) as completed_tasks + FROM + ${LISTS_TABLE} + LEFT JOIN ${TODOS_TABLE} + ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_id + GROUP BY + ${LISTS_TABLE}.id; + `).preload(); +} + export function TodoListsWidget(props: TodoListsWidgetProps) { const powerSync = usePowerSync(); const navigate = useNavigate(); - const listRecords = usePowerSyncWatchedQuery(` - SELECT - ${LISTS_TABLE}.*, COUNT(${TODOS_TABLE}.id) AS total_tasks, SUM(CASE WHEN ${TODOS_TABLE}.completed = true THEN 1 ELSE 0 END) as completed_tasks - FROM - ${LISTS_TABLE} - LEFT JOIN ${TODOS_TABLE} - ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_id - GROUP BY - ${LISTS_TABLE}.id; - `); + const listRecords = usePowerSyncWatchedQuery(props.lists); const deleteList = async (id: string) => { await powerSync.writeTransaction(async (tx) => { diff --git a/packages/powersync-react/src/hooks/usePowerSyncWatchedQuery.ts b/packages/powersync-react/src/hooks/usePowerSyncWatchedQuery.ts index b97b9a11..4c7e9f0c 100644 --- a/packages/powersync-react/src/hooks/usePowerSyncWatchedQuery.ts +++ b/packages/powersync-react/src/hooks/usePowerSyncWatchedQuery.ts @@ -1,4 +1,4 @@ -import { SQLWatchOptions } from '@journeyapps/powersync-sdk-common'; +import { QueryWithResult, SQLWatchOptions } from '@journeyapps/powersync-sdk-common'; import React from 'react'; import { usePowerSync } from './PowerSyncContext'; @@ -6,18 +6,61 @@ import { usePowerSync } from './PowerSyncContext'; * A hook to access the results of a watched query. */ export const usePowerSyncWatchedQuery = ( - sqlStatement: string, + query: string | QueryWithResult, parameters: any[] = [], options: Omit = {} ): T[] => { + let initialResults = []; + + if (typeof query != 'string') { + initialResults = query.initialResults; + parameters = query.query.parameters; + query = query.query.sql; + } + const powerSync = usePowerSync(); if (!powerSync) { - return []; + return initialResults; } const memoizedParams = React.useMemo(() => parameters, [...parameters]); const memoizedOptions = React.useMemo(() => options, [JSON.stringify(options)]); - const [data, setData] = React.useState([]); + const [data, setData] = React.useState(initialResults); + const abortController = React.useRef(new AbortController()); + + React.useEffect(() => { + // Abort any previous watches + abortController.current?.abort(); + abortController.current = new AbortController(); + (async () => { + for await (const result of powerSync.watch(query as string, parameters, { + ...options, + signal: abortController.current.signal + })) { + setData(result.rows?._array ?? []); + } + })(); + + return () => { + abortController.current?.abort(); + }; + }, [powerSync, query, memoizedParams, memoizedOptions]); + + return data; +}; + +export const useWatchedQuery = ( + query: QueryWithResult, + options: Omit = {} +): T[] => { + const powerSync = usePowerSync(); + if (!powerSync) { + return []; + } + + const memoizedParams = React.useMemo(() => query.query.parameters, [...query.query.parameters]); + const memoizedOptions = React.useMemo(() => options, [JSON.stringify(options)]); + const [data, setData] = React.useState(query.initialResults); const abortController = React.useRef(new AbortController()); React.useEffect(() => { @@ -25,7 +68,7 @@ export const usePowerSyncWatchedQuery = ( abortController.current?.abort(); abortController.current = new AbortController(); (async () => { - for await (const result of powerSync.watch(sqlStatement, parameters, { + for await (const result of powerSync.watch(query.query.sql, memoizedParams, { ...options, signal: abortController.current.signal })) { @@ -36,7 +79,7 @@ export const usePowerSyncWatchedQuery = ( return () => { abortController.current?.abort(); }; - }, [powerSync, sqlStatement, memoizedParams, memoizedOptions]); + }, [powerSync, query.query.sql, memoizedParams, memoizedOptions]); return data; }; diff --git a/packages/powersync-sdk-common/src/client/AbstractPowerSyncDatabase.ts b/packages/powersync-sdk-common/src/client/AbstractPowerSyncDatabase.ts index 7dec6d10..f4cb3d44 100644 --- a/packages/powersync-sdk-common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/powersync-sdk-common/src/client/AbstractPowerSyncDatabase.ts @@ -20,6 +20,7 @@ import { StreamingSyncImplementationListener, StreamingSyncImplementation } from './sync/stream/AbstractStreamingSyncImplementation'; +import { Query } from '../db/Query'; export interface DisconnectAndClearOptions { /** When set to false, data in local-only tables is preserved. */ @@ -481,6 +482,10 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver(sql: string, parameters?: any[]): Query { + return new Query(this, sql, parameters); + } + /** * Takes a read lock, without starting a transaction. * In most cases, {@link readTransaction} should be used instead. diff --git a/packages/powersync-sdk-common/src/db/Query.ts b/packages/powersync-sdk-common/src/db/Query.ts new file mode 100644 index 00000000..32d8da81 --- /dev/null +++ b/packages/powersync-sdk-common/src/db/Query.ts @@ -0,0 +1,49 @@ +import type { AbstractPowerSyncDatabase, SQLWatchOptions } from '../client/AbstractPowerSyncDatabase'; +import { QueryResult } from './DBAdapter'; + +export class Query { + db: AbstractPowerSyncDatabase; + sql: string; + parameters: any[]; + + constructor(db: AbstractPowerSyncDatabase, sql: string, parameters?: any[]) { + this.db = db; + this.sql = sql; + this.parameters = parameters ?? []; + } + + execute(): Promise { + return this.db.execute(this.sql, this.parameters); + } + + getAll(): Promise { + return this.db.getAll(this.sql, this.parameters); + } + + get(): Promise { + return this.db.get(this.sql, this.parameters); + } + + getOptional(): Promise { + return this.db.getOptional(this.sql, this.parameters); + } + + async *watch(options?: SQLWatchOptions): AsyncIterable { + for await (let r of this.db.watch(this.sql, this.parameters, options)) { + yield r.rows!._array; + } + } + + async preload(): Promise> { + const r = await this.getAll(); + return { + initialResults: r, + query: this + }; + } +} + +export interface QueryWithResult { + initialResults: T[]; + query: Query; +} diff --git a/packages/powersync-sdk-common/src/index.ts b/packages/powersync-sdk-common/src/index.ts index 84c1dd33..dc4374b5 100644 --- a/packages/powersync-sdk-common/src/index.ts +++ b/packages/powersync-sdk-common/src/index.ts @@ -29,3 +29,5 @@ export * from './db/schema/TableV2'; export * from './utils/BaseObserver'; export * from './utils/strings'; + +export * from './db/Query';