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

Feat: FTS for react native supabase demo #447

Merged
merged 4 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
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
39 changes: 39 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,39 @@
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 class SearchResult {
mugikhan marked this conversation as resolved.
Show resolved Hide resolved
id: string;
todoName: string | null;
listName: string;

constructor(id: string, listName: string, todoName: string | null = null) {
this.id = id;
this.listName = listName;
this.todoName = todoName;
}
}
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
configureFts(this.powersync);
mugikhan marked this conversation as resolved.
Show resolved Hide resolved
}
}

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
Loading