suparisma 1.1.1 → 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/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}.`);
package/dist/parser.js CHANGED
@@ -70,10 +70,10 @@ function parseZodImport(comment) {
70
70
  */
71
71
  function parsePrismaSchema(schemaPath) {
72
72
  const schema = fs_1.default.readFileSync(schemaPath, 'utf-8');
73
- const modelRegex = /model\s+(\w+)\s+{([^}]*)}/gs;
73
+ const modelRegex = /model\s+(\w+)\s+{([^}]*)}/g;
74
74
  const models = [];
75
75
  // Extract enum names from the schema
76
- const enumRegex = /enum\s+(\w+)\s+{[^}]*}/gs;
76
+ const enumRegex = /enum\s+(\w+)\s+{[^}]*}/g;
77
77
  const enumNames = [];
78
78
  let enumMatch;
79
79
  while ((enumMatch = enumRegex.exec(schema)) !== null) {
@@ -95,77 +95,87 @@ function parsePrismaSchema(schemaPath) {
95
95
  const searchFields = [];
96
96
  // Track zod imports at model level
97
97
  const zodImports = [];
98
- const lines = modelBody.split('\n');
99
- let lastFieldName = '';
100
- let lastFieldType = '';
98
+ // Use EXACT same logic as analyzePrismaSchema for consistency
99
+ const bodyLines = modelBody.trim().split('\n');
100
+ let nextFieldShouldBeSearchable = false;
101
101
  let pendingZodDirective;
102
- for (let i = 0; i < lines.length; i++) {
103
- const line = lines[i]?.trim();
102
+ for (let i = 0; i < bodyLines.length; i++) {
103
+ const currentLine = bodyLines[i]?.trim();
104
104
  // Skip blank lines and non-field lines
105
- if (!line || line.startsWith('@@')) {
105
+ if (!currentLine || currentLine.startsWith('@@')) {
106
106
  continue;
107
107
  }
108
- // Check for standalone @enableSearch comment
109
- if (line === '// @enableSearch' && lastFieldName) {
110
- searchFields.push({
111
- name: lastFieldName,
112
- type: lastFieldType,
113
- });
108
+ // Check for /// @enableSearch directive (applies to NEXT field)
109
+ if (currentLine === '/// @enableSearch') {
110
+ nextFieldShouldBeSearchable = true;
114
111
  continue;
115
112
  }
116
- // Check if line is a comment
117
- if (line.startsWith('//')) {
113
+ // Check for standalone // @enableSearch comment (applies to NEXT field)
114
+ if (currentLine === '// @enableSearch') {
115
+ nextFieldShouldBeSearchable = true;
116
+ continue;
117
+ }
118
+ // Check if line is a comment - SKIP ALL TYPES of comments but keep search flag
119
+ if (currentLine.startsWith('///') || currentLine.startsWith('//')) {
118
120
  // Parse zod directives from comments
119
- const zodDirective = parseZodDirective(line);
121
+ const zodDirective = parseZodDirective(currentLine);
120
122
  if (zodDirective) {
121
123
  pendingZodDirective = zodDirective;
122
124
  }
123
125
  // Parse zod imports from comments
124
- const zodImportInfos = parseZodImport(line);
126
+ const zodImportInfos = parseZodImport(currentLine);
125
127
  zodImports.push(...zodImportInfos);
126
128
  continue;
127
129
  }
128
- // Parse field definition - Updated to handle array types
129
- const fieldMatch = line.match(/\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*(?:@[^)]+)?/);
130
+ // Parse field definition - Updated to handle array types and inline comments
131
+ const fieldMatch = currentLine.match(/^\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*/);
130
132
  if (fieldMatch) {
131
133
  const fieldName = fieldMatch[1];
132
- const baseFieldType = fieldMatch[2]; // e.g., "String" from "String[]"
134
+ const baseFieldType = fieldMatch[2];
135
+ // Check if this field should be searchable due to @enableSearch directive
136
+ if (nextFieldShouldBeSearchable && fieldName && baseFieldType) {
137
+ searchFields.push({
138
+ name: fieldName,
139
+ type: baseFieldType,
140
+ });
141
+ nextFieldShouldBeSearchable = false; // Reset flag
142
+ }
143
+ // Check for inline // @enableSearch comment
144
+ if (currentLine.includes('// @enableSearch')) {
145
+ if (fieldName && baseFieldType && !searchFields.some(f => f.name === fieldName)) {
146
+ searchFields.push({
147
+ name: fieldName,
148
+ type: baseFieldType,
149
+ });
150
+ }
151
+ }
152
+ // Continue with field processing for the fields array
133
153
  const isArray = !!fieldMatch[3]; // [] makes it an array
134
154
  const isOptional = !!fieldMatch[4]; // ? makes it optional
135
- // Store for potential standalone @enableSearch comment
136
- lastFieldName = fieldName || '';
137
- lastFieldType = baseFieldType || '';
138
155
  // Detect special fields
139
- const isId = line.includes('@id');
156
+ const isId = currentLine.includes('@id');
140
157
  const isCreatedAt = fieldName === 'created_at' || fieldName === 'createdAt';
141
158
  const isUpdatedAt = fieldName === 'updated_at' || fieldName === 'updatedAt';
142
- const hasDefaultValue = line.includes('@default');
159
+ const hasDefaultValue = currentLine.includes('@default');
143
160
  // Extract default value if present
144
161
  let defaultValue;
145
162
  if (hasDefaultValue) {
146
- const defaultMatch = line.match(/@default\(\s*(.+?)\s*\)/);
163
+ const defaultMatch = currentLine.match(/@default\(\s*(.+?)\s*\)/);
147
164
  if (defaultMatch) {
148
165
  defaultValue = defaultMatch[1];
149
166
  }
150
167
  }
151
168
  // Improved relation detection
152
169
  const primitiveTypes = ['String', 'Int', 'Float', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Decimal', 'BigInt'];
153
- const isRelation = line.includes('@relation') ||
170
+ const isRelation = currentLine.includes('@relation') ||
154
171
  (!!fieldName &&
155
172
  (fieldName.endsWith('_id') || fieldName === 'userId' || fieldName === 'user_id')) ||
156
173
  // Also detect relation fields by checking if the type is not a primitive type and not an enum
157
174
  (!!baseFieldType && !primitiveTypes.includes(baseFieldType) && !enumNames.includes(baseFieldType));
158
- // Check for inline @enableSearch comment
159
- if (line.includes('// @enableSearch')) {
160
- searchFields.push({
161
- name: fieldName || '',
162
- type: baseFieldType || '',
163
- });
164
- }
165
175
  // Check for inline zod directive
166
176
  let fieldZodDirective = pendingZodDirective;
167
- if (line.includes('/// @zod.')) {
168
- const inlineZodDirective = parseZodDirective(line);
177
+ if (currentLine.includes('/// @zod.')) {
178
+ const inlineZodDirective = parseZodDirective(currentLine);
169
179
  if (inlineZodDirective) {
170
180
  fieldZodDirective = inlineZodDirective;
171
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suparisma",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Opinionated typesafe React realtime CRUD hooks generator for all your Supabase tables, powered by Prisma.",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -21,7 +21,9 @@ enum SomeEnum {
21
21
 
22
22
  model User {
23
23
  id String @id @default(uuid())
24
+ // @enableSearch
24
25
  name String
26
+ // @enableSearch
25
27
  email String @unique
26
28
  things Thing[] // One-to-many relation
27
29
  createdAt DateTime @default(now())
@@ -33,11 +35,14 @@ model Thing {
33
35
  id String @id @default(uuid())
34
36
  // @enableSearch
35
37
  name String?
38
+ // @enableSearch
39
+ /// Somehting here
40
+ description String? /// @thing
36
41
  stringArray String[]
37
42
  someEnum SomeEnum @default(ONE)
38
43
  someNumber Int?
39
44
 
40
- /// [Json]
45
+ // @enableSearch
41
46
  someJson Json? /// @zod.custom.use(z.array(LLMNodeSchema).nullable())
42
47
  userId String?
43
48
  user User? @relation(fields: [userId], references: [id])