suparisma 1.1.2 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +439 -14
- package/SEARCH_FEATURES.md +430 -0
- package/dist/generators/coreGenerator.js +152 -42
- package/dist/generators/typeGenerator.js +4 -21
- package/dist/index.js +206 -15
- package/dist/parser.js +47 -37
- package/package.json +1 -1
- package/prisma/schema.prisma +6 -1
|
@@ -23,9 +23,13 @@ import { supabase } from './supabase-client';
|
|
|
23
23
|
* @example
|
|
24
24
|
* // Search for users with names containing "john"
|
|
25
25
|
* const query = { field: "name", value: "john" };
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* // Search across multiple fields
|
|
29
|
+
* const query = { field: "multi", value: "john" };
|
|
26
30
|
*/
|
|
27
31
|
export type SearchQuery = {
|
|
28
|
-
/** The field name to search in */
|
|
32
|
+
/** The field name to search in, or "multi" for multi-field search */
|
|
29
33
|
field: string;
|
|
30
34
|
/** The search term/value to look for */
|
|
31
35
|
value: string;
|
|
@@ -34,6 +38,15 @@ export type SearchQuery = {
|
|
|
34
38
|
// Define type for Supabase query builder
|
|
35
39
|
export type SupabaseQueryBuilder = ReturnType<ReturnType<typeof supabase.from>['select']>;
|
|
36
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Utility function to escape regex special characters for safe RegExp usage
|
|
43
|
+
* Prevents "Invalid regular expression" errors when search terms contain special characters
|
|
44
|
+
*/
|
|
45
|
+
export function escapeRegexCharacters(str: string): string {
|
|
46
|
+
// Escape all special regex characters: ( ) [ ] { } + * ? ^ $ | . \\
|
|
47
|
+
return str.replace(/[()\\[\\]{}+*?^$|.\\\\]/g, '\\\\\\\\$&');
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
/**
|
|
38
51
|
* Advanced filter operators for complex queries
|
|
39
52
|
* @example
|
|
@@ -162,10 +175,22 @@ export type ModelResult<T> = Promise<{
|
|
|
162
175
|
* users.search.addQuery({ field: "name", value: "john" });
|
|
163
176
|
*
|
|
164
177
|
* @example
|
|
178
|
+
* // Search across multiple fields
|
|
179
|
+
* users.search.searchMultiField("john doe");
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
165
182
|
* // Check if search is loading
|
|
166
183
|
* if (users.search.loading) {
|
|
167
184
|
* return <div>Searching...</div>;
|
|
168
185
|
* }
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* // Get current search terms for highlighting
|
|
189
|
+
* const searchTerms = users.search.getCurrentSearchTerms();
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* // Safely escape regex characters
|
|
193
|
+
* const escaped = users.search.escapeRegex("user@example.com");
|
|
169
194
|
*/
|
|
170
195
|
export type SearchState = {
|
|
171
196
|
/** Current active search queries */
|
|
@@ -180,6 +205,14 @@ export type SearchState = {
|
|
|
180
205
|
removeQuery: (field: string) => void;
|
|
181
206
|
/** Clear all search queries and return to normal data fetching */
|
|
182
207
|
clearQueries: () => void;
|
|
208
|
+
/** Search across multiple fields (convenience method) */
|
|
209
|
+
searchMultiField: (value: string) => void;
|
|
210
|
+
/** Search in a specific field (convenience method) */
|
|
211
|
+
searchField: (field: string, value: string) => void;
|
|
212
|
+
/** Get current search terms for custom highlighting */
|
|
213
|
+
getCurrentSearchTerms: () => string[];
|
|
214
|
+
/** Safely escape regex special characters */
|
|
215
|
+
escapeRegex: (text: string) => string;
|
|
183
216
|
};
|
|
184
217
|
|
|
185
218
|
/**
|
|
@@ -897,7 +930,39 @@ export function createSuparismaHook<
|
|
|
897
930
|
setSearchQueries([]);
|
|
898
931
|
isSearchingRef.current = false;
|
|
899
932
|
findMany({ where, orderBy, take: limit, skip: offset });
|
|
900
|
-
}, [where, orderBy, limit, offset])
|
|
933
|
+
}, [where, orderBy, limit, offset]),
|
|
934
|
+
|
|
935
|
+
// Search across multiple fields (convenience method)
|
|
936
|
+
searchMultiField: useCallback((value: string) => {
|
|
937
|
+
if (searchFields.length <= 1) {
|
|
938
|
+
console.warn('Multi-field search requires at least 2 searchable fields');
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
setSearchQueries([{ field: 'multi', value }]);
|
|
943
|
+
executeSearch([{ field: 'multi', value }]);
|
|
944
|
+
}, [searchFields.length]),
|
|
945
|
+
|
|
946
|
+
// Search in a specific field (convenience method)
|
|
947
|
+
searchField: useCallback((field: string, value: string) => {
|
|
948
|
+
if (!searchFields.includes(field)) {
|
|
949
|
+
console.warn(\`Field "\${field}" is not searchable. Available fields: \${searchFields.join(', ')}\`);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
setSearchQueries([{ field, value }]);
|
|
954
|
+
executeSearch([{ field, value }]);
|
|
955
|
+
}, [searchFields]),
|
|
956
|
+
|
|
957
|
+
// Get current search terms for custom highlighting
|
|
958
|
+
getCurrentSearchTerms: useCallback(() => {
|
|
959
|
+
return searchQueries.map(q => q.value.trim());
|
|
960
|
+
}, [searchQueries]),
|
|
961
|
+
|
|
962
|
+
// Safely escape regex special characters
|
|
963
|
+
escapeRegex: useCallback((text: string) => {
|
|
964
|
+
return escapeRegexCharacters(text);
|
|
965
|
+
}, [])
|
|
901
966
|
};
|
|
902
967
|
|
|
903
968
|
// Execute search based on queries
|
|
@@ -920,13 +985,52 @@ export function createSuparismaHook<
|
|
|
920
985
|
try {
|
|
921
986
|
let results: TWithRelations[] = [];
|
|
922
987
|
|
|
988
|
+
// Validate search queries
|
|
989
|
+
const validQueries = queries.filter(query => {
|
|
990
|
+
if (!query.field || !query.value) {
|
|
991
|
+
console.warn('Invalid search query - missing field or value:', query);
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
// Allow "multi" as a special field for multi-field search
|
|
995
|
+
if (query.field === 'multi' && searchFields.length > 1) {
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
if (!searchFields.includes(query.field)) {
|
|
999
|
+
console.warn(\`Field "\${query.field}" is not searchable. Available fields: \${searchFields.join(', ')}, or "multi" for multi-field search\`);
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
return true;
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
if (validQueries.length === 0) {
|
|
1006
|
+
console.log('No valid search queries found');
|
|
1007
|
+
setData([]);
|
|
1008
|
+
setCount(0);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
923
1012
|
// Execute RPC function for each query using Promise.all
|
|
924
|
-
const searchPromises =
|
|
925
|
-
// Build function name
|
|
926
|
-
const functionName =
|
|
1013
|
+
const searchPromises = validQueries.map(query => {
|
|
1014
|
+
// Build function name based on field type
|
|
1015
|
+
const functionName = query.field === 'multi'
|
|
1016
|
+
? \`search_\${tableName.toLowerCase()}_multi_field\`
|
|
1017
|
+
: \`search_\${tableName.toLowerCase()}_by_\${query.field.toLowerCase()}_prefix\`;
|
|
927
1018
|
|
|
928
|
-
|
|
929
|
-
|
|
1019
|
+
console.log(\`🔍 Executing search: \${functionName}(search_prefix: "\${query.value.trim()}")\`);
|
|
1020
|
+
|
|
1021
|
+
// Call RPC function with proper error handling
|
|
1022
|
+
return Promise.resolve(supabase.rpc(functionName, { search_prefix: query.value.trim() }))
|
|
1023
|
+
.then((result: any) => ({
|
|
1024
|
+
...result,
|
|
1025
|
+
queryField: query.field,
|
|
1026
|
+
queryValue: query.value
|
|
1027
|
+
}))
|
|
1028
|
+
.catch((error: any) => ({
|
|
1029
|
+
data: null,
|
|
1030
|
+
error: error,
|
|
1031
|
+
queryField: query.field,
|
|
1032
|
+
queryValue: query.value
|
|
1033
|
+
}));
|
|
930
1034
|
});
|
|
931
1035
|
|
|
932
1036
|
// Execute all search queries in parallel
|
|
@@ -934,82 +1038,88 @@ export function createSuparismaHook<
|
|
|
934
1038
|
|
|
935
1039
|
// Combine and deduplicate results
|
|
936
1040
|
const allResults: Record<string, TWithRelations> = {};
|
|
1041
|
+
let hasErrors = false;
|
|
937
1042
|
|
|
938
1043
|
// Process each search result
|
|
939
|
-
searchResults.forEach((result, index) => {
|
|
1044
|
+
searchResults.forEach((result: any, index: number) => {
|
|
940
1045
|
if (result.error) {
|
|
941
|
-
console.error(
|
|
1046
|
+
console.error(\`🔍 Search error for field "\${result.queryField}" with value "\${result.queryValue}":\`, result.error);
|
|
1047
|
+
hasErrors = true;
|
|
942
1048
|
return;
|
|
943
1049
|
}
|
|
944
1050
|
|
|
945
|
-
if (result.data) {
|
|
1051
|
+
if (result.data && Array.isArray(result.data)) {
|
|
1052
|
+
console.log(\`🔍 Search results for "\${result.queryField}": \${result.data.length} items\`);
|
|
1053
|
+
|
|
946
1054
|
// Add each result, using id as key to deduplicate
|
|
947
1055
|
for (const item of result.data as TWithRelations[]) {
|
|
948
1056
|
// @ts-ignore: Assume item has an id property
|
|
949
|
-
if (item.id) {
|
|
1057
|
+
if (item && typeof item === 'object' && 'id' in item && item.id) {
|
|
950
1058
|
// @ts-ignore: Add to results using id as key
|
|
951
1059
|
allResults[item.id] = item;
|
|
952
1060
|
}
|
|
953
1061
|
}
|
|
1062
|
+
} else if (result.data) {
|
|
1063
|
+
console.warn(\`🔍 Unexpected search result format for "\${result.queryField}":\`, typeof result.data);
|
|
954
1064
|
}
|
|
955
1065
|
});
|
|
956
1066
|
|
|
957
1067
|
// Convert back to array
|
|
958
1068
|
results = Object.values(allResults);
|
|
1069
|
+
console.log(\`🔍 Combined search results: \${results.length} unique items\`);
|
|
959
1070
|
|
|
960
|
-
// Apply any where conditions client-side
|
|
1071
|
+
// Apply any where conditions client-side (now using the proper filter function)
|
|
961
1072
|
if (where) {
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
// Skip complex filters for now
|
|
966
|
-
continue;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
if (item[key as keyof typeof item] !== value) {
|
|
970
|
-
return false;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
return true;
|
|
974
|
-
});
|
|
1073
|
+
const originalCount = results.length;
|
|
1074
|
+
results = results.filter((item) => matchesFilter(item, where));
|
|
1075
|
+
console.log(\`🔍 After applying where filter: \${results.length}/\${originalCount} items\`);
|
|
975
1076
|
}
|
|
976
1077
|
|
|
977
1078
|
// Set count directly for search results
|
|
978
1079
|
setCount(results.length);
|
|
979
1080
|
|
|
980
|
-
// Apply ordering if needed
|
|
1081
|
+
// Apply ordering if needed (using the proper compare function)
|
|
981
1082
|
if (orderBy) {
|
|
982
|
-
const
|
|
983
|
-
if (orderEntries.length > 0) {
|
|
984
|
-
const [orderField, direction] = orderEntries[0] || [];
|
|
1083
|
+
const orderByArray = Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
985
1084
|
results = [...results].sort((a, b) => {
|
|
986
|
-
|
|
987
|
-
const
|
|
1085
|
+
for (const orderByClause of orderByArray) {
|
|
1086
|
+
for (const [field, direction] of Object.entries(orderByClause)) {
|
|
1087
|
+
const aValue = a[field as keyof typeof a];
|
|
1088
|
+
const bValue = b[field as keyof typeof b];
|
|
988
1089
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
|
1090
|
+
if (aValue === bValue) continue;
|
|
1091
|
+
|
|
1092
|
+
return compareValues(aValue, bValue, direction as 'asc' | 'desc');
|
|
993
1093
|
}
|
|
994
|
-
}
|
|
995
|
-
|
|
1094
|
+
}
|
|
1095
|
+
return 0;
|
|
1096
|
+
});
|
|
996
1097
|
}
|
|
997
1098
|
|
|
998
1099
|
// Apply pagination if needed
|
|
999
1100
|
let paginatedResults = results;
|
|
1000
|
-
if (limit && limit > 0) {
|
|
1001
|
-
paginatedResults = results.slice(0, limit);
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
1101
|
if (offset && offset > 0) {
|
|
1005
1102
|
paginatedResults = paginatedResults.slice(offset);
|
|
1006
1103
|
}
|
|
1007
1104
|
|
|
1105
|
+
if (limit && limit > 0) {
|
|
1106
|
+
paginatedResults = paginatedResults.slice(0, limit);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
console.log(\`🔍 Final search results: \${paginatedResults.length} items (total: \${results.length})\`);
|
|
1110
|
+
|
|
1008
1111
|
// Update data with search results
|
|
1009
1112
|
setData(paginatedResults);
|
|
1113
|
+
|
|
1114
|
+
// Show error if there were issues but still show partial results
|
|
1115
|
+
if (hasErrors && results.length === 0) {
|
|
1116
|
+
setError(new Error('Search failed - please check if search functions are properly configured'));
|
|
1117
|
+
}
|
|
1010
1118
|
} catch (err) {
|
|
1011
|
-
console.error('Search error:', err);
|
|
1119
|
+
console.error('🔍 Search error:', err);
|
|
1012
1120
|
setError(err as Error);
|
|
1121
|
+
setData([]);
|
|
1122
|
+
setCount(0);
|
|
1013
1123
|
} finally {
|
|
1014
1124
|
setSearchLoading(false);
|
|
1015
1125
|
}
|
|
@@ -118,33 +118,16 @@ function generateModelTypesFile(model) {
|
|
|
118
118
|
// Generate imports section for zod custom types
|
|
119
119
|
let customImports = '';
|
|
120
120
|
if (model.zodImports && model.zodImports.length > 0) {
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
// Get the zod schemas file path from environment variable
|
|
124
|
-
const zodSchemasPath = process.env.ZOD_SCHEMAS_FILE_PATH || '../commonTypes';
|
|
125
|
-
// Add custom imports with environment variable path
|
|
126
|
-
customImports += model.zodImports
|
|
127
|
-
.map(zodImport => {
|
|
128
|
-
// Extract the types from the original import statement
|
|
129
|
-
const typeMatch = zodImport.importStatement.match(/import\s+{\s*([^}]+)\s*}\s+from/);
|
|
130
|
-
if (typeMatch) {
|
|
131
|
-
const types = typeMatch[1].trim();
|
|
132
|
-
return `import { ${types} } from '${zodSchemasPath}'`;
|
|
133
|
-
}
|
|
134
|
-
// Fallback to original import if parsing fails
|
|
135
|
-
return zodImport.importStatement;
|
|
136
|
-
})
|
|
137
|
-
.join('\n') + '\n\n';
|
|
138
|
-
// Add type definitions for imported zod schemas if needed
|
|
139
|
-
// This is a simplified approach - you might want to make this more sophisticated
|
|
121
|
+
// For projects without zod dependency, fallback to any types instead of importing zod
|
|
122
|
+
// This prevents TypeScript errors when zod is not installed
|
|
140
123
|
const customTypeDefinitions = model.zodImports
|
|
141
124
|
.flatMap(zodImport => zodImport.types)
|
|
142
125
|
.filter((type, index, array) => array.indexOf(type) === index) // Remove duplicates
|
|
143
126
|
.map(type => {
|
|
144
|
-
// If it ends with 'Schema', create a corresponding type
|
|
127
|
+
// If it ends with 'Schema', create a corresponding type using any instead of zod
|
|
145
128
|
if (type.endsWith('Schema')) {
|
|
146
129
|
const typeName = type.replace('Schema', '');
|
|
147
|
-
return
|
|
130
|
+
return `// Fallback type when zod is not available\nexport type ${typeName} = any;`;
|
|
148
131
|
}
|
|
149
132
|
return '';
|
|
150
133
|
})
|
package/dist/index.js
CHANGED
|
@@ -105,20 +105,45 @@ function analyzePrismaSchema(schemaPath) {
|
|
|
105
105
|
: modelName;
|
|
106
106
|
const enableRealtime = !modelBodyWithComments.includes('// @disableRealtime');
|
|
107
107
|
const searchFields = [];
|
|
108
|
-
// Split model body into lines to check
|
|
108
|
+
// Split model body into lines to check for @enableSearch directives
|
|
109
109
|
const bodyLines = modelBody.trim().split('\n');
|
|
110
|
+
let nextFieldShouldBeSearchable = false;
|
|
110
111
|
for (let i = 0; i < bodyLines.length; i++) {
|
|
111
|
-
const currentLine = bodyLines[i]
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
112
|
+
const currentLine = bodyLines[i]?.trim() || '';
|
|
113
|
+
// Skip blank lines and non-field lines
|
|
114
|
+
if (!currentLine || currentLine.startsWith('@@')) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// Check for /// @enableSearch directive (applies to NEXT field)
|
|
118
|
+
if (currentLine === '/// @enableSearch') {
|
|
119
|
+
nextFieldShouldBeSearchable = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// Check for standalone // @enableSearch comment (applies to NEXT field)
|
|
123
|
+
if (currentLine === '// @enableSearch') {
|
|
124
|
+
nextFieldShouldBeSearchable = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Check if line is a comment - SKIP ALL TYPES of comments but keep search flag
|
|
128
|
+
if (currentLine.startsWith('///') || currentLine.startsWith('//')) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Parse field definition - Updated to handle array types and inline comments
|
|
132
|
+
const fieldMatch = currentLine.match(/^\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*/);
|
|
133
|
+
if (fieldMatch) {
|
|
134
|
+
const fieldName = fieldMatch[1];
|
|
135
|
+
const fieldType = fieldMatch[2];
|
|
136
|
+
// Check if this field should be searchable due to @enableSearch directive
|
|
137
|
+
if (nextFieldShouldBeSearchable && fieldName && fieldType) {
|
|
138
|
+
searchFields.push({
|
|
139
|
+
name: fieldName,
|
|
140
|
+
type: fieldType,
|
|
141
|
+
});
|
|
142
|
+
nextFieldShouldBeSearchable = false; // Reset flag
|
|
143
|
+
}
|
|
144
|
+
// Check for inline // @enableSearch comment
|
|
145
|
+
if (currentLine.includes('// @enableSearch')) {
|
|
146
|
+
if (fieldName && fieldType && !searchFields.some(f => f.name === fieldName)) {
|
|
122
147
|
searchFields.push({
|
|
123
148
|
name: fieldName,
|
|
124
149
|
type: fieldType,
|
|
@@ -195,6 +220,7 @@ async function configurePrismaTablesForSuparisma(schemaPath) {
|
|
|
195
220
|
if (model.searchFields.length > 0) {
|
|
196
221
|
console.log(` 🔍 Setting up full-text search for model ${model.name}:`);
|
|
197
222
|
const { rows: columns } = await pool.query(`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, [actualTableName]);
|
|
223
|
+
// Create individual field search functions
|
|
198
224
|
for (const searchField of model.searchFields) {
|
|
199
225
|
const matchingColumn = columns.find((c) => c.column_name.toLowerCase() === searchField.name.toLowerCase());
|
|
200
226
|
if (!matchingColumn) {
|
|
@@ -206,16 +232,73 @@ async function configurePrismaTablesForSuparisma(schemaPath) {
|
|
|
206
232
|
const indexName = `idx_gin_search_${actualTableName.toLowerCase()}_${actualColumnName.toLowerCase()}`;
|
|
207
233
|
console.log(` ➡️ Configuring field "${actualColumnName}":`);
|
|
208
234
|
try {
|
|
209
|
-
// Create search function
|
|
235
|
+
// Create search function with improved partial search and error handling
|
|
210
236
|
const createFunctionQuery = `
|
|
211
237
|
CREATE OR REPLACE FUNCTION "public"."${functionName}"(search_prefix text)
|
|
212
238
|
RETURNS SETOF "public"."${actualTableName}" AS $$
|
|
239
|
+
DECLARE
|
|
240
|
+
clean_prefix text;
|
|
241
|
+
words text[];
|
|
242
|
+
word text;
|
|
243
|
+
tsquery_str text := '';
|
|
213
244
|
BEGIN
|
|
245
|
+
-- Handle empty or null search terms
|
|
246
|
+
IF search_prefix IS NULL OR trim(search_prefix) = '' THEN
|
|
247
|
+
RETURN;
|
|
248
|
+
END IF;
|
|
249
|
+
|
|
250
|
+
-- Clean the search prefix: remove special characters, normalize spaces
|
|
251
|
+
clean_prefix := regexp_replace(trim(search_prefix), '[^a-zA-Z0-9\\s]', ' ', 'g');
|
|
252
|
+
clean_prefix := regexp_replace(clean_prefix, '\\s+', ' ', 'g');
|
|
253
|
+
clean_prefix := trim(clean_prefix);
|
|
254
|
+
|
|
255
|
+
-- Handle empty string after cleaning
|
|
256
|
+
IF clean_prefix = '' THEN
|
|
257
|
+
RETURN;
|
|
258
|
+
END IF;
|
|
259
|
+
|
|
260
|
+
-- Split into words and build partial search query
|
|
261
|
+
words := string_to_array(clean_prefix, ' ');
|
|
262
|
+
|
|
263
|
+
-- Build tsquery for partial matching
|
|
264
|
+
FOR i IN 1..array_length(words, 1) LOOP
|
|
265
|
+
word := words[i];
|
|
266
|
+
IF word != '' THEN
|
|
267
|
+
IF tsquery_str != '' THEN
|
|
268
|
+
tsquery_str := tsquery_str || ' & ';
|
|
269
|
+
END IF;
|
|
270
|
+
-- Add prefix matching for each word
|
|
271
|
+
tsquery_str := tsquery_str || word || ':*';
|
|
272
|
+
END IF;
|
|
273
|
+
END LOOP;
|
|
274
|
+
|
|
275
|
+
-- Return query with proper error handling
|
|
214
276
|
RETURN QUERY
|
|
215
277
|
SELECT * FROM "public"."${actualTableName}"
|
|
216
|
-
WHERE
|
|
278
|
+
WHERE
|
|
279
|
+
"${actualColumnName}" IS NOT NULL
|
|
280
|
+
AND "${actualColumnName}" != ''
|
|
281
|
+
AND (
|
|
282
|
+
-- Use the built tsquery for structured search
|
|
283
|
+
to_tsvector('english', "${actualColumnName}") @@ to_tsquery('english', tsquery_str)
|
|
284
|
+
OR
|
|
285
|
+
-- Fallback to simple text matching for very partial matches
|
|
286
|
+
"${actualColumnName}" ILIKE '%' || search_prefix || '%'
|
|
287
|
+
);
|
|
288
|
+
EXCEPTION
|
|
289
|
+
WHEN others THEN
|
|
290
|
+
-- Log error and return empty result set instead of failing
|
|
291
|
+
RAISE NOTICE 'Search function error: %, falling back to simple ILIKE search', SQLERRM;
|
|
292
|
+
-- Fallback to simple pattern matching
|
|
293
|
+
RETURN QUERY
|
|
294
|
+
SELECT * FROM "public"."${actualTableName}"
|
|
295
|
+
WHERE
|
|
296
|
+
"${actualColumnName}" IS NOT NULL
|
|
297
|
+
AND "${actualColumnName}" != ''
|
|
298
|
+
AND "${actualColumnName}" ILIKE '%' || search_prefix || '%';
|
|
299
|
+
RETURN;
|
|
217
300
|
END;
|
|
218
|
-
$$ LANGUAGE plpgsql STABLE;`; // Added STABLE for
|
|
301
|
+
$$ LANGUAGE plpgsql STABLE;`; // Added STABLE for performance
|
|
219
302
|
await pool.query(createFunctionQuery);
|
|
220
303
|
console.log(` ✅ Created/Replaced RPC function: "${functionName}"(search_prefix text)`);
|
|
221
304
|
// Create GIN index
|
|
@@ -250,6 +333,114 @@ async function configurePrismaTablesForSuparisma(schemaPath) {
|
|
|
250
333
|
console.error(` ❌ Failed to set up search for "${actualTableName}"."${actualColumnName}": ${err.message}`);
|
|
251
334
|
}
|
|
252
335
|
}
|
|
336
|
+
// Create multi-field search function if there are multiple searchable fields
|
|
337
|
+
if (model.searchFields.length > 1) {
|
|
338
|
+
console.log(` ➡️ Creating multi-field search function:`);
|
|
339
|
+
try {
|
|
340
|
+
const validSearchFields = model.searchFields.filter(field => columns.find(c => c.column_name.toLowerCase() === field.name.toLowerCase()));
|
|
341
|
+
if (validSearchFields.length > 1) {
|
|
342
|
+
const multiFieldFunctionName = `search_${actualTableName.toLowerCase()}_multi_field`;
|
|
343
|
+
const multiFieldIndexName = `idx_gin_search_${actualTableName.toLowerCase()}_multi_field`;
|
|
344
|
+
// Get actual column names
|
|
345
|
+
const actualColumnNames = validSearchFields.map(field => {
|
|
346
|
+
const matchingColumn = columns.find(c => c.column_name.toLowerCase() === field.name.toLowerCase());
|
|
347
|
+
return matchingColumn.column_name;
|
|
348
|
+
});
|
|
349
|
+
// Create multi-field search function with improved partial search
|
|
350
|
+
const createMultiFieldFunctionQuery = `
|
|
351
|
+
CREATE OR REPLACE FUNCTION "public"."${multiFieldFunctionName}"(search_prefix text)
|
|
352
|
+
RETURNS SETOF "public"."${actualTableName}" AS $$
|
|
353
|
+
DECLARE
|
|
354
|
+
clean_prefix text;
|
|
355
|
+
words text[];
|
|
356
|
+
word text;
|
|
357
|
+
tsquery_str text := '';
|
|
358
|
+
combined_text text;
|
|
359
|
+
BEGIN
|
|
360
|
+
-- Handle empty or null search terms
|
|
361
|
+
IF search_prefix IS NULL OR trim(search_prefix) = '' THEN
|
|
362
|
+
RETURN;
|
|
363
|
+
END IF;
|
|
364
|
+
|
|
365
|
+
-- Clean the search prefix: remove special characters, normalize spaces
|
|
366
|
+
clean_prefix := regexp_replace(trim(search_prefix), '[^a-zA-Z0-9\\s]', ' ', 'g');
|
|
367
|
+
clean_prefix := regexp_replace(clean_prefix, '\\s+', ' ', 'g');
|
|
368
|
+
clean_prefix := trim(clean_prefix);
|
|
369
|
+
|
|
370
|
+
-- Handle empty string after cleaning
|
|
371
|
+
IF clean_prefix = '' THEN
|
|
372
|
+
RETURN;
|
|
373
|
+
END IF;
|
|
374
|
+
|
|
375
|
+
-- Split into words and build partial search query
|
|
376
|
+
words := string_to_array(clean_prefix, ' ');
|
|
377
|
+
|
|
378
|
+
-- Build tsquery for partial matching
|
|
379
|
+
FOR i IN 1..array_length(words, 1) LOOP
|
|
380
|
+
word := words[i];
|
|
381
|
+
IF word != '' THEN
|
|
382
|
+
IF tsquery_str != '' THEN
|
|
383
|
+
tsquery_str := tsquery_str || ' & ';
|
|
384
|
+
END IF;
|
|
385
|
+
-- Add prefix matching for each word
|
|
386
|
+
tsquery_str := tsquery_str || word || ':*';
|
|
387
|
+
END IF;
|
|
388
|
+
END LOOP;
|
|
389
|
+
|
|
390
|
+
-- Return query searching across all searchable fields
|
|
391
|
+
RETURN QUERY
|
|
392
|
+
SELECT * FROM "public"."${actualTableName}"
|
|
393
|
+
WHERE
|
|
394
|
+
(
|
|
395
|
+
-- Use the built tsquery for structured search
|
|
396
|
+
to_tsvector('english',
|
|
397
|
+
COALESCE("${actualColumnNames.join('", \'\') || \' \' || COALESCE("')}", '')
|
|
398
|
+
) @@ to_tsquery('english', tsquery_str)
|
|
399
|
+
OR
|
|
400
|
+
-- Fallback to simple text matching across all fields
|
|
401
|
+
(${actualColumnNames.map(col => `"${col}" ILIKE '%' || search_prefix || '%'`).join(' OR ')})
|
|
402
|
+
);
|
|
403
|
+
EXCEPTION
|
|
404
|
+
WHEN others THEN
|
|
405
|
+
-- Log error and return empty result set instead of failing
|
|
406
|
+
RAISE NOTICE 'Multi-field search function error: %, falling back to simple ILIKE search', SQLERRM;
|
|
407
|
+
-- Fallback to simple pattern matching across all fields
|
|
408
|
+
RETURN QUERY
|
|
409
|
+
SELECT * FROM "public"."${actualTableName}"
|
|
410
|
+
WHERE
|
|
411
|
+
(${actualColumnNames.map(col => `"${col}" ILIKE '%' || search_prefix || '%'`).join(' OR ')});
|
|
412
|
+
RETURN;
|
|
413
|
+
END;
|
|
414
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
415
|
+
await pool.query(createMultiFieldFunctionQuery);
|
|
416
|
+
console.log(` ✅ Created multi-field search function: "${multiFieldFunctionName}"(search_prefix text)`);
|
|
417
|
+
// Create multi-field GIN index
|
|
418
|
+
const createMultiFieldIndexQuery = `
|
|
419
|
+
DO $$
|
|
420
|
+
BEGIN
|
|
421
|
+
IF NOT EXISTS (
|
|
422
|
+
SELECT 1 FROM pg_indexes
|
|
423
|
+
WHERE schemaname = 'public'
|
|
424
|
+
AND tablename = '${actualTableName}'
|
|
425
|
+
AND indexname = '${multiFieldIndexName}'
|
|
426
|
+
) THEN
|
|
427
|
+
CREATE INDEX "${multiFieldIndexName}" ON "public"."${actualTableName}"
|
|
428
|
+
USING GIN (to_tsvector('english',
|
|
429
|
+
COALESCE("${actualColumnNames.join('", \'\') || \' \' || COALESCE("')}", '')
|
|
430
|
+
));
|
|
431
|
+
RAISE NOTICE ' ✅ Created multi-field GIN index: "${multiFieldIndexName}"';
|
|
432
|
+
ELSE
|
|
433
|
+
RAISE NOTICE ' ℹ️ Multi-field GIN index "${multiFieldIndexName}" already exists.';
|
|
434
|
+
END IF;
|
|
435
|
+
END;
|
|
436
|
+
$$;`;
|
|
437
|
+
await pool.query(createMultiFieldIndexQuery);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
console.error(` ❌ Failed to set up multi-field search for "${actualTableName}": ${err.message}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
253
444
|
}
|
|
254
445
|
else {
|
|
255
446
|
console.log(` ℹ️ No fields marked with // @enableSearch for model ${model.name}.`);
|