Skip to content

Commit e1be20e

Browse files
authored
Feat: FTS for react native supabase demo (#447)
1 parent 195fffd commit e1be20e

File tree

12 files changed

+394
-33
lines changed

12 files changed

+394
-33
lines changed

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { Stack } from 'expo-router';
1+
import { router, Stack } from 'expo-router';
22
import React, { useMemo } from 'react';
33
import { useSystem } from '../library/powersync/system';
44
import { PowerSyncContext } from '@powersync/react-native';
5+
import { Pressable } from 'react-native';
6+
import { MaterialIcons } from '@expo/vector-icons';
57

68
/**
79
* This App uses a nested navigation stack.
@@ -30,6 +32,21 @@ const HomeLayout = () => {
3032

3133
<Stack.Screen name="index" options={{ headerShown: false }} />
3234
<Stack.Screen name="views" options={{ headerShown: false }} />
35+
<Stack.Screen
36+
name="search_modal"
37+
options={{
38+
headerTitle: 'Search',
39+
headerRight: () => (
40+
<Pressable
41+
onPress={() => {
42+
router.back();
43+
}}>
44+
<MaterialIcons name="close" color="#fff" size={24} />
45+
</Pressable>
46+
),
47+
presentation: 'fullScreenModal'
48+
}}
49+
/>
3350
</Stack>
3451
</PowerSyncContext.Provider>
3552
);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { StatusBar } from 'expo-status-bar';
2+
import { StyleSheet, View } from 'react-native';
3+
import { SearchBarWidget } from '../library/widgets/SearchBarWidget';
4+
5+
export default function Modal() {
6+
return (
7+
<View style={styles.container}>
8+
<SearchBarWidget />
9+
<StatusBar style={'light'} />
10+
</View>
11+
);
12+
}
13+
14+
const styles = StyleSheet.create({
15+
container: {
16+
flex: 1,
17+
flexGrow: 1,
18+
alignItems: 'center',
19+
justifyContent: 'center'
20+
}
21+
});

demos/react-native-supabase-todolist/ios/Podfile.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,7 +1005,7 @@ PODS:
10051005
- React-debug
10061006
- react-native-encrypted-storage (4.0.3):
10071007
- React-Core
1008-
- react-native-quick-sqlite (2.2.0):
1008+
- react-native-quick-sqlite (2.2.1):
10091009
- DoubleConversion
10101010
- glog
10111011
- hermes-engine
@@ -1467,7 +1467,7 @@ DEPENDENCIES:
14671467
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
14681468
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
14691469
- react-native-encrypted-storage (from `../../../node_modules/react-native-encrypted-storage`)
1470-
- "react-native-quick-sqlite (from `../../../node_modules/@journeyapps/react-native-quick-sqlite`)"
1470+
- "react-native-quick-sqlite (from `../node_modules/@journeyapps/react-native-quick-sqlite`)"
14711471
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
14721472
- React-nativeconfig (from `../node_modules/react-native/ReactCommon`)
14731473
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@@ -1593,7 +1593,7 @@ EXTERNAL SOURCES:
15931593
react-native-encrypted-storage:
15941594
:path: "../../../node_modules/react-native-encrypted-storage"
15951595
react-native-quick-sqlite:
1596-
:path: "../../../node_modules/@journeyapps/react-native-quick-sqlite"
1596+
:path: "../node_modules/@journeyapps/react-native-quick-sqlite"
15971597
react-native-safe-area-context:
15981598
:path: "../../../node_modules/react-native-safe-area-context"
15991599
React-nativeconfig:
@@ -1698,7 +1698,7 @@ SPEC CHECKSUMS:
16981698
React-logger: 257858bd55f3a4e1bc0cf07ddc8fb9faba6f8c7c
16991699
React-Mapbuffer: 6c1cacdbf40b531f549eba249e531a7d0bfd8e7f
17001700
react-native-encrypted-storage: db300a3f2f0aba1e818417c1c0a6be549038deb7
1701-
react-native-quick-sqlite: b4b34028dbe2d532beb2575f4b90ae58bec42260
1701+
react-native-quick-sqlite: fa617eb5224e530a0cafe21f35dbff9d98b1a557
17021702
react-native-safe-area-context: afa5d614d6b1b73b743c9261985876606560d128
17031703
React-nativeconfig: ba9a2e54e2f0882cf7882698825052793ed4c851
17041704
React-NativeModulesApple: 8d11ff8955181540585c944cf48e9e7236952697
@@ -1733,4 +1733,4 @@ SPEC CHECKSUMS:
17331733

17341734
PODFILE CHECKSUM: ad989b4e43152979093488e5c8b7457e401bf191
17351735

1736-
COCOAPODS: 1.15.2
1736+
COCOAPODS: 1.16.2
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 interface SearchResult {
30+
id: string;
31+
listName: string;
32+
todoName: string | null;
33+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { AppSchema } from '../powersync/AppSchema';
2+
import { ExtractType, generateJsonExtracts } from './helpers';
3+
import { PowerSyncDatabase } from '@powersync/react-native';
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(
15+
db: PowerSyncDatabase,
16+
tableName: string,
17+
columns: string[],
18+
tokenizationMethod = 'unicode61'
19+
): Promise<void> {
20+
const internalName = AppSchema.tables.find((table) => table.name === tableName)?.internalName;
21+
const stringColumns = columns.join(', ');
22+
23+
return await db.writeTransaction(async (tx) => {
24+
// Add FTS table
25+
await tx.execute(`
26+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_${tableName}
27+
USING fts5(id UNINDEXED, ${stringColumns}, tokenize='${tokenizationMethod}');
28+
`);
29+
// Copy over records already in table
30+
await tx.execute(`
31+
INSERT OR REPLACE INTO fts_${tableName}(rowid, id, ${stringColumns})
32+
SELECT rowid, id, ${generateJsonExtracts(ExtractType.columnOnly, 'data', columns)} FROM ${internalName};
33+
`);
34+
// Add INSERT, UPDATE and DELETE and triggers to keep fts table in sync with table
35+
await tx.execute(`
36+
CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_${tableName} AFTER INSERT ON ${internalName}
37+
BEGIN
38+
INSERT INTO fts_${tableName}(rowid, id, ${stringColumns})
39+
VALUES (
40+
NEW.rowid,
41+
NEW.id,
42+
${generateJsonExtracts(ExtractType.columnOnly, 'NEW.data', columns)}
43+
);
44+
END;
45+
`);
46+
await tx.execute(`
47+
CREATE TRIGGER IF NOT EXISTS fts_update_trigger_${tableName} AFTER UPDATE ON ${internalName} BEGIN
48+
UPDATE fts_${tableName}
49+
SET ${generateJsonExtracts(ExtractType.columnInOperation, 'NEW.data', columns)}
50+
WHERE rowid = NEW.rowid;
51+
END;
52+
`);
53+
await tx.execute(`
54+
CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_${tableName} AFTER DELETE ON ${internalName} BEGIN
55+
DELETE FROM fts_${tableName} WHERE rowid = OLD.rowid;
56+
END;
57+
`);
58+
});
59+
}
60+
61+
/**
62+
* This is where you can add more methods to generate FTS tables in this demo
63+
* that correspond to the tables in your schema and populate them
64+
* with the data you would like to search on
65+
*/
66+
export async function configureFts(db: PowerSyncDatabase): Promise<void> {
67+
await createFtsTable(db, 'lists', ['name'], 'porter unicode61');
68+
await createFtsTable(db, 'todos', ['description', 'list_id']);
69+
}
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+
await configureFts(this.powersync);
7176
}
7277
}
7378

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { View, StyleSheet } from 'react-native';
2+
import { Input, ListItem } from '@rneui/themed';
3+
import React, { useState } from 'react';
4+
import { IconNode } from '@rneui/base';
5+
6+
export interface AutocompleteWidgetProps {
7+
data: any[];
8+
onChange: (value: string) => void;
9+
placeholder?: string;
10+
onPress: (id: string) => void;
11+
leftIcon?: IconNode;
12+
}
13+
14+
export const Autocomplete: React.FC<AutocompleteWidgetProps> = ({ data, onChange, placeholder, onPress, leftIcon }) => {
15+
const [value, setValue] = useState('');
16+
const [menuVisible, setMenuVisible] = useState(false);
17+
18+
return (
19+
<View style={styles.container}>
20+
<View style={styles.inputContainer}>
21+
<Input
22+
onFocus={() => {
23+
if (value?.length === 0) {
24+
setMenuVisible(true);
25+
}
26+
}}
27+
leftIcon={leftIcon}
28+
placeholder={placeholder}
29+
onBlur={() => setMenuVisible(false)}
30+
underlineColorAndroid={'transparent'}
31+
inputContainerStyle={{ borderBottomWidth: 0 }}
32+
onChangeText={(text) => {
33+
onChange(text);
34+
setMenuVisible(true);
35+
setValue(text);
36+
}}
37+
containerStyle={{
38+
borderColor: 'black',
39+
borderWidth: 1,
40+
borderRadius: 4,
41+
height: 48,
42+
backgroundColor: 'white'
43+
}}
44+
/>
45+
</View>
46+
{menuVisible && (
47+
<View style={styles.menuContainer}>
48+
{data.map((val, index) => (
49+
<ListItem
50+
bottomDivider
51+
key={index}
52+
onPress={() => {
53+
setMenuVisible(false);
54+
onPress(val.id);
55+
}}
56+
style={{ paddingBottom: 8 }}>
57+
<ListItem.Content>
58+
{val.listName && (
59+
<ListItem.Title style={{ fontSize: 18, color: 'black' }}>{val.listName}</ListItem.Title>
60+
)}
61+
{val.todoName && (
62+
<ListItem.Subtitle style={{ fontSize: 14, color: 'grey' }}>
63+
{'\u2022'} {val.todoName}
64+
</ListItem.Subtitle>
65+
)}
66+
</ListItem.Content>
67+
<ListItem.Chevron />
68+
</ListItem>
69+
))}
70+
</View>
71+
)}
72+
</View>
73+
);
74+
};
75+
76+
const styles = StyleSheet.create({
77+
container: {
78+
flexDirection: 'column',
79+
flex: 1,
80+
flexGrow: 1,
81+
marginHorizontal: 8
82+
},
83+
inputContainer: {
84+
flexDirection: 'row',
85+
flex: 0,
86+
marginVertical: 8
87+
},
88+
menuContainer: {
89+
flex: 2,
90+
flexGrow: 1,
91+
flexDirection: 'column'
92+
}
93+
});

0 commit comments

Comments
 (0)