From e1be20e02575ccc70c45fd30c3e68b060095cc8c Mon Sep 17 00:00:00 2001 From: Mughees Khan Date: Wed, 18 Dec 2024 14:13:19 +0200 Subject: [PATCH] Feat: FTS for react native supabase demo (#447) --- .../app/_layout.tsx | 19 +++- .../app/search_modal.tsx | 21 +++++ .../ios/Podfile.lock | 10 +- .../library/fts/fts_helpers.ts | 33 +++++++ .../library/fts/fts_setup.ts | 69 ++++++++++++++ .../library/fts/helpers.ts | 36 +++++++ .../library/powersync/system.ts | 5 + .../library/widgets/AutoCompleteWidget.tsx | 93 +++++++++++++++++++ .../library/widgets/HeaderWidget.tsx | 64 +++++++++---- .../library/widgets/SearchBarWidget.tsx | 52 +++++++++++ .../package.json | 2 +- pnpm-lock.yaml | 23 +++-- 12 files changed, 394 insertions(+), 33 deletions(-) create mode 100644 demos/react-native-supabase-todolist/app/search_modal.tsx create mode 100644 demos/react-native-supabase-todolist/library/fts/fts_helpers.ts create mode 100644 demos/react-native-supabase-todolist/library/fts/fts_setup.ts create mode 100644 demos/react-native-supabase-todolist/library/fts/helpers.ts create mode 100644 demos/react-native-supabase-todolist/library/widgets/AutoCompleteWidget.tsx create mode 100644 demos/react-native-supabase-todolist/library/widgets/SearchBarWidget.tsx diff --git a/demos/react-native-supabase-todolist/app/_layout.tsx b/demos/react-native-supabase-todolist/app/_layout.tsx index 8efcf8ab..941d4095 100644 --- a/demos/react-native-supabase-todolist/app/_layout.tsx +++ b/demos/react-native-supabase-todolist/app/_layout.tsx @@ -1,7 +1,9 @@ -import { Stack } from 'expo-router'; +import { router, Stack } from 'expo-router'; import React, { useMemo } from 'react'; import { useSystem } from '../library/powersync/system'; import { PowerSyncContext } from '@powersync/react-native'; +import { Pressable } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; /** * This App uses a nested navigation stack. @@ -30,6 +32,21 @@ const HomeLayout = () => { + ( + { + router.back(); + }}> + + + ), + presentation: 'fullScreenModal' + }} + /> ); diff --git a/demos/react-native-supabase-todolist/app/search_modal.tsx b/demos/react-native-supabase-todolist/app/search_modal.tsx new file mode 100644 index 00000000..6024a6ae --- /dev/null +++ b/demos/react-native-supabase-todolist/app/search_modal.tsx @@ -0,0 +1,21 @@ +import { StatusBar } from 'expo-status-bar'; +import { StyleSheet, View } from 'react-native'; +import { SearchBarWidget } from '../library/widgets/SearchBarWidget'; + +export default function Modal() { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center' + } +}); diff --git a/demos/react-native-supabase-todolist/ios/Podfile.lock b/demos/react-native-supabase-todolist/ios/Podfile.lock index bcfd7481..b589188c 100644 --- a/demos/react-native-supabase-todolist/ios/Podfile.lock +++ b/demos/react-native-supabase-todolist/ios/Podfile.lock @@ -1005,7 +1005,7 @@ PODS: - React-debug - react-native-encrypted-storage (4.0.3): - React-Core - - react-native-quick-sqlite (2.2.0): + - react-native-quick-sqlite (2.2.1): - DoubleConversion - glog - hermes-engine @@ -1467,7 +1467,7 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - react-native-encrypted-storage (from `../../../node_modules/react-native-encrypted-storage`) - - "react-native-quick-sqlite (from `../../../node_modules/@journeyapps/react-native-quick-sqlite`)" + - "react-native-quick-sqlite (from `../node_modules/@journeyapps/react-native-quick-sqlite`)" - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) - React-nativeconfig (from `../node_modules/react-native/ReactCommon`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -1593,7 +1593,7 @@ EXTERNAL SOURCES: react-native-encrypted-storage: :path: "../../../node_modules/react-native-encrypted-storage" react-native-quick-sqlite: - :path: "../../../node_modules/@journeyapps/react-native-quick-sqlite" + :path: "../node_modules/@journeyapps/react-native-quick-sqlite" react-native-safe-area-context: :path: "../../../node_modules/react-native-safe-area-context" React-nativeconfig: @@ -1698,7 +1698,7 @@ SPEC CHECKSUMS: React-logger: 257858bd55f3a4e1bc0cf07ddc8fb9faba6f8c7c React-Mapbuffer: 6c1cacdbf40b531f549eba249e531a7d0bfd8e7f react-native-encrypted-storage: db300a3f2f0aba1e818417c1c0a6be549038deb7 - react-native-quick-sqlite: b4b34028dbe2d532beb2575f4b90ae58bec42260 + react-native-quick-sqlite: fa617eb5224e530a0cafe21f35dbff9d98b1a557 react-native-safe-area-context: afa5d614d6b1b73b743c9261985876606560d128 React-nativeconfig: ba9a2e54e2f0882cf7882698825052793ed4c851 React-NativeModulesApple: 8d11ff8955181540585c944cf48e9e7236952697 @@ -1733,4 +1733,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: ad989b4e43152979093488e5c8b7457e401bf191 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/demos/react-native-supabase-todolist/library/fts/fts_helpers.ts b/demos/react-native-supabase-todolist/library/fts/fts_helpers.ts new file mode 100644 index 00000000..5d2a396b --- /dev/null +++ b/demos/react-native-supabase-todolist/library/fts/fts_helpers.ts @@ -0,0 +1,33 @@ +import { system } from '../powersync/system'; + +/** + * adding * to the end of the search term will match any word that starts with the search term + * e.g. searching bl will match blue, black, etc. + * consult FTS5 Full-text Query Syntax documentation for more options + * @param searchTerm + * @returns a modified search term with options. + */ +function createSearchTermWithOptions(searchTerm: string): string { + const searchTermWithOptions: string = `${searchTerm}*`; + return searchTermWithOptions; +} + +/** + * Search the FTS table for the given searchTerm + * @param searchTerm + * @param tableName + * @returns results from the FTS table + */ +export async function searchTable(searchTerm: string, tableName: string): Promise { + const searchTermWithOptions = createSearchTermWithOptions(searchTerm); + return await system.powersync.getAll(`SELECT * FROM fts_${tableName} WHERE fts_${tableName} MATCH ? ORDER BY rank`, [ + searchTermWithOptions + ]); +} + +//Used to display the search results in the autocomplete text field +export interface SearchResult { + id: string; + listName: string; + todoName: string | null; +} diff --git a/demos/react-native-supabase-todolist/library/fts/fts_setup.ts b/demos/react-native-supabase-todolist/library/fts/fts_setup.ts new file mode 100644 index 00000000..574eceaf --- /dev/null +++ b/demos/react-native-supabase-todolist/library/fts/fts_setup.ts @@ -0,0 +1,69 @@ +import { AppSchema } from '../powersync/AppSchema'; +import { ExtractType, generateJsonExtracts } from './helpers'; +import { PowerSyncDatabase } from '@powersync/react-native'; + +/** + * Create a Full Text Search table for the given table and columns + * with an option to use a different tokenizer otherwise it defaults + * to unicode61. It also creates the triggers that keep the FTS table + * and the PowerSync table in sync. + * @param tableName + * @param columns + * @param tokenizationMethod + */ +async function createFtsTable( + db: PowerSyncDatabase, + tableName: string, + columns: string[], + tokenizationMethod = 'unicode61' +): Promise { + const internalName = AppSchema.tables.find((table) => table.name === tableName)?.internalName; + const stringColumns = columns.join(', '); + + return await db.writeTransaction(async (tx) => { + // Add FTS table + await tx.execute(` + CREATE VIRTUAL TABLE IF NOT EXISTS fts_${tableName} + USING fts5(id UNINDEXED, ${stringColumns}, tokenize='${tokenizationMethod}'); + `); + // Copy over records already in table + await tx.execute(` + INSERT OR REPLACE INTO fts_${tableName}(rowid, id, ${stringColumns}) + SELECT rowid, id, ${generateJsonExtracts(ExtractType.columnOnly, 'data', columns)} FROM ${internalName}; + `); + // Add INSERT, UPDATE and DELETE and triggers to keep fts table in sync with table + await tx.execute(` + CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_${tableName} AFTER INSERT ON ${internalName} + BEGIN + INSERT INTO fts_${tableName}(rowid, id, ${stringColumns}) + VALUES ( + NEW.rowid, + NEW.id, + ${generateJsonExtracts(ExtractType.columnOnly, 'NEW.data', columns)} + ); + END; + `); + await tx.execute(` + CREATE TRIGGER IF NOT EXISTS fts_update_trigger_${tableName} AFTER UPDATE ON ${internalName} BEGIN + UPDATE fts_${tableName} + SET ${generateJsonExtracts(ExtractType.columnInOperation, 'NEW.data', columns)} + WHERE rowid = NEW.rowid; + END; + `); + await tx.execute(` + CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_${tableName} AFTER DELETE ON ${internalName} BEGIN + DELETE FROM fts_${tableName} WHERE rowid = OLD.rowid; + END; + `); + }); +} + +/** + * This is where you can add more methods to generate FTS tables in this demo + * that correspond to the tables in your schema and populate them + * with the data you would like to search on + */ +export async function configureFts(db: PowerSyncDatabase): Promise { + await createFtsTable(db, 'lists', ['name'], 'porter unicode61'); + await createFtsTable(db, 'todos', ['description', 'list_id']); +} diff --git a/demos/react-native-supabase-todolist/library/fts/helpers.ts b/demos/react-native-supabase-todolist/library/fts/helpers.ts new file mode 100644 index 00000000..5a9054bd --- /dev/null +++ b/demos/react-native-supabase-todolist/library/fts/helpers.ts @@ -0,0 +1,36 @@ +type ExtractGenerator = (jsonColumnName: string, columnName: string) => string; + +export enum ExtractType { + columnOnly, + columnInOperation +} + +type ExtractGeneratorMap = Map; + +function _createExtract(jsonColumnName: string, columnName: string): string { + return `json_extract(${jsonColumnName}, '$.${columnName}')`; +} + +const extractGeneratorsMap: ExtractGeneratorMap = new Map([ + [ExtractType.columnOnly, (jsonColumnName: string, columnName: string) => _createExtract(jsonColumnName, columnName)], + [ + ExtractType.columnInOperation, + (jsonColumnName: string, columnName: string) => { + const extract = _createExtract(jsonColumnName, columnName); + return `${columnName} = ${extract}`; + } + ] +]); + +export const generateJsonExtracts = (type: ExtractType, jsonColumnName: string, columns: string[]): string => { + const generator = extractGeneratorsMap.get(type); + if (generator == null) { + throw new Error('Unexpected null generator for key: $type'); + } + + if (columns.length == 1) { + return generator(jsonColumnName, columns[0]); + } + + return columns.map((column) => generator(jsonColumnName, column)).join(', '); +}; diff --git a/demos/react-native-supabase-todolist/library/powersync/system.ts b/demos/react-native-supabase-todolist/library/powersync/system.ts index ca78d05c..d95650c4 100644 --- a/demos/react-native-supabase-todolist/library/powersync/system.ts +++ b/demos/react-native-supabase-todolist/library/powersync/system.ts @@ -11,6 +11,7 @@ import { AppConfig } from '../supabase/AppConfig'; import { SupabaseConnector } from '../supabase/SupabaseConnector'; import { AppSchema } from './AppSchema'; import { PhotoAttachmentQueue } from './PhotoAttachmentQueue'; +import { configureFts } from '../fts/fts_setup'; Logger.useDefaults(); @@ -68,6 +69,10 @@ export class System { if (this.attachmentQueue) { await this.attachmentQueue.init(); } + + // Demo using SQLite Full-Text Search with PowerSync. + // See https://docs.powersync.com/usage-examples/full-text-search for more details + await configureFts(this.powersync); } } diff --git a/demos/react-native-supabase-todolist/library/widgets/AutoCompleteWidget.tsx b/demos/react-native-supabase-todolist/library/widgets/AutoCompleteWidget.tsx new file mode 100644 index 00000000..bbc06444 --- /dev/null +++ b/demos/react-native-supabase-todolist/library/widgets/AutoCompleteWidget.tsx @@ -0,0 +1,93 @@ +import { View, StyleSheet } from 'react-native'; +import { Input, ListItem } from '@rneui/themed'; +import React, { useState } from 'react'; +import { IconNode } from '@rneui/base'; + +export interface AutocompleteWidgetProps { + data: any[]; + onChange: (value: string) => void; + placeholder?: string; + onPress: (id: string) => void; + leftIcon?: IconNode; +} + +export const Autocomplete: React.FC = ({ data, onChange, placeholder, onPress, leftIcon }) => { + const [value, setValue] = useState(''); + const [menuVisible, setMenuVisible] = useState(false); + + return ( + + + { + if (value?.length === 0) { + setMenuVisible(true); + } + }} + leftIcon={leftIcon} + placeholder={placeholder} + onBlur={() => setMenuVisible(false)} + underlineColorAndroid={'transparent'} + inputContainerStyle={{ borderBottomWidth: 0 }} + onChangeText={(text) => { + onChange(text); + setMenuVisible(true); + setValue(text); + }} + containerStyle={{ + borderColor: 'black', + borderWidth: 1, + borderRadius: 4, + height: 48, + backgroundColor: 'white' + }} + /> + + {menuVisible && ( + + {data.map((val, index) => ( + { + setMenuVisible(false); + onPress(val.id); + }} + style={{ paddingBottom: 8 }}> + + {val.listName && ( + {val.listName} + )} + {val.todoName && ( + + {'\u2022'} {val.todoName} + + )} + + + + ))} + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'column', + flex: 1, + flexGrow: 1, + marginHorizontal: 8 + }, + inputContainer: { + flexDirection: 'row', + flex: 0, + marginVertical: 8 + }, + menuContainer: { + flex: 2, + flexGrow: 1, + flexDirection: 'column' + } +}); diff --git a/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx b/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx index f8af67d3..47e9ea9d 100644 --- a/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx +++ b/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { Alert, Text } from 'react-native'; -import { useNavigation } from 'expo-router'; +import { Alert, View, StyleSheet } from 'react-native'; +import { router, useNavigation } from 'expo-router'; import { Icon, Header } from '@rneui/themed'; import { useStatus } from '@powersync/react'; import { DrawerActions } from '@react-navigation/native'; import { useSystem } from '../powersync/system'; +import { usePathname } from 'expo-router'; export const HeaderWidget: React.FC<{ title?: string; @@ -15,6 +16,8 @@ export const HeaderWidget: React.FC<{ const status = useStatus(); const { title } = props; + + const pathName = usePathname(); return (
} rightComponent={ - { - if (system.attachmentQueue) { - system.attachmentQueue.trigger(); - } - Alert.alert( - 'Status', - `${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${ - status?.lastSyncedAt?.toISOString() ?? '-' - }\nVersion: ${powersync.sdkVersion}` - ); - }} - /> + + {pathName.includes('lists') && ( + { + router.push('search_modal'); + }} + /> + )} + { + if (system.attachmentQueue) { + system.attachmentQueue.trigger(); + } + Alert.alert( + 'Status', + `${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${ + status?.lastSyncedAt?.toISOString() ?? '-' + }\nVersion: ${powersync.sdkVersion}` + ); + }} + /> + } centerContainerStyle={{ justifyContent: 'center', alignItems: 'center' }} centerComponent={{ text: title, style: { color: '#fff' } }} /> ); }; + +const styles = StyleSheet.create({ + headerRight: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center' + } +}); diff --git a/demos/react-native-supabase-todolist/library/widgets/SearchBarWidget.tsx b/demos/react-native-supabase-todolist/library/widgets/SearchBarWidget.tsx new file mode 100644 index 00000000..ce5e35fc --- /dev/null +++ b/demos/react-native-supabase-todolist/library/widgets/SearchBarWidget.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { usePowerSync } from '@powersync/react'; +import { Autocomplete } from './AutoCompleteWidget'; +import { SearchResult, searchTable } from '../fts/fts_helpers'; +import { LIST_TABLE, ListRecord } from '../powersync/AppSchema'; +import { router } from 'expo-router'; + +// This is a simple search bar widget that allows users to search for lists and todo items +export const SearchBarWidget: React.FC = () => { + const [searchResults, setSearchResults] = React.useState([]); + + const powersync = usePowerSync(); + + const handleInputChange = async (value: string) => { + if (value.length !== 0) { + let listsSearchResults: any[] = []; + const todoItemsSearchResults = await searchTable(value, 'todos'); + for (let i = 0; i < todoItemsSearchResults.length; i++) { + const res = await powersync.get(`SELECT * FROM ${LIST_TABLE} WHERE id = ?`, [ + todoItemsSearchResults[i]['list_id'] + ]); + todoItemsSearchResults[i]['list_name'] = res.name; + } + if (!todoItemsSearchResults.length) { + listsSearchResults = await searchTable(value, 'lists'); + } + const formattedListResults: SearchResult[] = listsSearchResults.map((result): SearchResult => { + return { id: result['id'], listName: result['name'], todoName: null }; + }); + const formattedTodoItemsResults: SearchResult[] = todoItemsSearchResults.map((result): SearchResult => { + return { id: result['list_id'], listName: result['list_name'] ?? '', todoName: result['description'] }; + }); + setSearchResults([...formattedTodoItemsResults, ...formattedListResults]); + } + }; + + return ( + { + router.back(); + router.push({ + pathname: 'views/todos/edit/[id]', + params: { id: id } + }); + }} + /> + ); +}; diff --git a/demos/react-native-supabase-todolist/package.json b/demos/react-native-supabase-todolist/package.json index 1aa52ff6..9cbd43e6 100644 --- a/demos/react-native-supabase-todolist/package.json +++ b/demos/react-native-supabase-todolist/package.json @@ -10,7 +10,7 @@ "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/vector-icons": "^14.0.3", - "@journeyapps/react-native-quick-sqlite": "^2.2.0", + "@journeyapps/react-native-quick-sqlite": "^2.2.1", "@powersync/attachments": "workspace:*", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 329e1d59..65c03465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -768,8 +768,8 @@ importers: specifier: ^14.0.3 version: 14.0.4 '@journeyapps/react-native-quick-sqlite': - specifier: ^2.2.0 - version: 2.2.0(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + specifier: ^2.2.1 + version: 2.2.1(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@powersync/attachments': specifier: workspace:* version: link:../../packages/attachments @@ -4601,6 +4601,12 @@ packages: react: '*' react-native: '*' + '@journeyapps/react-native-quick-sqlite@2.2.1': + resolution: {integrity: sha512-imvd5P9ii5SmtPJed+R4Yn26QDV3ED/jx/3OuKMDcdjCFi/m5yCCrLz0ewgRwubHGkltNYy5d3tCYodmy4+1KA==} + peerDependencies: + react: '*' + react-native: '*' + '@journeyapps/wa-sqlite@0.4.2': resolution: {integrity: sha512-xdpDLbyC/DHkNcnXCfgBXUgfy+ff1w/sxVY6mjdGP8F4bgxnSQfUyN8+PNE2nTgYUx4y5ar57MEnSty4zjIm7Q==} @@ -19107,9 +19113,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/eslint-parser@7.25.8(@babel/core@7.24.5)(eslint@8.57.1)': + '@babel/eslint-parser@7.25.8(@babel/core@7.25.7)(eslint@8.57.1)': dependencies: - '@babel/core': 7.24.5 + '@babel/core': 7.25.7 '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 eslint: 8.57.1 eslint-visitor-keys: 2.1.0 @@ -24549,6 +24555,11 @@ snapshots: react: 18.2.0 react-native: 0.74.5(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0) + '@journeyapps/react-native-quick-sqlite@2.2.1(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': + dependencies: + react: 18.2.0 + react-native: 0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0) + '@journeyapps/wa-sqlite@0.4.2': {} '@journeyapps/wa-sqlite@1.0.0': {} @@ -26904,7 +26915,7 @@ snapshots: '@react-native/eslint-config@0.73.2(eslint@8.57.1)(prettier@3.3.3)(typescript@5.5.4)': dependencies: '@babel/core': 7.24.5 - '@babel/eslint-parser': 7.25.8(@babel/core@7.24.5)(eslint@8.57.1) + '@babel/eslint-parser': 7.25.8(@babel/core@7.25.7)(eslint@8.57.1) '@react-native/eslint-plugin': 0.73.1 '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.5.4) @@ -33369,7 +33380,7 @@ snapshots: eslint-plugin-ft-flow@2.0.3(@babel/eslint-parser@7.25.8(@babel/core@7.24.5)(eslint@8.57.1))(eslint@8.57.1): dependencies: - '@babel/eslint-parser': 7.25.8(@babel/core@7.24.5)(eslint@8.57.1) + '@babel/eslint-parser': 7.25.8(@babel/core@7.25.7)(eslint@8.57.1) eslint: 8.57.1 lodash: 4.17.21 string-natural-compare: 3.0.1