suparisma 1.0.3 → 1.0.5

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 CHANGED
@@ -21,6 +21,8 @@ A powerful, typesafe React hook generator for Supabase, driven by your Prisma sc
21
21
  - [Basic CRUD Operations](#basic-crud-operations)
22
22
  - [Realtime Updates](#realtime-updates)
23
23
  - [Filtering Data](#filtering-data)
24
+ - [⚠️ IMPORTANT: Using Dynamic Filters with React](#️-important-using-dynamic-filters-with-react)
25
+ - [Array Filtering](#array-filtering)
24
26
  - [Sorting Data](#sorting-data)
25
27
  - [Pagination](#pagination)
26
28
  - [Search Functionality](#search-functionality)
@@ -58,7 +60,7 @@ Suparisma bridges this gap by:
58
60
  - 🚀 **Auto-generated React hooks** based on your Prisma schema
59
61
  - 🔄 **Real-time updates by default** for all tables (with opt-out capability)
60
62
  - 🔒 **Type-safe interfaces** for all database operations
61
- - 🔍 **Full-text search** with configurable annotations
63
+ - 🔍 **Full-text search** with configurable annotations *(currently under maintenance)*
62
64
  - 🔢 **Pagination and sorting** built into every hook
63
65
  - 🧩 **Prisma-like API** that feels familiar if you already use Prisma
64
66
  - 📱 **Works with any React framework** including Next.js, Remix, etc.
@@ -303,6 +305,281 @@ const { data } = useSuparisma.thing({
303
305
  });
304
306
  ```
305
307
 
308
+ ### ⚠️ IMPORTANT: Using Dynamic Filters with React
309
+
310
+ **You MUST use `useMemo` for dynamic where filters to prevent constant re-subscriptions!**
311
+
312
+ When creating `where` filters based on state variables, React will create a new object reference on every render, causing the realtime subscription to restart constantly and **breaking realtime updates**.
313
+
314
+ ❌ **WRONG - This breaks realtime:**
315
+ ```tsx
316
+ function MyComponent() {
317
+ const [filter, setFilter] = useState("active");
318
+
319
+ const { data } = useSuparisma.thing({
320
+ where: filter ? { status: filter } : undefined // ❌ New object every render!
321
+ });
322
+ }
323
+ ```
324
+
325
+ ✅ **CORRECT - Use useMemo:**
326
+ ```tsx
327
+ import { useMemo } from 'react';
328
+
329
+ function MyComponent() {
330
+ const [filter, setFilter] = useState("active");
331
+ const [arrayFilter, setArrayFilter] = useState(["item1"]);
332
+
333
+ // Create stable object reference that only changes when dependencies change
334
+ const whereFilter = useMemo(() => {
335
+ if (filter) {
336
+ return { status: filter };
337
+ }
338
+ return undefined;
339
+ }, [filter]); // Only recreate when filter actually changes
340
+
341
+ const { data } = useSuparisma.thing({
342
+ where: whereFilter // ✅ Stable reference!
343
+ });
344
+ }
345
+ ```
346
+
347
+ ✅ **Complex example with multiple filters:**
348
+ ```tsx
349
+ const whereFilter = useMemo(() => {
350
+ if (arrayFilterValue && arrayOperator) {
351
+ return {
352
+ tags: arrayOperator === 'has'
353
+ ? { has: [arrayFilterValue] }
354
+ : arrayOperator === 'hasEvery'
355
+ ? { hasEvery: ["required", "tag", arrayFilterValue] }
356
+ : { isEmpty: false }
357
+ };
358
+ } else if (statusFilter) {
359
+ return { status: statusFilter };
360
+ }
361
+ return undefined;
362
+ }, [arrayFilterValue, arrayOperator, statusFilter]); // Dependencies
363
+
364
+ const { data } = useSuparisma.thing({ where: whereFilter });
365
+ ```
366
+
367
+ **Why this matters:**
368
+ - Without `useMemo`, the subscription restarts on EVERY render
369
+ - This causes realtime events to be lost during reconnection
370
+ - You'll see constant "Unsubscribing/Subscribing" messages in the console
371
+ - Realtime updates will appear to be broken
372
+
373
+ **The same applies to `orderBy` if it's dynamic:**
374
+ ```tsx
375
+ const orderByConfig = useMemo(() => ({
376
+ [sortField]: sortDirection
377
+ }), [sortField, sortDirection]);
378
+
379
+ const { data } = useSuparisma.thing({
380
+ where: whereFilter,
381
+ orderBy: orderByConfig
382
+ });
383
+ ```
384
+
385
+ ### Array Filtering
386
+
387
+ Suparisma provides powerful operators for filtering array fields (e.g., `String[]`, `Int[]`, etc.):
388
+
389
+ ```prisma
390
+ model Post {
391
+ id String @id @default(uuid())
392
+ title String
393
+ tags String[] // Array field
394
+ ratings Int[] // Array field
395
+ // ... other fields
396
+ }
397
+ ```
398
+
399
+ #### Array Operators
400
+
401
+ | Operator | Description | Example Usage |
402
+ |----------|-------------|---------------|
403
+ | `has` | Array contains **ANY** of the specified items | `tags: { has: ["react", "typescript"] }` |
404
+ | `hasSome` | Array contains **ANY** of the specified items (alias for `has`) | `tags: { hasSome: ["react", "vue"] }` |
405
+ | `hasEvery` | Array contains **ALL** of the specified items | `tags: { hasEvery: ["react", "typescript"] }` |
406
+ | `isEmpty` | Array is empty or not empty | `tags: { isEmpty: false }` |
407
+
408
+ #### Array Filtering Examples
409
+
410
+ ```tsx
411
+ // Find posts that have ANY of these tags
412
+ const { data: reactOrVuePosts } = useSuparisma.post({
413
+ where: {
414
+ tags: { has: ["react", "vue", "angular"] }
415
+ }
416
+ });
417
+ // Returns posts with tags like: ["react"], ["vue"], ["react", "typescript"], etc.
418
+
419
+ // Find posts that have ALL of these tags
420
+ const { data: fullStackPosts } = useSuparisma.post({
421
+ where: {
422
+ tags: { hasEvery: ["react", "typescript", "nodejs"] }
423
+ }
424
+ });
425
+ // Returns posts that contain all three tags (and possibly more)
426
+
427
+ // Find posts with any of these ratings
428
+ const { data: highRatedPosts } = useSuparisma.post({
429
+ where: {
430
+ ratings: { hasSome: [4, 5] }
431
+ }
432
+ });
433
+ // Returns posts with arrays containing 4 or 5: [3, 4], [5], [1, 2, 4, 5], etc.
434
+
435
+ // Find posts with no tags
436
+ const { data: untaggedPosts } = useSuparisma.post({
437
+ where: {
438
+ tags: { isEmpty: true }
439
+ }
440
+ });
441
+
442
+ // Find posts that have tags (non-empty)
443
+ const { data: taggedPosts } = useSuparisma.post({
444
+ where: {
445
+ tags: { isEmpty: false }
446
+ }
447
+ });
448
+
449
+ // Combine array filtering with other conditions
450
+ const { data: featuredReactPosts } = useSuparisma.post({
451
+ where: {
452
+ tags: { has: ["react"] },
453
+ featured: true,
454
+ ratings: { hasEvery: [4, 5] } // Must have both 4 AND 5 ratings
455
+ }
456
+ });
457
+
458
+ // Exact array match (regular equality)
459
+ const { data: exactMatch } = useSuparisma.post({
460
+ where: {
461
+ tags: ["react", "typescript"] // Exact match: only this array
462
+ }
463
+ });
464
+ ```
465
+
466
+ #### Real-World Array Filtering Scenarios
467
+
468
+ ```tsx
469
+ // E-commerce: Find products by multiple categories
470
+ const { data: products } = useSuparisma.product({
471
+ where: {
472
+ categories: { has: ["electronics", "gaming"] } // Any of these categories
473
+ }
474
+ });
475
+
476
+ // Social Media: Find posts with specific hashtags
477
+ const { data: trendingPosts } = useSuparisma.post({
478
+ where: {
479
+ hashtags: { hasSome: ["trending", "viral", "popular"] }
480
+ }
481
+ });
482
+
483
+ // Project Management: Find tasks assigned to specific team members
484
+ const { data: myTasks } = useSuparisma.task({
485
+ where: {
486
+ assignedTo: { has: ["john@company.com"] } // Tasks assigned to John
487
+ }
488
+ });
489
+
490
+ // Content Management: Find articles with all required tags
491
+ const { data: completeArticles } = useSuparisma.article({
492
+ where: {
493
+ requiredTags: { hasEvery: ["reviewed", "approved", "published"] }
494
+ }
495
+ });
496
+ ```
497
+
498
+ #### Interactive Array Filtering Component
499
+
500
+ ```tsx
501
+ import { useState } from "react";
502
+ import useSuparisma from '../generated';
503
+
504
+ function ProductFilter() {
505
+ const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
506
+ const [filterMode, setFilterMode] = useState<'any' | 'all'>('any');
507
+
508
+ const { data: products, loading } = useSuparisma.product({
509
+ where: selectedCategories.length > 0 ? {
510
+ categories: filterMode === 'any'
511
+ ? { has: selectedCategories } // ANY of selected categories
512
+ : { hasEvery: selectedCategories } // ALL of selected categories
513
+ } : undefined
514
+ });
515
+
516
+ const handleCategoryToggle = (category: string) => {
517
+ setSelectedCategories(prev =>
518
+ prev.includes(category)
519
+ ? prev.filter(c => c !== category)
520
+ : [...prev, category]
521
+ );
522
+ };
523
+
524
+ return (
525
+ <div>
526
+ {/* Filter Mode Toggle */}
527
+ <div className="mb-4">
528
+ <label className="mr-4">
529
+ <input
530
+ type="radio"
531
+ value="any"
532
+ checked={filterMode === 'any'}
533
+ onChange={(e) => setFilterMode(e.target.value as 'any')}
534
+ />
535
+ Match ANY categories
536
+ </label>
537
+ <label>
538
+ <input
539
+ type="radio"
540
+ value="all"
541
+ checked={filterMode === 'all'}
542
+ onChange={(e) => setFilterMode(e.target.value as 'all')}
543
+ />
544
+ Match ALL categories
545
+ </label>
546
+ </div>
547
+
548
+ {/* Category Checkboxes */}
549
+ <div className="mb-4">
550
+ {['electronics', 'clothing', 'books', 'home', 'sports'].map(category => (
551
+ <label key={category} className="mr-4">
552
+ <input
553
+ type="checkbox"
554
+ checked={selectedCategories.includes(category)}
555
+ onChange={() => handleCategoryToggle(category)}
556
+ />
557
+ {category}
558
+ </label>
559
+ ))}
560
+ </div>
561
+
562
+ {/* Results */}
563
+ <div>
564
+ {loading ? (
565
+ <p>Loading products...</p>
566
+ ) : (
567
+ <div>
568
+ <h3>Found {products?.length || 0} products</h3>
569
+ {products?.map(product => (
570
+ <div key={product.id}>
571
+ <h4>{product.name}</h4>
572
+ <p>Categories: {product.categories.join(', ')}</p>
573
+ </div>
574
+ ))}
575
+ </div>
576
+ )}
577
+ </div>
578
+ </div>
579
+ );
580
+ }
581
+ ```
582
+
306
583
  ### Sorting Data
307
584
 
308
585
  Sort data using Prisma-like ordering:
@@ -345,6 +622,8 @@ const { data, count } = useSuparisma.thing();
345
622
 
346
623
  ### Search Functionality
347
624
 
625
+ > ⚠️ **MAINTENANCE NOTICE**: Search functionality is currently under maintenance and may not work as expected. We're working on improvements and will update the documentation once it's fully operational.
626
+
348
627
  For fields annotated with `// @enableSearch`, you can use full-text search:
349
628
 
350
629
  ```tsx
@@ -43,6 +43,18 @@ export type SupabaseQueryBuilder = ReturnType<ReturnType<typeof supabase.from>['
43
43
  * @example
44
44
  * // Posts with titles containing "news"
45
45
  * { title: { contains: "news" } }
46
+ *
47
+ * @example
48
+ * // Array contains ANY of these items (overlaps)
49
+ * { tags: { has: ["typescript", "react"] } }
50
+ *
51
+ * @example
52
+ * // Array contains ALL of these items (contains)
53
+ * { categories: { hasEvery: ["tech", "programming"] } }
54
+ *
55
+ * @example
56
+ * // Array contains ANY of these items (same as 'has')
57
+ * { tags: { hasSome: ["javascript", "python"] } }
46
58
  */
47
59
  export type FilterOperators<T> = {
48
60
  /** Equal to value */
@@ -67,6 +79,16 @@ export type FilterOperators<T> = {
67
79
  startsWith?: string;
68
80
  /** String ends with value (case insensitive) */
69
81
  endsWith?: string;
82
+
83
+ // Array-specific operators
84
+ /** Array contains ANY of the specified items (for array fields) */
85
+ has?: T extends Array<infer U> ? U[] : never;
86
+ /** Array contains ANY of the specified items (alias for 'has') */
87
+ hasSome?: T extends Array<infer U> ? U[] : never;
88
+ /** Array contains ALL of the specified items (for array fields) */
89
+ hasEvery?: T extends Array<infer U> ? U[] : never;
90
+ /** Array is empty (for array fields) */
91
+ isEmpty?: T extends Array<any> ? boolean : never;
70
92
  };
71
93
 
72
94
  // Type for a single field in an advanced where filter
@@ -239,6 +261,35 @@ export function buildFilterString<T>(where?: T): string | undefined {
239
261
  if ('endsWith' in advancedOps && advancedOps.endsWith !== undefined) {
240
262
  filters.push(\`\${key}=ilike.%\${advancedOps.endsWith}\`);
241
263
  }
264
+
265
+ // Array-specific operators
266
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
267
+ // Array contains ANY of the specified items (overlaps)
268
+ const arrayValue = JSON.stringify(advancedOps.has);
269
+ filters.push(\`\${key}=ov.\${arrayValue}\`);
270
+ }
271
+
272
+ if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
273
+ // Array contains ALL of the specified items (contains)
274
+ const arrayValue = JSON.stringify(advancedOps.hasEvery);
275
+ filters.push(\`\${key}=cs.\${arrayValue}\`);
276
+ }
277
+
278
+ if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
279
+ // Array contains ANY of the specified items (overlaps)
280
+ const arrayValue = JSON.stringify(advancedOps.hasSome);
281
+ filters.push(\`\${key}=ov.\${arrayValue}\`);
282
+ }
283
+
284
+ if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
285
+ if (advancedOps.isEmpty) {
286
+ // Check if array is empty
287
+ filters.push(\`\${key}=eq.{}\`);
288
+ } else {
289
+ // Check if array is not empty
290
+ filters.push(\`\${key}=neq.{}\`);
291
+ }
292
+ }
242
293
  } else {
243
294
  // Simple equality
244
295
  filters.push(\`\${key}=eq.\${value}\`);
@@ -316,6 +367,37 @@ export function applyFilter<T>(
316
367
  // @ts-ignore: Supabase typing issue
317
368
  filteredQuery = filteredQuery.ilike(key, \`%\${advancedOps.endsWith}\`);
318
369
  }
370
+
371
+ // Array-specific operators
372
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
373
+ // Array contains ANY of the specified items (overlaps)
374
+ // @ts-ignore: Supabase typing issue
375
+ filteredQuery = filteredQuery.overlaps(key, advancedOps.has);
376
+ }
377
+
378
+ if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
379
+ // Array contains ALL of the specified items (contains)
380
+ // @ts-ignore: Supabase typing issue
381
+ filteredQuery = filteredQuery.contains(key, advancedOps.hasEvery);
382
+ }
383
+
384
+ if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
385
+ // Array contains ANY of the specified items (overlaps)
386
+ // @ts-ignore: Supabase typing issue
387
+ filteredQuery = filteredQuery.overlaps(key, advancedOps.hasSome);
388
+ }
389
+
390
+ if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
391
+ if (advancedOps.isEmpty) {
392
+ // Check if array is empty
393
+ // @ts-ignore: Supabase typing issue
394
+ filteredQuery = filteredQuery.eq(key, []);
395
+ } else {
396
+ // Check if array is not empty
397
+ // @ts-ignore: Supabase typing issue
398
+ filteredQuery = filteredQuery.neq(key, []);
399
+ }
400
+ }
319
401
  } else {
320
402
  // Simple equality
321
403
  // @ts-ignore: Supabase typing issue
@@ -830,24 +912,52 @@ export function createSuparismaHook<
830
912
 
831
913
  const channelId = channelName || \`changes_to_\${tableName}_\${Math.random().toString(36).substring(2, 15)}\`;
832
914
 
833
- // Use a dynamic filter string builder inside the event handler or rely on Supabase
834
- // For the subscription filter, we must use the initial computedFilter or a stable one.
835
- // However, for client-side logic (sorting, adding/removing from list), we use refs.
836
- const initialComputedFilter = where ? buildFilterString(where) : realtimeFilter;
837
- console.log(\`Setting up subscription for \${tableName} with initial filter: \${initialComputedFilter}\`);
915
+ // Check if we have complex array filters that should be handled client-side only
916
+ let hasComplexArrayFilters = false;
917
+ if (where) {
918
+ for (const [key, value] of Object.entries(where)) {
919
+ if (typeof value === 'object' && value !== null) {
920
+ const advancedOps = value as any;
921
+ // Check for complex array operators
922
+ if ('has' in advancedOps || 'hasEvery' in advancedOps || 'hasSome' in advancedOps || 'isEmpty' in advancedOps) {
923
+ hasComplexArrayFilters = true;
924
+ break;
925
+ }
926
+ }
927
+ }
928
+ }
929
+
930
+ // For complex array filters, use no database filter and rely on client-side filtering
931
+ // For simple filters, use database-level filtering
932
+ let subscriptionConfig: any = {
933
+ event: '*',
934
+ schema: 'public',
935
+ table: tableName,
936
+ };
937
+
938
+ if (hasComplexArrayFilters) {
939
+ // Don't include filter at all for complex array operations
940
+ console.log(\`Setting up subscription for \${tableName} with NO FILTER (complex array filters detected) - will receive ALL events\`);
941
+ } else if (where) {
942
+ // Include filter for simple operations
943
+ const filter = buildFilterString(where);
944
+ if (filter) {
945
+ subscriptionConfig.filter = filter;
946
+ }
947
+ console.log(\`Setting up subscription for \${tableName} with database filter: \${filter}\`);
948
+ } else if (realtimeFilter) {
949
+ // Use custom realtime filter if provided
950
+ subscriptionConfig.filter = realtimeFilter;
951
+ console.log(\`Setting up subscription for \${tableName} with custom filter: \${realtimeFilter}\`);
952
+ }
838
953
 
839
954
  const channel = supabase
840
955
  .channel(channelId)
841
956
  .on(
842
957
  'postgres_changes',
843
- {
844
- event: '*',
845
- schema: 'public',
846
- table: tableName,
847
- filter: initialComputedFilter, // Subscription filter uses initial state
848
- },
958
+ subscriptionConfig,
849
959
  (payload) => {
850
- console.log(\`Received \${payload.eventType} event for \${tableName}\`, payload);
960
+ console.log(\`🔥 REALTIME EVENT RECEIVED for \${tableName}:\`, payload.eventType, payload);
851
961
 
852
962
  // Access current options via refs inside the event handler
853
963
  const currentWhere = whereRef.current;
@@ -856,7 +966,10 @@ export function createSuparismaHook<
856
966
  const currentOffset = offsetRef.current; // Not directly used in handlers but good for consistency
857
967
 
858
968
  // Skip realtime updates when search is active
859
- if (isSearchingRef.current) return;
969
+ if (isSearchingRef.current) {
970
+ console.log('⏭️ Skipping realtime update - search is active');
971
+ return;
972
+ }
860
973
 
861
974
  if (payload.eventType === 'INSERT') {
862
975
  // Process insert event
@@ -865,14 +978,46 @@ export function createSuparismaHook<
865
978
  const newRecord = payload.new as TWithRelations;
866
979
  console.log(\`Processing INSERT for \${tableName}\`, { newRecord });
867
980
 
868
- // Check if this record matches our filter if we have one
981
+ // ALWAYS check if this record matches our filter client-side
982
+ // This is especially important for complex array filters
869
983
  if (currentWhere) { // Use ref value
870
984
  let matchesFilter = true;
871
985
 
872
- // Check each filter condition
986
+ // Check each filter condition client-side for complex filters
873
987
  for (const [key, value] of Object.entries(currentWhere)) {
874
988
  if (typeof value === 'object' && value !== null) {
875
- // Complex filter - this is handled by Supabase, assume it matches
989
+ // Handle complex array filters client-side
990
+ const advancedOps = value as any;
991
+ const recordValue = newRecord[key as keyof typeof newRecord] as any;
992
+
993
+ // Array-specific operators validation
994
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
995
+ // Array contains ANY of the specified items
996
+ if (!Array.isArray(recordValue) || !advancedOps.has.some((item: any) => recordValue.includes(item))) {
997
+ matchesFilter = false;
998
+ break;
999
+ }
1000
+ } else if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
1001
+ // Array contains ALL of the specified items
1002
+ if (!Array.isArray(recordValue) || !advancedOps.hasEvery.every((item: any) => recordValue.includes(item))) {
1003
+ matchesFilter = false;
1004
+ break;
1005
+ }
1006
+ } else if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
1007
+ // Array contains ANY of the specified items
1008
+ if (!Array.isArray(recordValue) || !advancedOps.hasSome.some((item: any) => recordValue.includes(item))) {
1009
+ matchesFilter = false;
1010
+ break;
1011
+ }
1012
+ } else if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
1013
+ // Array is empty or not empty
1014
+ const isEmpty = !Array.isArray(recordValue) || recordValue.length === 0;
1015
+ if (isEmpty !== advancedOps.isEmpty) {
1016
+ matchesFilter = false;
1017
+ break;
1018
+ }
1019
+ }
1020
+ // Add other complex filter validations as needed
876
1021
  } else if (newRecord[key as keyof typeof newRecord] !== value) {
877
1022
  matchesFilter = false;
878
1023
  console.log(\`Filter mismatch on \${key}\`, { expected: value, actual: newRecord[key as keyof typeof newRecord] });
@@ -953,12 +1098,68 @@ export function createSuparismaHook<
953
1098
  // Access current options via refs
954
1099
  const currentOrderBy = orderByRef.current;
955
1100
  const currentLimit = limitRef.current; // If needed for re-fetch logic on update
1101
+ const currentWhere = whereRef.current;
956
1102
 
957
1103
  // Skip if search is active
958
1104
  if (isSearchingRef.current) {
959
1105
  return prev;
960
1106
  }
961
1107
 
1108
+ const updatedRecord = payload.new as TWithRelations;
1109
+
1110
+ // Check if the updated record still matches our current filter
1111
+ if (currentWhere) {
1112
+ let matchesFilter = true;
1113
+
1114
+ for (const [key, value] of Object.entries(currentWhere)) {
1115
+ if (typeof value === 'object' && value !== null) {
1116
+ // Handle complex array filters client-side
1117
+ const advancedOps = value as any;
1118
+ const recordValue = updatedRecord[key as keyof typeof updatedRecord] as any;
1119
+
1120
+ // Array-specific operators validation
1121
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
1122
+ // Array contains ANY of the specified items
1123
+ if (!Array.isArray(recordValue) || !advancedOps.has.some((item: any) => recordValue.includes(item))) {
1124
+ matchesFilter = false;
1125
+ break;
1126
+ }
1127
+ } else if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
1128
+ // Array contains ALL of the specified items
1129
+ if (!Array.isArray(recordValue) || !advancedOps.hasEvery.every((item: any) => recordValue.includes(item))) {
1130
+ matchesFilter = false;
1131
+ break;
1132
+ }
1133
+ } else if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
1134
+ // Array contains ANY of the specified items
1135
+ if (!Array.isArray(recordValue) || !advancedOps.hasSome.some((item: any) => recordValue.includes(item))) {
1136
+ matchesFilter = false;
1137
+ break;
1138
+ }
1139
+ } else if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
1140
+ // Array is empty or not empty
1141
+ const isEmpty = !Array.isArray(recordValue) || recordValue.length === 0;
1142
+ if (isEmpty !== advancedOps.isEmpty) {
1143
+ matchesFilter = false;
1144
+ break;
1145
+ }
1146
+ }
1147
+ } else if (updatedRecord[key as keyof typeof updatedRecord] !== value) {
1148
+ matchesFilter = false;
1149
+ break;
1150
+ }
1151
+ }
1152
+
1153
+ // If the updated record doesn't match the filter, remove it from the list
1154
+ if (!matchesFilter) {
1155
+ console.log('Updated record no longer matches filter, removing from list');
1156
+ return prev.filter((item) =>
1157
+ // @ts-ignore: Supabase typing issue
1158
+ !('id' in item && 'id' in updatedRecord && item.id === updatedRecord.id)
1159
+ );
1160
+ }
1161
+ }
1162
+
962
1163
  const newData = prev.map((item) =>
963
1164
  // @ts-ignore: Supabase typing issue
964
1165
  'id' in item && 'id' in payload.new && item.id === payload.new.id
@@ -1010,7 +1211,10 @@ export function createSuparismaHook<
1010
1211
  });
1011
1212
  } else if (payload.eventType === 'DELETE') {
1012
1213
  // Process delete event
1214
+ console.log('🗑️ Processing DELETE event for', tableName);
1013
1215
  setData((prev) => {
1216
+ console.log('🗑️ DELETE: Current data before deletion:', prev.length, 'items');
1217
+
1014
1218
  // Access current options via refs
1015
1219
  const currentWhere = whereRef.current;
1016
1220
  const currentOrderBy = orderByRef.current;
@@ -1019,6 +1223,7 @@ export function createSuparismaHook<
1019
1223
 
1020
1224
  // Skip if search is active
1021
1225
  if (isSearchingRef.current) {
1226
+ console.log('⏭️ DELETE: Skipping - search is active');
1022
1227
  return prev;
1023
1228
  }
1024
1229
 
@@ -1028,15 +1233,21 @@ export function createSuparismaHook<
1028
1233
  // Filter out the deleted item
1029
1234
  const filteredData = prev.filter((item) => {
1030
1235
  // @ts-ignore: Supabase typing issue
1031
- return !('id' in item && 'id' in payload.old && item.id === payload.old.id);
1236
+ const shouldKeep = !('id' in item && 'id' in payload.old && item.id === payload.old.id);
1237
+ if (!shouldKeep) {
1238
+ console.log('🗑️ DELETE: Removing item with ID:', item.id);
1239
+ }
1240
+ return shouldKeep;
1032
1241
  });
1033
1242
 
1243
+ console.log('🗑️ DELETE: Data after deletion:', filteredData.length, 'items (was', currentSize, ')');
1244
+
1034
1245
  // Fetch the updated count after the data changes
1035
1246
  setTimeout(() => fetchTotalCount(), 0);
1036
1247
 
1037
1248
  // If we need to maintain the size with a limit
1038
1249
  if (currentLimit && currentLimit > 0 && filteredData.length < currentSize && currentSize === currentLimit) { // Use ref value
1039
- console.log(\`Record deleted with limit \${currentLimit}, will fetch additional record to maintain size\`);
1250
+ console.log(\`🗑️ DELETE: Record deleted with limit \${currentLimit}, will fetch additional record to maintain size\`);
1040
1251
 
1041
1252
  // Use setTimeout to ensure this state update completes first
1042
1253
  setTimeout(() => {
@@ -1113,7 +1324,7 @@ export function createSuparismaHook<
1113
1324
  searchTimeoutRef.current = null;
1114
1325
  }
1115
1326
  };
1116
- }, [realtime, channelName, tableName, initialLoadRef]); // Removed where, orderBy, limit, offset from deps
1327
+ }, [realtime, channelName, tableName, where, initialLoadRef]); // Added 'where' back so subscription updates when filter changes
1117
1328
 
1118
1329
  // Create a memoized options object to prevent unnecessary re-renders
1119
1330
  const optionsRef = useRef({ where, orderBy, limit, offset });
@@ -119,7 +119,7 @@ function generateModelTypesFile(model) {
119
119
  // Edit the generator script instead
120
120
 
121
121
  import type { ${modelName} } from '@prisma/client';
122
- import type { ModelResult, SuparismaOptions, SearchQuery, SearchState } from '../utils/core';
122
+ import type { ModelResult, SuparismaOptions, SearchQuery, SearchState, FilterOperators } from '../utils/core';
123
123
 
124
124
  /**
125
125
  * Extended ${modelName} type that includes relation fields.
@@ -208,8 +208,64 @@ ${withRelationsProps
208
208
  .join(',\n')}
209
209
  * }
210
210
  * });
211
+ *
212
+ * @example
213
+ * // Array filtering (for array fields)
214
+ * ${modelName.toLowerCase()}.findMany({
215
+ * where: {
216
+ * // Array contains specific items
217
+ ${withRelationsProps
218
+ .filter((p) => p.includes('[]'))
219
+ .slice(0, 1)
220
+ .map((p) => {
221
+ const field = p.trim().split(':')[0].trim();
222
+ return ` * ${field}: { has: ["item1", "item2"] }`;
223
+ })
224
+ .join(',\n')}
225
+ * }
226
+ * });
211
227
  */
212
- export type ${modelName}WhereInput = Partial<${modelName}WithRelations>;
228
+ export type ${modelName}WhereInput = {
229
+ ${model.fields
230
+ .filter((field) => !relationObjectFields.includes(field.name) && !foreignKeyFields.includes(field.name))
231
+ .map((field) => {
232
+ const isOptional = true; // All where fields are optional
233
+ let baseType;
234
+ switch (field.type) {
235
+ case 'Int':
236
+ case 'Float':
237
+ baseType = 'number';
238
+ break;
239
+ case 'Boolean':
240
+ baseType = 'boolean';
241
+ break;
242
+ case 'DateTime':
243
+ baseType = 'string'; // ISO date string
244
+ break;
245
+ case 'Json':
246
+ baseType = 'any'; // Or a more specific structured type if available
247
+ break;
248
+ default:
249
+ // Covers String, Enum names (e.g., "SomeEnum"), Bytes, Decimal, etc.
250
+ baseType = 'string';
251
+ }
252
+ const finalType = field.isList ? `${baseType}[]` : baseType;
253
+ const filterType = `${finalType} | FilterOperators<${finalType}>`;
254
+ return ` ${field.name}${isOptional ? '?' : ''}: ${filterType};`;
255
+ })
256
+ .concat(
257
+ // Add foreign key fields
258
+ foreignKeyFields.map((field) => {
259
+ const fieldInfo = model.fields.find((f) => f.name === field);
260
+ if (fieldInfo) {
261
+ const baseType = fieldInfo.type === 'Int' ? 'number' : 'string';
262
+ const filterType = `${baseType} | FilterOperators<${baseType}>`;
263
+ return ` ${field}?: ${filterType};`;
264
+ }
265
+ return '';
266
+ }).filter(Boolean))
267
+ .join('\n')}
268
+ };
213
269
 
214
270
  /**
215
271
  * Unique identifier for finding a specific ${modelName} record.
package/dist/parser.js CHANGED
@@ -12,6 +12,16 @@ function parsePrismaSchema(schemaPath) {
12
12
  const schema = fs_1.default.readFileSync(schemaPath, 'utf-8');
13
13
  const modelRegex = /model\s+(\w+)\s+{([^}]*)}/gs;
14
14
  const models = [];
15
+ // Extract enum names from the schema
16
+ const enumRegex = /enum\s+(\w+)\s+{[^}]*}/gs;
17
+ const enumNames = [];
18
+ let enumMatch;
19
+ while ((enumMatch = enumRegex.exec(schema)) !== null) {
20
+ const enumName = enumMatch[1];
21
+ if (enumName) {
22
+ enumNames.push(enumName);
23
+ }
24
+ }
15
25
  let match;
16
26
  while ((match = modelRegex.exec(schema)) !== null) {
17
27
  const modelName = match[1] || '';
@@ -45,7 +55,7 @@ function parsePrismaSchema(schemaPath) {
45
55
  continue;
46
56
  }
47
57
  // Parse field definition - Updated to handle array types
48
- const fieldMatch = line.match(/\s*(\w+)\s+(\w+)(\[\])?\??(\?)?\s*(?:@[^)]+)?/);
58
+ const fieldMatch = line.match(/\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*(?:@[^)]+)?/);
49
59
  if (fieldMatch) {
50
60
  const fieldName = fieldMatch[1];
51
61
  const baseFieldType = fieldMatch[2]; // e.g., "String" from "String[]"
@@ -67,9 +77,13 @@ function parsePrismaSchema(schemaPath) {
67
77
  defaultValue = defaultMatch[1];
68
78
  }
69
79
  }
80
+ // Improved relation detection
81
+ const primitiveTypes = ['String', 'Int', 'Float', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Decimal', 'BigInt'];
70
82
  const isRelation = line.includes('@relation') ||
71
83
  (!!fieldName &&
72
- (fieldName.endsWith('_id') || fieldName === 'userId' || fieldName === 'user_id'));
84
+ (fieldName.endsWith('_id') || fieldName === 'userId' || fieldName === 'user_id')) ||
85
+ // Also detect relation fields by checking if the type is not a primitive type and not an enum
86
+ (!!baseFieldType && !primitiveTypes.includes(baseFieldType) && !enumNames.includes(baseFieldType));
73
87
  // Check for inline @enableSearch comment
74
88
  if (line.includes('// @enableSearch')) {
75
89
  searchFields.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suparisma",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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": {
@@ -19,6 +19,14 @@ enum SomeEnum {
19
19
  THREE
20
20
  }
21
21
 
22
+ model User {
23
+ id String @id @default(uuid())
24
+ name String
25
+ email String @unique
26
+ things Thing[] // One-to-many relation
27
+ createdAt DateTime @default(now())
28
+ updatedAt DateTime @updatedAt
29
+ }
22
30
 
23
31
  model Thing {
24
32
  id String @id @default(uuid())
@@ -27,6 +35,8 @@ model Thing {
27
35
  stringArray String[]
28
36
  someEnum SomeEnum @default(ONE)
29
37
  someNumber Int?
38
+ userId String?
39
+ user User? @relation(fields: [userId], references: [id])
30
40
  createdAt DateTime @default(now())
31
41
  updatedAt DateTime @updatedAt
32
42
  }