Skip to content

Commit

Permalink
Feat: FTS for react native supabase demo (#447)
Browse files Browse the repository at this point in the history
  • Loading branch information
mugikhan authored Dec 18, 2024
1 parent 195fffd commit e1be20e
Show file tree
Hide file tree
Showing 12 changed files with 394 additions and 33 deletions.
19 changes: 18 additions & 1 deletion demos/react-native-supabase-todolist/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -30,6 +32,21 @@ const HomeLayout = () => {

<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="views" options={{ headerShown: false }} />
<Stack.Screen
name="search_modal"
options={{
headerTitle: 'Search',
headerRight: () => (
<Pressable
onPress={() => {
router.back();
}}>
<MaterialIcons name="close" color="#fff" size={24} />
</Pressable>
),
presentation: 'fullScreenModal'
}}
/>
</Stack>
</PowerSyncContext.Provider>
);
Expand Down
21 changes: 21 additions & 0 deletions demos/react-native-supabase-todolist/app/search_modal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<SearchBarWidget />
<StatusBar style={'light'} />
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center'
}
});
10 changes: 5 additions & 5 deletions demos/react-native-supabase-todolist/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1733,4 +1733,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: ad989b4e43152979093488e5c8b7457e401bf191

COCOAPODS: 1.15.2
COCOAPODS: 1.16.2
33 changes: 33 additions & 0 deletions demos/react-native-supabase-todolist/library/fts/fts_helpers.ts
Original file line number Diff line number Diff line change
@@ -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<any[]> {
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;
}
69 changes: 69 additions & 0 deletions demos/react-native-supabase-todolist/library/fts/fts_setup.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await createFtsTable(db, 'lists', ['name'], 'porter unicode61');
await createFtsTable(db, 'todos', ['description', 'list_id']);
}
36 changes: 36 additions & 0 deletions demos/react-native-supabase-todolist/library/fts/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
type ExtractGenerator = (jsonColumnName: string, columnName: string) => string;

export enum ExtractType {
columnOnly,
columnInOperation
}

type ExtractGeneratorMap = Map<ExtractType, ExtractGenerator>;

function _createExtract(jsonColumnName: string, columnName: string): string {
return `json_extract(${jsonColumnName}, '$.${columnName}')`;
}

const extractGeneratorsMap: ExtractGeneratorMap = new Map<ExtractType, ExtractGenerator>([
[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(', ');
};
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<AutocompleteWidgetProps> = ({ data, onChange, placeholder, onPress, leftIcon }) => {
const [value, setValue] = useState('');
const [menuVisible, setMenuVisible] = useState(false);

return (
<View style={styles.container}>
<View style={styles.inputContainer}>
<Input
onFocus={() => {
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'
}}
/>
</View>
{menuVisible && (
<View style={styles.menuContainer}>
{data.map((val, index) => (
<ListItem
bottomDivider
key={index}
onPress={() => {
setMenuVisible(false);
onPress(val.id);
}}
style={{ paddingBottom: 8 }}>
<ListItem.Content>
{val.listName && (
<ListItem.Title style={{ fontSize: 18, color: 'black' }}>{val.listName}</ListItem.Title>
)}
{val.todoName && (
<ListItem.Subtitle style={{ fontSize: 14, color: 'grey' }}>
{'\u2022'} {val.todoName}
</ListItem.Subtitle>
)}
</ListItem.Content>
<ListItem.Chevron />
</ListItem>
))}
</View>
)}
</View>
);
};

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'
}
});
Loading

0 comments on commit e1be20e

Please sign in to comment.