Skip to content

Commit

Permalink
Setup fts5 and search screen
Browse files Browse the repository at this point in the history
  • Loading branch information
mugikhan committed Dec 17, 2024
1 parent 195fffd commit dda87d9
Show file tree
Hide file tree
Showing 9 changed files with 373 additions and 20 deletions.
6 changes: 6 additions & 0 deletions demos/react-native-supabase-todolist/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const HomeLayout = () => {

<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="views" options={{ headerShown: false }} />
<Stack.Screen
name="search_modal"
options={{
presentation: 'fullScreenModal'
}}
/>
</Stack>
</PowerSyncContext.Provider>
);
Expand Down
23 changes: 23 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,23 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, 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'
}
});
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 {
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;
}
}
64 changes: 64 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,64 @@
import { AppSchema } from '../powersync/AppSchema';
import { ExtractType, generateJsonExtracts } from './helpers';
import { system } from '../powersync/system';

/**
* 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(tableName: string, columns: string[], tokenizationMethod = 'unicode61'): Promise<void> {
const internalName = AppSchema.tables.find((table) => table.name === tableName)?.internalName;
const stringColumns = columns.join(', ');

return await system.powersync.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(): Promise<void> {
await createFtsTable('lists', ['name'], 'porter unicode61');
await createFtsTable('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();
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { View, StyleSheet, TouchableOpacity } from 'react-native';
import { Card, Input, ListItem, Text } from '@rneui/themed';
import React, { useState } from 'react';
import { router } from 'expo-router';

export interface AutocompleteWidgetProps {
origValue: string;
label?: string;
data: any[];
onChange: (value: string) => void;
// origOnChange: (value: string) => void;
icon?: string;
style?: object;
menuStyle?: object;
right?: object;
left?: object;
}

export const Autocomplete: React.FC<AutocompleteWidgetProps> = ({
origValue,
label,
data,
onChange,
// origOnChange,
icon,
style,
menuStyle,
right,
left
}) => {
const [value, setValue] = useState(origValue);
const [menuVisible, setMenuVisible] = useState(false);
const [filteredData, setFilteredData] = useState<any[]>([]);

const filterData = (text: string) => {
return data.filter((val: any) => val?.toLowerCase()?.indexOf(text?.toLowerCase()) > -1);
};
return (
<View style={{ flexDirection: 'column', flex: 1, flexGrow: 1 }}>
<View style={{ flexDirection: 'row', flex: 0 }}>
<Input
onFocus={() => {
if (value.length === 0) {
setMenuVisible(true);
}
}}
onBlur={() => setMenuVisible(false)}
label={label}
// right={right}
// left={left}
// style={styles.input}
onChangeText={(text) => {
// origOnChange(text);
onChange(text);
// if (text && text.length > 0) {
// setFilteredData(filterData(text));
// } else if (text && text.length === 0) {
// setFilteredData(data);
// }
setMenuVisible(true);
setValue(text);
}}
// value={value}
/>
</View>
{menuVisible && (
<View
style={{
flex: 2,
flexGrow: 1,
flexDirection: 'column'
}}>
{data.map((val, index) => (
<TouchableOpacity
key={index}
onPress={() => {
router.push({
pathname: 'views/todos/edit/[id]',
params: { id: val.id }
});
}}>
<Card style={{ display: 'flex', width: '100%' }}>
{val.listName && <Text style={{ fontSize: 18 }}>{val.listName}</Text>}
{val.todoName && (
<Text style={{ fontSize: 14 }}>
{'\u2022'} {val.todoName}
</Text>
)}
</Card>
</TouchableOpacity>
// <ListItem
// // key={i}
// style={[{ width: '100%' }]}
// // icon={icon}
// onPress={() => {
// setValue(val);
// setMenuVisible(false);
// }}
// // title={datum}
// >
// <ListItem.Content>
// <ListItem.Title>{val}</ListItem.Title>
// </ListItem.Content>
// </ListItem>
))}
</View>
)}
</View>
);
};

const styles = StyleSheet.create({
input: {
flexDirection: 'row',
flex: 1,
flexGrow: 1,
width: '100%',
alignItems: 'center',
justifyContent: 'flex-start'
}
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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';
Expand Down Expand Up @@ -29,27 +29,46 @@ export const HeaderWidget: React.FC<{
/>
}
rightComponent={
<Icon
name={status.connected ? 'wifi' : 'wifi-off'}
type="material-community"
color="white"
size={20}
style={{ padding: 5 }}
onPress={() => {
if (system.attachmentQueue) {
system.attachmentQueue.trigger();
}
Alert.alert(
'Status',
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
status?.lastSyncedAt?.toISOString() ?? '-'
}\nVersion: ${powersync.sdkVersion}`
);
}}
/>
<View style={styles.headerRight}>
<Icon
name="search"
type="material"
color="white"
size={24}
onPress={() => {
router.push('search_modal');
}}
/>
<Icon
name={status.connected ? 'wifi' : 'wifi-off'}
type="material-community"
color="white"
size={24}
style={{ padding: 5 }}
onPress={() => {
if (system.attachmentQueue) {
system.attachmentQueue.trigger();
}
Alert.alert(
'Status',
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
status?.lastSyncedAt?.toISOString() ?? '-'
}\nVersion: ${powersync.sdkVersion}`
);
}}
/>
</View>
}
centerContainerStyle={{ justifyContent: 'center', alignItems: 'center' }}
centerComponent={{ text: title, style: { color: '#fff' } }}
/>
);
};

const styles = StyleSheet.create({
headerRight: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
}
});
Loading

0 comments on commit dda87d9

Please sign in to comment.