Skip to content

Commit dda87d9

Browse files
committed
Setup fts5 and search screen
1 parent 195fffd commit dda87d9

File tree

9 files changed

+373
-20
lines changed

9 files changed

+373
-20
lines changed

demos/react-native-supabase-todolist/app/_layout.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ const HomeLayout = () => {
3030

3131
<Stack.Screen name="index" options={{ headerShown: false }} />
3232
<Stack.Screen name="views" options={{ headerShown: false }} />
33+
<Stack.Screen
34+
name="search_modal"
35+
options={{
36+
presentation: 'fullScreenModal'
37+
}}
38+
/>
3339
</Stack>
3440
</PowerSyncContext.Provider>
3541
);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Stack } from 'expo-router';
2+
import { StatusBar } from 'expo-status-bar';
3+
import { StyleSheet, Text, View } from 'react-native';
4+
import { SearchBarWidget } from '../library/widgets/SearchBarWidget';
5+
6+
export default function Modal() {
7+
return (
8+
<View style={styles.container}>
9+
<SearchBarWidget />
10+
11+
<StatusBar style={'light'} />
12+
</View>
13+
);
14+
}
15+
16+
const styles = StyleSheet.create({
17+
container: {
18+
flex: 1,
19+
flexGrow: 1,
20+
alignItems: 'center',
21+
justifyContent: 'center'
22+
}
23+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { system } from '../powersync/system';
2+
3+
/**
4+
* adding * to the end of the search term will match any word that starts with the search term
5+
* e.g. searching bl will match blue, black, etc.
6+
* consult FTS5 Full-text Query Syntax documentation for more options
7+
* @param searchTerm
8+
* @returns a modified search term with options.
9+
*/
10+
function createSearchTermWithOptions(searchTerm: string): string {
11+
const searchTermWithOptions: string = `${searchTerm}*`;
12+
return searchTermWithOptions;
13+
}
14+
15+
/**
16+
* Search the FTS table for the given searchTerm
17+
* @param searchTerm
18+
* @param tableName
19+
* @returns results from the FTS table
20+
*/
21+
export async function searchTable(searchTerm: string, tableName: string): Promise<any[]> {
22+
const searchTermWithOptions = createSearchTermWithOptions(searchTerm);
23+
return await system.powersync.getAll(`SELECT * FROM fts_${tableName} WHERE fts_${tableName} MATCH ? ORDER BY rank`, [
24+
searchTermWithOptions
25+
]);
26+
}
27+
28+
//Used to display the search results in the autocomplete text field
29+
export class SearchResult {
30+
id: string;
31+
todoName: string | null;
32+
listName: string;
33+
34+
constructor(id: string, listName: string, todoName: string | null = null) {
35+
this.id = id;
36+
this.listName = listName;
37+
this.todoName = todoName;
38+
}
39+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { AppSchema } from '../powersync/AppSchema';
2+
import { ExtractType, generateJsonExtracts } from './helpers';
3+
import { system } from '../powersync/system';
4+
5+
/**
6+
* Create a Full Text Search table for the given table and columns
7+
* with an option to use a different tokenizer otherwise it defaults
8+
* to unicode61. It also creates the triggers that keep the FTS table
9+
* and the PowerSync table in sync.
10+
* @param tableName
11+
* @param columns
12+
* @param tokenizationMethod
13+
*/
14+
async function createFtsTable(tableName: string, columns: string[], tokenizationMethod = 'unicode61'): Promise<void> {
15+
const internalName = AppSchema.tables.find((table) => table.name === tableName)?.internalName;
16+
const stringColumns = columns.join(', ');
17+
18+
return await system.powersync.writeTransaction(async (tx) => {
19+
// Add FTS table
20+
await tx.execute(`
21+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_${tableName}
22+
USING fts5(id UNINDEXED, ${stringColumns}, tokenize='${tokenizationMethod}');
23+
`);
24+
// Copy over records already in table
25+
await tx.execute(`
26+
INSERT OR REPLACE INTO fts_${tableName}(rowid, id, ${stringColumns})
27+
SELECT rowid, id, ${generateJsonExtracts(ExtractType.columnOnly, 'data', columns)} FROM ${internalName};
28+
`);
29+
// Add INSERT, UPDATE and DELETE and triggers to keep fts table in sync with table
30+
await tx.execute(`
31+
CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_${tableName} AFTER INSERT ON ${internalName}
32+
BEGIN
33+
INSERT INTO fts_${tableName}(rowid, id, ${stringColumns})
34+
VALUES (
35+
NEW.rowid,
36+
NEW.id,
37+
${generateJsonExtracts(ExtractType.columnOnly, 'NEW.data', columns)}
38+
);
39+
END;
40+
`);
41+
await tx.execute(`
42+
CREATE TRIGGER IF NOT EXISTS fts_update_trigger_${tableName} AFTER UPDATE ON ${internalName} BEGIN
43+
UPDATE fts_${tableName}
44+
SET ${generateJsonExtracts(ExtractType.columnInOperation, 'NEW.data', columns)}
45+
WHERE rowid = NEW.rowid;
46+
END;
47+
`);
48+
await tx.execute(`
49+
CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_${tableName} AFTER DELETE ON ${internalName} BEGIN
50+
DELETE FROM fts_${tableName} WHERE rowid = OLD.rowid;
51+
END;
52+
`);
53+
});
54+
}
55+
56+
/**
57+
* This is where you can add more methods to generate FTS tables in this demo
58+
* that correspond to the tables in your schema and populate them
59+
* with the data you would like to search on
60+
*/
61+
export async function configureFts(): Promise<void> {
62+
await createFtsTable('lists', ['name'], 'porter unicode61');
63+
await createFtsTable('todos', ['description', 'list_id']);
64+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
type ExtractGenerator = (jsonColumnName: string, columnName: string) => string;
2+
3+
export enum ExtractType {
4+
columnOnly,
5+
columnInOperation
6+
}
7+
8+
type ExtractGeneratorMap = Map<ExtractType, ExtractGenerator>;
9+
10+
function _createExtract(jsonColumnName: string, columnName: string): string {
11+
return `json_extract(${jsonColumnName}, '$.${columnName}')`;
12+
}
13+
14+
const extractGeneratorsMap: ExtractGeneratorMap = new Map<ExtractType, ExtractGenerator>([
15+
[ExtractType.columnOnly, (jsonColumnName: string, columnName: string) => _createExtract(jsonColumnName, columnName)],
16+
[
17+
ExtractType.columnInOperation,
18+
(jsonColumnName: string, columnName: string) => {
19+
const extract = _createExtract(jsonColumnName, columnName);
20+
return `${columnName} = ${extract}`;
21+
}
22+
]
23+
]);
24+
25+
export const generateJsonExtracts = (type: ExtractType, jsonColumnName: string, columns: string[]): string => {
26+
const generator = extractGeneratorsMap.get(type);
27+
if (generator == null) {
28+
throw new Error('Unexpected null generator for key: $type');
29+
}
30+
31+
if (columns.length == 1) {
32+
return generator(jsonColumnName, columns[0]);
33+
}
34+
35+
return columns.map((column) => generator(jsonColumnName, column)).join(', ');
36+
};

demos/react-native-supabase-todolist/library/powersync/system.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AppConfig } from '../supabase/AppConfig';
1111
import { SupabaseConnector } from '../supabase/SupabaseConnector';
1212
import { AppSchema } from './AppSchema';
1313
import { PhotoAttachmentQueue } from './PhotoAttachmentQueue';
14+
import { configureFts } from '../fts/fts_setup';
1415

1516
Logger.useDefaults();
1617

@@ -68,6 +69,10 @@ export class System {
6869
if (this.attachmentQueue) {
6970
await this.attachmentQueue.init();
7071
}
72+
73+
// Demo using SQLite Full-Text Search with PowerSync.
74+
// See https://docs.powersync.com/usage-examples/full-text-search for more details
75+
configureFts();
7176
}
7277
}
7378

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { View, StyleSheet, TouchableOpacity } from 'react-native';
2+
import { Card, Input, ListItem, Text } from '@rneui/themed';
3+
import React, { useState } from 'react';
4+
import { router } from 'expo-router';
5+
6+
export interface AutocompleteWidgetProps {
7+
origValue: string;
8+
label?: string;
9+
data: any[];
10+
onChange: (value: string) => void;
11+
// origOnChange: (value: string) => void;
12+
icon?: string;
13+
style?: object;
14+
menuStyle?: object;
15+
right?: object;
16+
left?: object;
17+
}
18+
19+
export const Autocomplete: React.FC<AutocompleteWidgetProps> = ({
20+
origValue,
21+
label,
22+
data,
23+
onChange,
24+
// origOnChange,
25+
icon,
26+
style,
27+
menuStyle,
28+
right,
29+
left
30+
}) => {
31+
const [value, setValue] = useState(origValue);
32+
const [menuVisible, setMenuVisible] = useState(false);
33+
const [filteredData, setFilteredData] = useState<any[]>([]);
34+
35+
const filterData = (text: string) => {
36+
return data.filter((val: any) => val?.toLowerCase()?.indexOf(text?.toLowerCase()) > -1);
37+
};
38+
return (
39+
<View style={{ flexDirection: 'column', flex: 1, flexGrow: 1 }}>
40+
<View style={{ flexDirection: 'row', flex: 0 }}>
41+
<Input
42+
onFocus={() => {
43+
if (value.length === 0) {
44+
setMenuVisible(true);
45+
}
46+
}}
47+
onBlur={() => setMenuVisible(false)}
48+
label={label}
49+
// right={right}
50+
// left={left}
51+
// style={styles.input}
52+
onChangeText={(text) => {
53+
// origOnChange(text);
54+
onChange(text);
55+
// if (text && text.length > 0) {
56+
// setFilteredData(filterData(text));
57+
// } else if (text && text.length === 0) {
58+
// setFilteredData(data);
59+
// }
60+
setMenuVisible(true);
61+
setValue(text);
62+
}}
63+
// value={value}
64+
/>
65+
</View>
66+
{menuVisible && (
67+
<View
68+
style={{
69+
flex: 2,
70+
flexGrow: 1,
71+
flexDirection: 'column'
72+
}}>
73+
{data.map((val, index) => (
74+
<TouchableOpacity
75+
key={index}
76+
onPress={() => {
77+
router.push({
78+
pathname: 'views/todos/edit/[id]',
79+
params: { id: val.id }
80+
});
81+
}}>
82+
<Card style={{ display: 'flex', width: '100%' }}>
83+
{val.listName && <Text style={{ fontSize: 18 }}>{val.listName}</Text>}
84+
{val.todoName && (
85+
<Text style={{ fontSize: 14 }}>
86+
{'\u2022'} {val.todoName}
87+
</Text>
88+
)}
89+
</Card>
90+
</TouchableOpacity>
91+
// <ListItem
92+
// // key={i}
93+
// style={[{ width: '100%' }]}
94+
// // icon={icon}
95+
// onPress={() => {
96+
// setValue(val);
97+
// setMenuVisible(false);
98+
// }}
99+
// // title={datum}
100+
// >
101+
// <ListItem.Content>
102+
// <ListItem.Title>{val}</ListItem.Title>
103+
// </ListItem.Content>
104+
// </ListItem>
105+
))}
106+
</View>
107+
)}
108+
</View>
109+
);
110+
};
111+
112+
const styles = StyleSheet.create({
113+
input: {
114+
flexDirection: 'row',
115+
flex: 1,
116+
flexGrow: 1,
117+
width: '100%',
118+
alignItems: 'center',
119+
justifyContent: 'flex-start'
120+
}
121+
});

demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
2-
import { Alert, Text } from 'react-native';
3-
import { useNavigation } from 'expo-router';
2+
import { Alert, View, StyleSheet } from 'react-native';
3+
import { router, useNavigation } from 'expo-router';
44
import { Icon, Header } from '@rneui/themed';
55
import { useStatus } from '@powersync/react';
66
import { DrawerActions } from '@react-navigation/native';
@@ -29,27 +29,46 @@ export const HeaderWidget: React.FC<{
2929
/>
3030
}
3131
rightComponent={
32-
<Icon
33-
name={status.connected ? 'wifi' : 'wifi-off'}
34-
type="material-community"
35-
color="white"
36-
size={20}
37-
style={{ padding: 5 }}
38-
onPress={() => {
39-
if (system.attachmentQueue) {
40-
system.attachmentQueue.trigger();
41-
}
42-
Alert.alert(
43-
'Status',
44-
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
45-
status?.lastSyncedAt?.toISOString() ?? '-'
46-
}\nVersion: ${powersync.sdkVersion}`
47-
);
48-
}}
49-
/>
32+
<View style={styles.headerRight}>
33+
<Icon
34+
name="search"
35+
type="material"
36+
color="white"
37+
size={24}
38+
onPress={() => {
39+
router.push('search_modal');
40+
}}
41+
/>
42+
<Icon
43+
name={status.connected ? 'wifi' : 'wifi-off'}
44+
type="material-community"
45+
color="white"
46+
size={24}
47+
style={{ padding: 5 }}
48+
onPress={() => {
49+
if (system.attachmentQueue) {
50+
system.attachmentQueue.trigger();
51+
}
52+
Alert.alert(
53+
'Status',
54+
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
55+
status?.lastSyncedAt?.toISOString() ?? '-'
56+
}\nVersion: ${powersync.sdkVersion}`
57+
);
58+
}}
59+
/>
60+
</View>
5061
}
5162
centerContainerStyle={{ justifyContent: 'center', alignItems: 'center' }}
5263
centerComponent={{ text: title, style: { color: '#fff' } }}
5364
/>
5465
);
5566
};
67+
68+
const styles = StyleSheet.create({
69+
headerRight: {
70+
display: 'flex',
71+
flexDirection: 'row',
72+
alignItems: 'center'
73+
}
74+
});

0 commit comments

Comments
 (0)