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.
@@ -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 = queries.map(query => {
925
- // Build function name: search_tablename_by_fieldname_prefix
926
- const functionName = \`search_\${tableName}_by_\${query.field}_prefix\`;
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
- // Call RPC function
929
- return supabase.rpc(functionName, { prefix: query.value.trim() });
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(\`Search error for \${queries[index]?.field}:\`, result.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
- results = results.filter((item) => {
963
- for (const [key, value] of Object.entries(where)) {
964
- if (typeof value === 'object' && value !== null) {
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 orderEntries = Object.entries(orderBy);
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
- const aValue = a[orderField as keyof typeof a] ?? '';
987
- const bValue = b[orderField as keyof typeof b] ?? '';
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
- if (direction === 'asc') {
990
- return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
991
- } else {
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
- // Add zod import for type inference
122
- customImports = 'import { z } from \'zod\';\n';
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 `export type ${typeName} = z.infer<typeof ${type}>;`;
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 preceding line for @enableSearch
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].trim();
112
- const previousLine = i > 0 ? bodyLines[i - 1].trim() : "";
113
- // Check if the PREVIOUS line contains the @enableSearch comment
114
- if (previousLine.includes('// @enableSearch')) {
115
- // Try to parse the CURRENT line as a field definition
116
- // Basic regex: fieldName fieldType (optional attributes/comments)
117
- const fieldMatch = currentLine.match(/^(\w+)\s+(\w+)/);
118
- if (fieldMatch) {
119
- const fieldName = fieldMatch[1];
120
- const fieldType = fieldMatch[2];
121
- if (fieldName && fieldType) {
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 to_tsvector('english', "${actualColumnName}") @@ to_tsquery('english', search_prefix || ':*');
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 potential performance benefits
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}.`);