Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

POC: Preloading queries #84

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions demos/react-supabase-todolist/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,11 +39,13 @@ export const router = createBrowserRouter([
children: [
{
path: TODO_LISTS_ROUTE,
element: <TodoListsPage />
element: <TodoListsPage />,
loader: todoListsLoader(db)
},
{
path: TODO_EDIT_ROUTE,
element: <TodoEditPage />
element: <TodoEditPage />,
loader: todoPageLoader(db)
},
{
path: SQL_CONSOLE_ROUTE,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,15 +15,25 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
Grid,
List,
TextField,
Typography,
styled
} 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<TodoRecord>(`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<ReturnType<ReturnType<typeof todoPageLoader>>>;

/**
* useSearchParams causes the entire element to fall back to client side rendering
Expand All @@ -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<TodoRecord>(
`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<HTMLInputElement>();
Expand Down Expand Up @@ -104,17 +112,24 @@ const TodoEditSection = () => {
<AddIcon />
</S.FloatingActionButton>
<Box>
<List dense={false}>
{todos.map((r) => (
<TodoItemWidget
key={r.id}
description={r.description}
onDelete={() => deleteTodo(r.id)}
isComplete={r.completed == 1}
toggleCompletion={() => toggleCompletion(r, !r.completed)}
/>
))}
</List>
<Grid container>
<Grid item xs={4}>
<TodoListsWidget selectedId={listID} lists={queryResults.lists} />
</Grid>
<Grid item xs={8}>
<List dense={false}>
{todos.map((r) => (
<TodoItemWidget
key={r.id}
description={r.description}
onDelete={() => deleteTodo(r.id)}
isComplete={r.completed == 1}
toggleCompletion={() => toggleCompletion(r, !r.completed)}
/>
))}
</List>
</Grid>
</Grid>
</Box>
{/* TODO use a dialog service in future, this is just a simple example app */}
<Dialog
Expand Down
17 changes: 15 additions & 2 deletions demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,25 @@ import Fab from '@mui/material/Fab';
import React from 'react';
import { NavigationPage } from '@/components/navigation/NavigationPage';
import { useSupabase } from '@/components/providers/SystemProvider';
import { TodoListsWidget } from '@/components/widgets/TodoListsWidget';
import { TodoListsWidget, loadTodoLists } from '@/components/widgets/TodoListsWidget';
import { LISTS_TABLE } from '@/library/powersync/AppSchema';
import { AbstractPowerSyncDatabase } from '@journeyapps/powersync-sdk-web';
import { LoaderFunctionArgs, useLoaderData } from 'react-router-dom';


export const todoListsLoader = (db: AbstractPowerSyncDatabase) => async ({ params }: LoaderFunctionArgs) => {
return {
lists: await loadTodoLists(db)
};
}

type QueryLoaderType = Awaited<ReturnType<ReturnType<typeof todoListsLoader>>>;


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<HTMLInputElement>();
Expand Down Expand Up @@ -50,7 +63,7 @@ export default function TodoListsPage() {
<AddIcon />
</S.FloatingActionButton>
<Box>
<TodoListsWidget />
<TodoListsWidget lists={queries.lists} />
</Box>
{/* TODO use a dialog service in future, this is just a simple example app */}
<Dialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,35 @@ import { usePowerSync, usePowerSyncWatchedQuery } from "@journeyapps/powersync-r
import { List } from "@mui/material";
import { useNavigate } from 'react-router-dom';
import { ListItemWidget } from "./ListItemWidget";
import { AbstractPowerSyncDatabase, QueryWithResult } from '@journeyapps/powersync-sdk-web';

export type TodoListsWidgetProps = {
selectedId?: string;
lists: QueryWithResult<ListRecord & { total_tasks: number; completed_tasks: number }>
}

const description = (total: number, completed: number = 0) => {
return `${total - completed} pending, ${completed} completed`;
};

export const loadTodoLists = async (db: AbstractPowerSyncDatabase) => {
return await db.query<ListRecord & { total_tasks: number; completed_tasks: number }>(`
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<ListRecord & { total_tasks: number; completed_tasks: number }>(`
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) => {
Expand Down
55 changes: 49 additions & 6 deletions packages/powersync-react/src/hooks/usePowerSyncWatchedQuery.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,74 @@
import { SQLWatchOptions } from '@journeyapps/powersync-sdk-common';
import { QueryWithResult, SQLWatchOptions } from '@journeyapps/powersync-sdk-common';
import React from 'react';
import { usePowerSync } from './PowerSyncContext';

/**
* A hook to access the results of a watched query.
*/
export const usePowerSyncWatchedQuery = <T = any>(
sqlStatement: string,
query: string | QueryWithResult<T>,
parameters: any[] = [],
options: Omit<SQLWatchOptions, 'signal'> = {}
): 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<T[]>([]);
const [data, setData] = React.useState<T[]>(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 = <T = any>(
query: QueryWithResult<T>,
options: Omit<SQLWatchOptions, 'signal'> = {}
): 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<T[]>(query.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(sqlStatement, parameters, {
for await (const result of powerSync.watch(query.query.sql, memoizedParams, {
...options,
signal: abortController.current.signal
})) {
Expand All @@ -36,7 +79,7 @@ export const usePowerSyncWatchedQuery = <T = any>(
return () => {
abortController.current?.abort();
};
}, [powerSync, sqlStatement, memoizedParams, memoizedOptions]);
}, [powerSync, query.query.sql, memoizedParams, memoizedOptions]);

return data;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -481,6 +482,10 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
return this.database.get(sql, parameters);
}

query<T>(sql: string, parameters?: any[]): Query<T> {
return new Query<T>(this, sql, parameters);
}

/**
* Takes a read lock, without starting a transaction.
* In most cases, {@link readTransaction} should be used instead.
Expand Down
49 changes: 49 additions & 0 deletions packages/powersync-sdk-common/src/db/Query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { AbstractPowerSyncDatabase, SQLWatchOptions } from '../client/AbstractPowerSyncDatabase';
import { QueryResult } from './DBAdapter';

export class Query<T> {
db: AbstractPowerSyncDatabase;
sql: string;
parameters: any[];

constructor(db: AbstractPowerSyncDatabase, sql: string, parameters?: any[]) {
this.db = db;
this.sql = sql;
this.parameters = parameters ?? [];
}

execute(): Promise<QueryResult> {
return this.db.execute(this.sql, this.parameters);
}

getAll(): Promise<T[]> {
return this.db.getAll<T>(this.sql, this.parameters);
}

get(): Promise<T> {
return this.db.get<T>(this.sql, this.parameters);
}

getOptional(): Promise<T | null> {
return this.db.getOptional<T>(this.sql, this.parameters);
}

async *watch(options?: SQLWatchOptions): AsyncIterable<T[]> {
for await (let r of this.db.watch(this.sql, this.parameters, options)) {
yield r.rows!._array;
}
}

async preload(): Promise<QueryWithResult<T>> {
const r = await this.getAll();
return {
initialResults: r,
query: this
};
}
}

export interface QueryWithResult<T> {
initialResults: T[];
query: Query<T>;
}
2 changes: 2 additions & 0 deletions packages/powersync-sdk-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ export * from './db/schema/TableV2';

export * from './utils/BaseObserver';
export * from './utils/strings';

export * from './db/Query';
Loading